收紧退款人工复核与重试契约

补齐退款事实对比字段与人工复核审计展示
人工复核重放严格校验管理员、原因和错误码
退款占用重试完整匹配管理员与归一化原因
统一退款原因持久化上限并修复刷新恢复
补充前后端回归测试、生成绑定与数据契约文档
This commit is contained in:
2026-07-14 19:20:25 +08:00
parent c70a33d682
commit a597fcd076
15 changed files with 480 additions and 36 deletions
+25 -13
View File
@@ -1,4 +1,4 @@
import {afterEach, expect, test, vi} from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import {
executeAdminRechargeRefund,
@@ -13,9 +13,9 @@ afterEach(() => {
test('充值订单查询按后台契约序列化筛选参数', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({entries: []}), {
new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: {'content-type': 'application/json'},
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
@@ -47,12 +47,14 @@ test('充值订单查询按后台契约序列化筛选参数', async () => {
});
test('用户详情只发送实际提供的用户定位字段', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({userId: 'user-1'}), {status: 200}),
);
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ userId: 'user-1' }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
await getAdminUserDetail('token-1', {publicUserCode: 'TN1001'});
await getAdminUserDetail('token-1', { publicUserCode: 'TN1001' });
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
const parsed = new URL(requestUrl, 'http://admin.local');
@@ -63,9 +65,13 @@ test('用户详情只发送实际提供的用户定位字段', async () => {
});
test('退款执行使用独立 execute 管理员路由', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}),
);
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await executeAdminRechargeRefund('token-1', {
@@ -92,14 +98,19 @@ test('退款执行使用独立 execute 管理员路由', async () => {
});
test('退款人工复核使用独立 resolve 管理员路由', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}),
);
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await resolveAdminRechargeRefundManualReview('token-1', {
outRefundNo: 'refund-1',
reason: '已核对微信商户平台原始账单',
expectedErrorCode: 'provider_transaction_id_mismatch',
});
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
@@ -111,6 +122,7 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
body: JSON.stringify({
outRefundNo: 'refund-1',
reason: '已核对微信商户平台原始账单',
expectedErrorCode: 'provider_transaction_id_mismatch',
}),
}),
);
+4
View File
@@ -785,7 +785,9 @@ export interface AdminProfileWalletPayload {
export interface AdminRechargeRefundPayload {
outRefundNo: string;
providerRefundId: string;
providerTransactionId: string;
providerStatus: string;
totalCents: number;
refundCents: number;
payerRefundCents: number;
successAtMicros?: number | null;
@@ -800,6 +802,7 @@ export interface AdminRechargeRefundPayload {
manualReviewResolvedByAdminUserId?: string | null;
manualReviewResolutionReason?: string | null;
manualReviewResolvedAtMicros?: number | null;
manualReviewResolvedErrorCode?: string | null;
}
export interface AdminRechargeRefundHoldPayload {
@@ -882,6 +885,7 @@ export interface AdminRechargeRefundRegisterRequest {
export interface AdminRechargeRefundManualReviewResolveRequest {
outRefundNo: string;
reason: string;
expectedErrorCode: string;
}
export interface AdminWalletRestrictionRequest {
@@ -355,6 +355,56 @@ test('刷新后根据 active hold 恢复原退款请求并复用 requestId', asy
expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1);
});
test('超长退款原因按服务端上限持久化并可在刷新后恢复', async () => {
const user = userEvent.setup();
const normalizedReason = '退'.repeat(80);
vi.mocked(executeAdminRechargeRefund)
.mockRejectedValueOnce(new Error('network disconnected'))
.mockResolvedValueOnce(actionFor(baseOrder));
const firstRender = renderPage();
await openPartialRefund(user);
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
await user.type(screen.getByLabelText('退款原因'), `${normalizedReason}`);
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
await user.click(screen.getByRole('button', { name: '确认退款' }));
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock
.calls[0]?.[1].requestId;
expect(vi.mocked(executeAdminRechargeRefund).mock.calls[0]?.[1].reason).toBe(
normalizedReason,
);
firstRender.unmount();
const activeHoldOrder = orderWithActiveHold();
activeHoldOrder.activeHold = {
...activeHoldOrder.activeHold!,
reason: normalizedReason,
};
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
entries: [activeHoldOrder],
});
renderPage();
await user.click(await screen.findByRole('button', { name: '核对退款' }));
expect(screen.getByRole('dialog', { name: '退款处理' })).toBeTruthy();
expect(screen.getByLabelText('退款原因')).toHaveProperty(
'value',
normalizedReason,
);
await user.click(screen.getByRole('button', { name: '继续核对退款' }));
await waitFor(() =>
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2),
);
expect(vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
reason: normalizedReason,
requestId: firstRequestId,
}),
);
});
test('过期退款请求上下文不会恢复 active hold,只开放安全查单', async () => {
const user = userEvent.setup();
vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce(
@@ -474,7 +524,16 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])(
entries: [
{
...baseOrder,
refunds: [manualReviewRefund({ lastErrorCode })],
refunds: [
manualReviewRefund({
lastErrorCode,
providerTransactionId:
lastErrorCode === 'provider_transaction_id_mismatch'
? 'wx-transaction-other'
: 'wx-transaction-1',
totalCents: lastErrorCode === 'order_total_mismatch' ? 700 : 600,
}),
],
},
],
});
@@ -485,6 +544,29 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])(
const submitButton = within(dialog).getByRole('button', {
name: '确认并追回',
});
expect(
within(dialog).getByText(
lastErrorCode === 'provider_transaction_id_mismatch'
? '微信交易单号不一致'
: '订单总额不一致',
),
).toBeTruthy();
expect(
within(dialog).getByText('本地微信交易单号').parentElement?.textContent,
).toContain('wx-transaction-1');
expect(
within(dialog).getByText('退款微信交易单号').parentElement?.textContent,
).toContain(
lastErrorCode === 'provider_transaction_id_mismatch'
? 'wx-transaction-other'
: 'wx-transaction-1',
);
expect(
within(dialog).getByText('本地订单总额').parentElement?.textContent,
).toContain('¥6.00');
expect(
within(dialog).getByText('退款订单总额').parentElement?.textContent,
).toContain(lastErrorCode === 'order_total_mismatch' ? '¥7.00' : '¥6.00');
expect(submitButton.hasAttribute('disabled')).toBe(true);
await user.type(
within(dialog).getByRole('textbox', { name: '人工复核原因' }),
@@ -498,6 +580,7 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])(
{
outRefundNo: 'refund-manual-1',
reason: '已核对微信商户平台原始账单',
expectedErrorCode: lastErrorCode,
},
);
});
@@ -516,7 +599,12 @@ test('非白名单、已处理和会员退款不显示人工复核按钮', async
...baseOrder,
orderId: 'order-resolved',
refunds: [
manualReviewRefund({ manualReviewResolvedAtMicros: 1_720_000 }),
manualReviewRefund({
manualReviewResolvedAtMicros: 1_720_000,
manualReviewResolvedByAdminUserId: 'admin-1',
manualReviewResolutionReason: '已核对商户平台',
manualReviewResolvedErrorCode: 'provider_transaction_id_mismatch',
}),
],
},
{
@@ -531,6 +619,10 @@ test('非白名单、已处理和会员退款不显示人工复核按钮', async
await screen.findByText('order-membership');
expect(screen.queryByRole('button', { name: '人工复核' })).toBeNull();
expect(screen.getByText(/复核 微信交易单号不一致/).textContent).toContain(
'admin-1',
);
expect(screen.getByText(/已核对商户平台/)).toBeTruthy();
});
test('支付侧已退款但泥点不足时持续展示异常欠账与消费限制', async () => {
@@ -650,7 +742,9 @@ function manualReviewRefund(
return {
outRefundNo: 'refund-manual-1',
providerRefundId: 'provider-refund-1',
providerTransactionId: 'wx-transaction-other',
providerStatus: 'SUCCESS',
totalCents: 600,
refundCents: 300,
payerRefundCents: 300,
successAtMicros: 1_720_000_000_000_000,
@@ -665,6 +759,7 @@ function manualReviewRefund(
manualReviewResolvedByAdminUserId: null,
manualReviewResolutionReason: null,
manualReviewResolvedAtMicros: null,
manualReviewResolvedErrorCode: null,
...overrides,
};
}
@@ -59,7 +59,7 @@ interface PersistedRefundRequestContext {
}
interface ManualReviewTarget {
orderId: string;
order: AdminRechargeOrderEntryPayload;
refund: AdminRechargeRefundPayload;
}
@@ -277,7 +277,7 @@ export function AdminRechargeOrderPage({
) {
return;
}
const reason = refundReason.trim();
const reason = normalizeRefundReason(refundReason);
if (!reason) {
setRefundError('请填写退款原因');
return;
@@ -403,7 +403,7 @@ export function AdminRechargeOrderPage({
order: AdminRechargeOrderEntryPayload,
refund: AdminRechargeRefundPayload,
) {
setManualReviewTarget({ orderId: order.orderId, refund });
setManualReviewTarget({ order, refund });
setManualReviewReason('');
setManualReviewError('');
}
@@ -411,15 +411,23 @@ export function AdminRechargeOrderPage({
async function handleResolveManualReview(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const reason = manualReviewReason.trim();
if (!manualReviewTarget || manualReviewLockRef.current || !reason) {
const expectedErrorCode = manualReviewTarget?.refund.lastErrorCode?.trim();
if (
!manualReviewTarget ||
manualReviewLockRef.current ||
!reason ||
!expectedErrorCode
) {
if (!reason) {
setManualReviewError('请填写人工复核原因');
} else if (!expectedErrorCode) {
setManualReviewError('退款人工复核状态已变化,请刷新后重试');
}
return;
}
const confirmed = await confirmWrite({
action: '确认微信退款并追回泥点',
target: `${manualReviewTarget.orderId} / ${manualReviewTarget.refund.outRefundNo}`,
target: `${manualReviewTarget.order.orderId} / ${manualReviewTarget.refund.outRefundNo}`,
});
if (!confirmed || manualReviewLockRef.current) {
return;
@@ -432,6 +440,7 @@ export function AdminRechargeOrderPage({
const response = await resolveAdminRechargeRefundManualReview(token, {
outRefundNo: manualReviewTarget.refund.outRefundNo,
reason,
expectedErrorCode,
});
setLastAction(response);
upsertOrder(response.order);
@@ -773,6 +782,7 @@ export function AdminRechargeOrderPage({
<input
aria-label="退款原因"
disabled={isSubmitting || Boolean(resumableRefundRequest)}
maxLength={80}
value={refundReason}
onChange={(event) => setRefundReason(event.target.value)}
/>
@@ -967,6 +977,36 @@ export function AdminRechargeOrderPage({
<X size={18} aria-hidden="true" />
</button>
</div>
<dl className="admin-info-list" aria-label="退款冲突核对信息">
<div>
<dt></dt>
<dd>
{formatManualReviewErrorCode(
manualReviewTarget.refund.lastErrorCode,
)}
</dd>
</div>
<div>
<dt></dt>
<dd>{manualReviewTarget.order.providerTransactionId || '-'}</dd>
</div>
<div>
<dt>退</dt>
<dd>{manualReviewTarget.refund.providerTransactionId}</dd>
</div>
<div>
<dt></dt>
<dd>{formatMoney(manualReviewTarget.order.amountCents)}</dd>
</div>
<div>
<dt>退</dt>
<dd>{formatMoney(manualReviewTarget.refund.totalCents)}</dd>
</div>
<div>
<dt>退</dt>
<dd>{formatMoney(manualReviewTarget.refund.refundCents)}</dd>
</div>
</dl>
<label className="admin-field">
<span></span>
<textarea
@@ -1048,6 +1088,7 @@ function RechargeOrderRow({
}) {
const userName = order.user?.displayName || '未读取用户资料';
const manualReviewRefund = findResolvableManualReviewRefund(order);
const resolvedManualReviewRefund = findResolvedManualReviewRefund(order);
return (
<tr>
<td>
@@ -1089,6 +1130,25 @@ function RechargeOrderRow({
{order.activeHold ? (
<small> {order.activeHold.heldPoints} </small>
) : null}
{resolvedManualReviewRefund ? (
<>
<small>
{' '}
{formatManualReviewErrorCode(
resolvedManualReviewRefund.manualReviewResolvedErrorCode,
)}{' '}
/{' '}
{resolvedManualReviewRefund.manualReviewResolvedByAdminUserId ||
'-'}
</small>
<small>
{resolvedManualReviewRefund.manualReviewResolutionReason || '-'} /{' '}
{formatMicros(
resolvedManualReviewRefund.manualReviewResolvedAtMicros ?? 0,
)}
</small>
</>
) : null}
</td>
<td>
<span> {order.wallet.spendableBalance}</span>
@@ -1322,6 +1382,19 @@ function formatRefundBlockReason(code?: string | null) {
return labels[code.toLowerCase()] ?? `当前不可退款:${code}`;
}
function formatManualReviewErrorCode(code?: string | null) {
const normalized = code?.trim().toLowerCase();
const labels: Record<string, string> = {
provider_transaction_id_mismatch: '微信交易单号不一致',
order_total_mismatch: '订单总额不一致',
};
return normalized ? (labels[normalized] ?? normalized) : '-';
}
function normalizeRefundReason(value: string) {
return Array.from(value.trim()).slice(0, 80).join('');
}
function datetimeLocalToRfc3339(value: string) {
const normalized = value.trim();
if (!normalized) {
@@ -1370,6 +1443,16 @@ function findResolvableManualReviewRefund(
);
}
function findResolvedManualReviewRefund(order: AdminRechargeOrderEntryPayload) {
return order.refunds.find(
(refund) =>
Boolean(refund.manualReviewResolvedErrorCode) &&
Boolean(refund.manualReviewResolvedByAdminUserId) &&
Boolean(refund.manualReviewResolutionReason) &&
Boolean(refund.manualReviewResolvedAtMicros),
);
}
function createRequestId() {
if (
typeof crypto !== 'undefined' &&
@@ -4079,9 +4079,9 @@
## 2026-07-13 后台充值退款使用钱包占用与统一用户详情
- 背景:普通 V3 退款已经能从回调、查单和账单收口现金事实,但后台主动退款若先调微信再扣泥点,会在用户余额不足时产生本可避免的欠账;后台各页面也没有统一查询用户余额、绑定状态和充值订单的入口。
- 决策:新增 `profile_recharge_refund_hold`,后台执行退款前按累计部分退款公式原子占用本次应追回的永久泥点,再使用客户端稳定 `requestId` 派生 `out_refund_no` 调微信。部分退款额外占用 1 泥点并发舍入缓冲,防止占用创建后到达的外部退款跨越累计 `floor` 边界;全额退款不加缓冲。`SUCCESS` 扣款并结算匹配占用,`CLOSED` 释放,`PROCESSING / ABNORMAL` 保持;结果未知时不擅自释放,至少等待 10 分钟并连续 3 次退款查单收到官方 `RESOURCE_NOT_EXISTS` 才由 worker 释放。普通消费必须排除全部活动占用。
- 决策:新增 `profile_recharge_refund_hold`,后台执行退款前按累计部分退款公式原子占用本次应追回的永久泥点,再使用客户端稳定 `requestId` 派生 `out_refund_no` 调微信。重试必须同时匹配原订单、退款号、退款金额、管理员和归一化原因,不能绕过底层完整幂等校验。部分退款额外占用 1 泥点并发舍入缓冲,防止占用创建后到达的外部退款跨越累计 `floor` 边界;全额退款不加缓冲。`SUCCESS` 扣款并结算匹配占用,`CLOSED` 释放,`PROCESSING / ABNORMAL` 保持;结果未知时不擅自释放,至少等待 10 分钟并连续 3 次退款查单收到官方 `RESOURCE_NOT_EXISTS` 才由 worker 释放。普通消费必须排除全部活动占用。
- 欠账与冻结:支付侧已经成功退款时不能回滚现金事实;永久泥点不足部分继续只写订单 settlement 的 `unrecovered_points`,限制普通消费,后续永久泥点优先自动偿还。每日免费和会员周期泥点不参与。人工冻结单独使用 `profile_wallet_manual_restriction`,解除人工冻结不解除退款欠账限制。
- 人工复核:交易号或订单总额冲突只允许管理员确认退款归属,退款行追加不可变的管理员、原因、时间和获批错误码重新运行标准结算;一次确认只豁免对应冲突,其他不变量继续 fail-closed。该操作不是“直接解冻”,余额不足仍形成欠账。会员退款、未知错误和非法结算计划不显示该入口,也不能复用泥点钱包冻结语义。
- 人工复核:交易号或订单总额冲突只允许管理员确认退款归属;管理员 DTO 与确认面板必须并排展示本地订单和微信退款事实的交易号、订单总额与冲突类型。提交请求携带界面所见 `expectedErrorCode`SpacetimeDB 事务核对当前错误码一致后,退款行追加不可变的管理员、原因、时间和获批错误码重新运行标准结算;已处理请求只有管理员、归一化原因和错误码全部一致时才视为幂等重放,状态变化或任一审计内容不一致继续 fail-closed。获批错误码通过正式读取契约展示,一次确认只豁免对应冲突,其他不变量继续 fail-closed。该操作不是“直接解冻”,余额不足仍形成欠账。会员退款、未知错误和非法结算计划不显示该入口,也不能复用泥点钱包冻结语义。
- 发布基线:退款功能和退款事实表尚未进入 release,首次上线只以 release 已有 schema 和数据为迁移基线;不为 master 上未发布的退款中间结构保留旧行 normalization 或历史人工复核兼容。
- 后台边界:充值订单、预检、执行、应急退款号登记、用户详情和钱包冻结均只挂在管理员鉴权路由。用户详情由 `user_id` 或陶泥号经认证服务解析,返回头像、昵称、脱敏手机号、绑定状态、钱包分桶、占用、欠账和最近订单;后台语义明确的用户字段复用同一个图标按钮和弹窗,管理员主体及 `admin:*` 合成 ID 不打开用户详情。
- 部分退款预检:微信支付查单 `trade_state=REFUND` 只表示已发生退款,不代表全额退款。刷新已登记退款后,本地累计成功退款大于 0 且小于订单总额、且不存在非终态退款、活动 hold、欠账或人工冻结时,可以继续退本地剩余额度;没有本地成功退款事实能解释 `REFUND` 时继续失败关闭并要求登记或账单对账。
@@ -205,14 +205,14 @@ npm run check:server-rs-ddd
15. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 只订阅这张活跃 timer 表的删除事件,收到 `order_id` 后通过 procedure 重新读取订单,只有状态确认为 `expired` 才执行微信查单补偿;支付或主动关闭同样会删除 timer,但会被状态判断忽略。监听断线期间遗漏的删除事件由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。普通微信支付查单中 `SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired``CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`wechat_mp_virtual` 使用小程序 `access_token` 和虚拟支付 AppKey 调用官方 `/xpay/query_order`,只在返回单号、支付类型 `order_type=0/7`、金额、合法 `paid_time` 与本地契约一致且状态为 `2/3/4` 时补入账;退款类型 `1/8` 不得触发充值,其余已知状态只记录检查结果。`short_series_goods``status=2` 恢复时先幂等入账,再调用 `/xpay/notify_provide_goods`,失败后允许基于本地 `paid` 状态只重试发货;`short_series_coin` 不调用现金单发货接口。`external-generation-worker` / controller 不处理充值过期。
16. 普通微信支付 V3 退款事实由 `profile_recharge_refund` 保存,回调、退款 API 响应、主动查单和交易账单发现统一调用 `record_profile_recharge_refund_observation_and_return``out_refund_no` 是商户幂等键,`provider_refund_id` 唯一;退款申请响应和主动查单等生产者生成 observation ID 时必须同时纳入来源和事实指纹,同一来源同一事实稳定重放、不同来源不得复用 ID;重复 observation 必须校验订单、交易、金额、状态、来源和指纹,不允许仅按主键直接吞掉冲突。
17. `profile_recharge_order_refund_settlement` 按原订单聚合累计成功退款金额和权益回收。部分退款不改充值订单 `paid`;累计金额等于订单金额时才改为 `refunded`。历史支付和首充资格以 `paid_at` 是否存在判断,退款不把用户重新变成首充。
18. 泥点退款按累计成功退款比例计算目标回收量,全额退款强制精确回收原 `points_delta`。自动回收只扣普通永久泥点,每日免费和会员周期限时泥点保持不变;不足部分持久化为 `shortfall` 并冻结正式钱包消费,后续 worker 只重试本地回收。已成功退款的泥点订单若出现微信交易号、订单总额冲突或结算计划无效,必须将 settlement 标记为 `wallet_frozen` 并阻断普通消费。管理员只能对交易号或订单总额冲突执行“确认退款归属”:procedure 在退款行尾部不可变记录管理员、原因、时间和本次获批的错误码,再重新运行标准结算;一次确认只豁免对应的交易号或订单总额冲突,渠道、支付状态及其他未获批冲突继续 fail-closed。能追回的永久泥点照常扣除,余额不足继续形成欠账;不能直接清空冻结或伪造已追回量。非法结算计划仍保持冻结等待数据/代码修复。流水来源为 `recharge_refund_recovery`。会员充值没有可逆 grant 快照,退款统一标记 `manual_review`,不猜测回滚档位、有效期或周期泥点,也不复用泥点退款冻结语义。
18. 泥点退款按累计成功退款比例计算目标回收量,全额退款强制精确回收原 `points_delta`。自动回收只扣普通永久泥点,每日免费和会员周期限时泥点保持不变;不足部分持久化为 `shortfall` 并冻结正式钱包消费,后续 worker 只重试本地回收。已成功退款的泥点订单若出现微信交易号、订单总额冲突或结算计划无效,必须将 settlement 标记为 `wallet_frozen` 并阻断普通消费。管理员只能对交易号或订单总额冲突执行“确认退款归属”:BFF 必须把微信退款事实的交易号、订单总额、当前冲突类型与本地订单值并排下发并展示,提交时携带管理员实际看到的 `expectedErrorCode`SpacetimeDB procedure 在同一事务内核对当前错误码一致后,才在退款行尾部不可变记录管理员、原因、时间和本次获批的错误码重新运行标准结算;已处理请求只有管理员、归一化原因和错误码全部一致时才视为幂等重放,状态变化或任一审计内容不一致必须 fail-closed。一次确认只豁免对应的交易号或订单总额冲突,渠道、支付状态及其他未获批冲突继续 fail-closed。能追回的永久泥点照常扣除,余额不足继续形成欠账;不能直接清空冻结或伪造已追回量。非法结算计划仍保持冻结等待数据/代码修复。流水来源为 `recharge_refund_recovery`。会员充值没有可逆 grant 快照,退款统一标记 `manual_review`,不猜测回滚档位、有效期或周期泥点,也不复用泥点退款冻结语义。
19. 外部现金退款 `SUCCESS` 必须先持久化并 ACK,即使本地订单缺失、金额冲突、权益不足或会员需要人工处理,也不能回滚已经发生的现金事实。冲突 observation 记录 resolution code 并进入告警;只有验签、解密、契约解析或 SpacetimeDB 持久化失败才让微信重试。
20. 主动查询与退款交易账单 worker 只由 HTTP 角色运行并由 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true` 显式开启。生产 env 示例和 API deploy 会为缺失配置补 `true`;当 `WECHAT_PAY_ENABLED=true + WECHAT_PAY_PROVIDER=real` 时显式关闭该开关必须阻断发布,启动日志也必须明确记录启用或异常关闭状态。非终态退款按 1 / 5 / 10 / 20 / 30 分钟衰减查单;北京时间次日 10 点后按 30 个稳定分片轮转补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,每 30 分钟覆盖完整窗口。单行失败不阻塞同日其他退款或其他日期,但该日不写完成 checkpoint 并继续重试;昨日返回 `NO_STATEMENT_EXIST` 时至少延迟到次日 10 点后再确认空账单。账单申请响应验签,GZIP 解压后按 SHA1 验真,CSV 用结构化 parser 和十进制定点金额解析;发现手工退款后必须再查单取得当前状态。
21. 普通 V3 支付通知同时校验 AppID、商户号、本地订单渠道、金额和微信支付单号;`success_time` 缺失或非法时拒绝,不能用本机时间补齐。晚到通知遇到 `refunded` 订单只做交易号一致性幂等校验,不再次发放权益。
22. 后台主动退款只支持 `wechat_mp``wechat_jsapi``wechat_h5``wechat_native` 普通 V3 泥点订单。`api-server` 必须先按正式支付渠道和商品类型拦截不支持的订单,再做微信支付订单查单预检,然后调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。`wechat_mp_virtual`、历史非正式渠道值、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须在调用普通 V3 provider 前 fail-closed。
23. `profile_recharge_refund_hold` 以稳定 `out_refund_no` 为主键,保存订单、用户、本次退款金额、占用永久泥点、管理员、原因和 `active / settled / released` 状态。部分退款的 hold 在累计应追回增量之外额外保留 1 泥点并发舍入缓冲,全额退款不加缓冲;活动 hold 不改变钱包总额,但普通钱包消费必须预留全部活动 hold;成功退款 observation 扣款并结算匹配 hold,关闭退款释放 hold,外部退款追回不得消耗其他活动 hold。
23. `profile_recharge_refund_hold` 以稳定 `out_refund_no` 为主键,保存订单、用户、本次退款金额、占用永久泥点、管理员、原因和 `active / settled / released` 状态。重试只有在订单、`out_refund_no`、退款金额、管理员和归一化原因全部与原 hold 一致时才可复用,任一不一致都按幂等内容冲突 fail-closed,不能用新原因调用微信后保留旧审计。部分退款的 hold 在累计应追回增量之外额外保留 1 泥点并发舍入缓冲,全额退款不加缓冲;活动 hold 不改变钱包总额,但普通钱包消费必须预留全部活动 hold;成功退款 observation 扣款并结算匹配 hold,关闭退款释放 hold,外部退款追回不得消耗其他活动 hold。
24. 退款欠账继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 为唯一真相;不新增平行 debt 累计。`profile_wallet_manual_restriction` 只保存人工冻结,普通消费同时检查人工冻结与退款欠账。后续永久泥点到账后继续偿还欠账,每日免费与会员周期泥点不参与;解除人工冻结不得清除退款欠账限制。
25. 管理员充值订单、用户详情、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,并调用 runtime service identity 受限 procedure;前端不得自行改 settlement 或钱包冻结。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。
25. 管理员充值订单、用户详情、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,返回微信退款交易号、订单总额以及获批错误码等正式审计字段,并调用 runtime service identity 受限 procedure后台确认面板必须展示这些后端事实,前端不得自行改 settlement 或钱包冻结。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。
## 创作入口泥点扣费契约
@@ -762,7 +762,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`ProfileRechargeRefund`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因、处理时间和本次获批的错误码;四字段只写一次,作为重新执行正式结算的审计事实。
- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。管理员读取契约同步暴露微信交易号、订单总额和人工复核获批错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因、处理时间和本次获批的错误码;四字段只写一次,作为重新执行正式结算的审计事实。
- 索引:`by_profile_recharge_refund_order_id``by_profile_recharge_refund_status_updated_at`
### `profile_recharge_refund_observation`
@@ -257,11 +257,18 @@ pub async fn admin_execute_recharge_refund(
.map_err(|error| error_response(&request_context, error))?;
let out_refund_no = build_out_refund_no(&payload.order_id, request_id);
let refund_reason = normalize_refund_reason(payload.reason.as_deref());
let admin_user_id = admin.session().subject.clone();
let mut entry = load_order(&state, &request_context, &payload.order_id).await?;
validate_refund_provider_route(&entry)
.map_err(|error| error_response(&request_context, error))?;
let has_matching_hold = entry.active_hold.as_ref().is_some_and(|hold| {
hold.out_refund_no == out_refund_no && hold.refund_cents == payload.refund_amount_cents
refund_hold_matches_execute_request(
hold,
&out_refund_no,
payload.refund_amount_cents,
&admin_user_id,
&refund_reason,
)
});
if entry.active_hold.is_some() && !has_matching_hold {
@@ -311,7 +318,7 @@ pub async fn admin_execute_recharge_refund(
entry.order.order_id.clone(),
out_refund_no.clone(),
payload.refund_amount_cents,
admin.session().subject.clone(),
admin_user_id,
refund_reason.clone(),
)
.map_err(|message| {
@@ -465,6 +472,7 @@ pub async fn admin_resolve_recharge_refund_manual_review(
payload.out_refund_no,
admin.session().subject.clone(),
payload.reason,
payload.expected_error_code,
)
.map_err(|message| {
error_response(
@@ -908,7 +916,9 @@ fn map_refund(refund: RuntimeProfileRechargeRefundSnapshot) -> AdminRechargeRefu
AdminRechargeRefundPayload {
out_refund_no: refund.out_refund_no,
provider_refund_id: refund.provider_refund_id,
provider_transaction_id: refund.provider_transaction_id,
provider_status: refund.provider_status.as_str().to_string(),
total_cents: refund.total_cents,
refund_cents: refund.refund_cents,
payer_refund_cents: refund.payer_refund_cents,
success_at_micros: refund.success_at_micros,
@@ -923,6 +933,7 @@ fn map_refund(refund: RuntimeProfileRechargeRefundSnapshot) -> AdminRechargeRefu
manual_review_resolved_by_admin_user_id: refund.manual_review_resolved_by_admin_user_id,
manual_review_resolution_reason: refund.manual_review_resolution_reason,
manual_review_resolved_at_micros: refund.manual_review_resolved_at_micros,
manual_review_resolved_error_code: refund.manual_review_resolved_error_code,
}
}
@@ -1088,6 +1099,19 @@ fn validate_prepared_refund_hold_status(
Ok(())
}
fn refund_hold_matches_execute_request(
hold: &RuntimeProfileRechargeRefundHoldSnapshot,
out_refund_no: &str,
refund_cents: u64,
admin_user_id: &str,
reason: &str,
) -> bool {
hold.out_refund_no == out_refund_no
&& hold.refund_cents == refund_cents
&& hold.admin_user_id == admin_user_id
&& hold.reason == reason
}
fn build_refund_notify_url(state: &AppState) -> Result<String, AppError> {
let configured = state
.config
@@ -1239,16 +1263,19 @@ mod tests {
http::{Request, StatusCode},
};
use module_runtime::{
RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldStatus,
RuntimeProfileRechargeRefundObservationSource,
RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldSnapshot,
RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource,
RuntimeProfileRechargeRefundRecoveryStatus, RuntimeProfileRechargeRefundSnapshot,
RuntimeProfileRechargeRefundStatus,
};
use tower::ServiceExt;
use super::{
admin_refund_observation_id, build_out_refund_no, classify_refund_block_reason,
is_likely_wechat_refund_id, is_ordinary_wechat_v3_channel, normalize_request_id,
payment_check_block_reason_code, payment_trade_state_allows_additional_refund,
refund_block_message, refund_provider_route_block_reason,
is_likely_wechat_refund_id, is_ordinary_wechat_v3_channel, map_refund,
normalize_request_id, payment_check_block_reason_code,
payment_trade_state_allows_additional_refund, refund_block_message,
refund_hold_matches_execute_request, refund_provider_route_block_reason,
validate_prepared_refund_hold_status,
};
use crate::{app::build_router, config::AppConfig, state::AppState};
@@ -1296,6 +1323,45 @@ mod tests {
assert_ne!(api_request, query);
}
#[test]
fn refund_mapping_preserves_manual_review_evidence_and_audit_code() {
let mapped = map_refund(RuntimeProfileRechargeRefundSnapshot {
out_refund_no: "refund-1".to_string(),
provider_refund_id: "provider-refund-1".to_string(),
order_id: "order-1".to_string(),
provider_transaction_id: "provider-transaction-1".to_string(),
user_id: Some("user-1".to_string()),
provider_status: RuntimeProfileRechargeRefundStatus::Success,
total_cents: 600,
refund_cents: 300,
payer_total_cents: 600,
payer_refund_cents: 300,
success_at_micros: Some(2),
first_observed_at_micros: 1,
updated_at_micros: 2,
last_observation_source: RuntimeProfileRechargeRefundObservationSource::Query,
last_observation_id: "observation-1".to_string(),
order_settled_at_micros: Some(2),
target_recovery_points: 30,
recovered_points: 30,
unrecovered_points: 0,
recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Applied,
last_recovery_ledger_id: Some("ledger-1".to_string()),
last_error_code: None,
manual_review_resolved_by_admin_user_id: Some("admin-1".to_string()),
manual_review_resolution_reason: Some("已核对商户平台".to_string()),
manual_review_resolved_at_micros: Some(2),
manual_review_resolved_error_code: Some("provider_transaction_id_mismatch".to_string()),
});
assert_eq!(mapped.provider_transaction_id, "provider-transaction-1");
assert_eq!(mapped.total_cents, 600);
assert_eq!(
mapped.manual_review_resolved_error_code.as_deref(),
Some("provider_transaction_id_mismatch")
);
}
#[test]
fn refund_provider_route_uses_canonical_payment_channels() {
assert!(is_ordinary_wechat_v3_channel("wechat_mp"));
@@ -1378,6 +1444,48 @@ mod tests {
);
}
#[test]
fn active_refund_hold_retry_requires_the_original_admin_and_reason() {
let hold = RuntimeProfileRechargeRefundHoldSnapshot {
out_refund_no: "refund-1".to_string(),
order_id: "order-1".to_string(),
user_id: "user-1".to_string(),
refund_cents: 300,
held_points: 30,
status: RuntimeProfileRechargeRefundHoldStatus::Active,
admin_user_id: "admin-1".to_string(),
reason: "用户申请".to_string(),
created_at_micros: 1,
updated_at_micros: 1,
settled_at_micros: None,
released_at_micros: None,
released_by_admin_user_id: None,
release_reason: None,
};
assert!(refund_hold_matches_execute_request(
&hold,
"refund-1",
300,
"admin-1",
"用户申请",
));
assert!(!refund_hold_matches_execute_request(
&hold,
"refund-1",
300,
"admin-2",
"用户申请",
));
assert!(!refund_hold_matches_execute_request(
&hold,
"refund-1",
300,
"admin-1",
"其他原因",
));
}
#[tokio::test]
async fn recharge_management_routes_require_admin_authentication() {
let app = build_router(
@@ -949,6 +949,7 @@ mod tests {
manual_review_resolved_by_admin_user_id: None,
manual_review_resolution_reason: None,
manual_review_resolved_at_micros: None,
manual_review_resolved_error_code: None,
}
}
@@ -615,7 +615,18 @@ pub fn build_runtime_profile_recharge_refund_manual_review_resolve_input(
out_refund_no: String,
admin_user_id: String,
reason: String,
expected_error_code: String,
) -> Result<RuntimeProfileRechargeRefundManualReviewResolveInput, String> {
let expected_error_code = normalize_profile_recharge_refund_identifier(
expected_error_code,
"refund_manual_review.expected_error_code",
)?;
if !matches!(
expected_error_code.as_str(),
"provider_transaction_id_mismatch" | "order_total_mismatch"
) {
return Err("refund_manual_review.expected_error_code 不支持人工确认".to_string());
}
Ok(RuntimeProfileRechargeRefundManualReviewResolveInput {
out_refund_no: normalize_profile_recharge_refund_identifier(
out_refund_no,
@@ -626,6 +637,7 @@ pub fn build_runtime_profile_recharge_refund_manual_review_resolve_input(
"refund_manual_review.admin_user_id",
)?,
reason: normalize_profile_recharge_refund_reason(reason, "refund_manual_review.reason")?,
expected_error_code,
})
}
@@ -1456,17 +1468,32 @@ mod tests {
" refund-1 ".to_string(),
" admin-1 ".to_string(),
" 已核对微信商户平台原始账单 ".to_string(),
" provider_transaction_id_mismatch ".to_string(),
)
.expect("manual review input should normalize");
assert_eq!(input.out_refund_no, "refund-1");
assert_eq!(input.admin_user_id, "admin-1");
assert_eq!(input.reason, "已核对微信商户平台原始账单");
assert_eq!(
input.expected_error_code,
"provider_transaction_id_mismatch"
);
assert!(
build_runtime_profile_recharge_refund_manual_review_resolve_input(
"refund-1".to_string(),
"admin-1".to_string(),
" ".to_string(),
"provider_transaction_id_mismatch".to_string(),
)
.is_err()
);
assert!(
build_runtime_profile_recharge_refund_manual_review_resolve_input(
"refund-1".to_string(),
"admin-1".to_string(),
"已核对".to_string(),
"other_error".to_string(),
)
.is_err()
);
@@ -1438,6 +1438,7 @@ pub struct RuntimeProfileRechargeRefundSnapshot {
pub manual_review_resolved_by_admin_user_id: Option<String>,
pub manual_review_resolution_reason: Option<String>,
pub manual_review_resolved_at_micros: Option<i64>,
pub manual_review_resolved_error_code: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -1731,6 +1732,7 @@ pub struct RuntimeProfileRechargeRefundManualReviewResolveInput {
pub out_refund_no: String,
pub admin_user_id: String,
pub reason: String,
pub expected_error_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
+10 -3
View File
@@ -697,7 +697,9 @@ pub struct AdminProfileWalletPayload {
pub struct AdminRechargeRefundPayload {
pub out_refund_no: String,
pub provider_refund_id: String,
pub provider_transaction_id: String,
pub provider_status: String,
pub total_cents: u64,
pub refund_cents: u64,
pub payer_refund_cents: u64,
pub success_at_micros: Option<i64>,
@@ -712,6 +714,7 @@ pub struct AdminRechargeRefundPayload {
pub manual_review_resolved_by_admin_user_id: Option<String>,
pub manual_review_resolution_reason: Option<String>,
pub manual_review_resolved_at_micros: Option<i64>,
pub manual_review_resolved_error_code: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -812,6 +815,7 @@ pub struct AdminRechargeRefundRegisterRequest {
pub struct AdminRechargeRefundManualReviewResolveRequest {
pub out_refund_no: String,
pub reason: String,
pub expected_error_code: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -864,19 +868,22 @@ pub struct AdminWalletRestrictionResponse {
mod tests {
use serde_json::json;
use super::{
AdminEditorShowcaseAssetPayload, AdminRechargeRefundManualReviewResolveRequest,
};
use super::{AdminEditorShowcaseAssetPayload, AdminRechargeRefundManualReviewResolveRequest};
#[test]
fn refund_manual_review_resolution_request_uses_camel_case_contract() {
let value = serde_json::to_value(AdminRechargeRefundManualReviewResolveRequest {
out_refund_no: "refund-1".to_string(),
reason: "已核对微信商户平台原始账单".to_string(),
expected_error_code: "provider_transaction_id_mismatch".to_string(),
})
.expect("request should serialize");
assert_eq!(value["outRefundNo"], json!("refund-1"));
assert_eq!(
value["expectedErrorCode"],
json!("provider_transaction_id_mismatch")
);
assert!(value.get("out_refund_no").is_none());
}
@@ -169,6 +169,7 @@ impl From<module_runtime::RuntimeProfileRechargeRefundManualReviewResolveInput>
out_refund_no: input.out_refund_no,
admin_user_id: input.admin_user_id,
reason: input.reason,
expected_error_code: input.expected_error_code,
}
}
}
@@ -1432,6 +1433,7 @@ pub(crate) fn map_runtime_profile_recharge_refund_snapshot(
manual_review_resolved_by_admin_user_id: snapshot.manual_review_resolved_by_admin_user_id,
manual_review_resolution_reason: snapshot.manual_review_resolution_reason,
manual_review_resolved_at_micros: snapshot.manual_review_resolved_at_micros,
manual_review_resolved_error_code: snapshot.manual_review_resolved_error_code,
}
}
@@ -10,6 +10,7 @@ pub struct RuntimeProfileRechargeRefundManualReviewResolveInput {
pub out_refund_no: String,
pub admin_user_id: String,
pub reason: String,
pub expected_error_code: String,
}
impl __sdk::InModule for RuntimeProfileRechargeRefundManualReviewResolveInput {
@@ -36,6 +36,7 @@ pub struct RuntimeProfileRechargeRefundSnapshot {
pub manual_review_resolved_by_admin_user_id: Option<String>,
pub manual_review_resolution_reason: Option<String>,
pub manual_review_resolved_at_micros: Option<i64>,
pub manual_review_resolved_error_code: Option<String>,
}
impl __sdk::InModule for RuntimeProfileRechargeRefundSnapshot {
@@ -2788,6 +2788,73 @@ mod tests {
);
}
#[test]
fn manual_review_request_matches_current_and_resolved_state() {
let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid);
let mut refund = recharge_refund_for_order(&order);
refund.last_error_code = Some("provider_transaction_id_mismatch".to_string());
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"provider_transaction_id_mismatch",
"admin-1",
"已核对商户平台",
)
.is_ok()
);
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"order_total_mismatch",
"admin-1",
"已核对商户平台",
)
.is_err()
);
refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300));
refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string());
refund.manual_review_resolved_by_admin_user_id = Some("admin-1".to_string());
refund.manual_review_resolution_reason = Some("已核对商户平台".to_string());
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"order_total_mismatch",
"admin-1",
"已核对商户平台",
)
.is_ok()
);
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"provider_transaction_id_mismatch",
"admin-1",
"已核对商户平台",
)
.is_err()
);
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"order_total_mismatch",
"admin-2",
"已核对商户平台",
)
.is_err()
);
assert!(
validate_profile_recharge_refund_manual_review_request(
&refund,
"order_total_mismatch",
"admin-1",
"另一份核对意见",
)
.is_err()
);
}
#[test]
fn refunded_order_keeps_first_recharge_qualification_consumed() {
let refunded = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded);
@@ -4606,6 +4673,7 @@ fn resolve_profile_recharge_refund_manual_review(
input.out_refund_no,
input.admin_user_id,
input.reason,
input.expected_error_code,
)?;
let mut refund = ctx
.db
@@ -4613,6 +4681,12 @@ fn resolve_profile_recharge_refund_manual_review(
.out_refund_no()
.find(&validated.out_refund_no)
.ok_or_else(|| "退款记录不存在".to_string())?;
validate_profile_recharge_refund_manual_review_request(
&refund,
&validated.expected_error_code,
&validated.admin_user_id,
&validated.reason,
)?;
if refund.manual_review_resolved_at.is_some() {
let settlement = ctx
.db
@@ -4688,6 +4762,32 @@ fn resolve_profile_recharge_refund_manual_review(
))
}
fn validate_profile_recharge_refund_manual_review_request(
refund: &ProfileRechargeRefund,
expected_error_code: &str,
admin_user_id: &str,
reason: &str,
) -> Result<(), String> {
let current_error_code = if refund.manual_review_resolved_at.is_some() {
refund.manual_review_resolved_error_code.as_deref()
} else {
refund.last_error_code.as_deref()
};
if current_error_code != Some(expected_error_code) {
return Err(format!(
"退款人工复核状态已变化,当前错误码为 {}",
current_error_code.unwrap_or("-")
));
}
if refund.manual_review_resolved_at.is_some()
&& (refund.manual_review_resolved_by_admin_user_id.as_deref() != Some(admin_user_id)
|| refund.manual_review_resolution_reason.as_deref() != Some(reason))
{
return Err("退款人工复核已由其他审计内容处理".to_string());
}
Ok(())
}
fn build_profile_admin_wallet_snapshot(
ctx: &ReducerContext,
user_id: &str,
@@ -9867,6 +9967,7 @@ fn build_profile_recharge_refund_snapshot_from_row(
manual_review_resolved_at_micros: row
.manual_review_resolved_at
.map(|value| value.to_micros_since_unix_epoch()),
manual_review_resolved_error_code: row.manual_review_resolved_error_code.clone(),
}
}