Files
lindyson-ai/apps/api/tests/ordersService.test.ts
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

844 lines
34 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
type MockClient = { query: ReturnType<typeof vi.fn> };
const mocks = vi.hoisted(() => ({
poolQuery: vi.fn(),
withTransaction: vi.fn(),
createRefundOrder: vi.fn(),
queryRefundOrder: vi.fn(),
getPaymentChannelCredentialsByRevision: vi.fn(),
creditPaidOrderWithClient: vi.fn(),
holdOrderCreditsForRefundWithClient: vi.fn(),
releaseOrderRefundHoldWithClient: vi.fn(),
reverseOrderRefundHoldWithClient: vi.fn(),
enqueuePortraitEntitlementReconcileWithClient: vi.fn(),
}));
vi.mock('../src/db/pool.js', () => ({
pool: { query: mocks.poolQuery },
withTransaction: mocks.withTransaction,
}));
vi.mock('../src/domain/referrals.js', () => ({
lockReferralUsersWithClient: vi.fn(), rewardReferralForOrderWithClient: vi.fn(), revokeReferralForOrderWithClient: vi.fn(),
}));
vi.mock('../src/domain/subscriptionLifecycle.js', () => ({ reconcileSubscription: vi.fn() }));
vi.mock('../src/domain/billingClock.js', () => ({ getBillingDate: () => '2026-07-10' }));
vi.mock('../src/payments/paymentChannelService.js', () => ({
getPaymentChannelCredentialsByRevision: mocks.getPaymentChannelCredentialsByRevision,
}));
vi.mock('../src/payments/jeepayService.js', () => ({
createRefundOrder: mocks.createRefundOrder,
isDefinitiveMissingRefundOrder: (error: unknown) => Boolean(
error instanceof Error
&& (error as { definitive?: unknown }).definitive === true
&& String((error as { providerMessage?: unknown }).providerMessage).includes('退款订单不存在'),
),
queryRefundOrder: mocks.queryRefundOrder,
}));
vi.mock('../src/domain/financialCredits.js', () => ({
creditPaidOrderWithClient: mocks.creditPaidOrderWithClient,
holdOrderCreditsForRefundWithClient: mocks.holdOrderCreditsForRefundWithClient,
releaseOrderRefundHoldWithClient: mocks.releaseOrderRefundHoldWithClient,
reverseOrderRefundHoldWithClient: mocks.reverseOrderRefundHoldWithClient,
}));
vi.mock('../src/domain/realPortraitReconciliation.js', () => ({
enqueuePortraitEntitlementReconcileWithClient: mocks.enqueuePortraitEntitlementReconcileWithClient,
}));
import { rewardReferralForOrderWithClient } from '../src/domain/referrals.js';
import {
applyRefundResult,
createMerchantOrderNo,
createOrder,
mapOrder,
listUserOrdersPage,
markOrderPaid,
markOrderPaymentFailedFromProviderEvent,
markOrderPaymentTerminalFromClaim,
markRefundAttemptManualReview,
processPendingOrderRefunds,
queueOrderRefundWithClient,
syncOrderRefund,
} from '../src/domain/ordersService.js';
import {
materializeExactCursorPageQuery,
parseExactCursorPageRequest,
} from '../src/domain/cursorPagination.js';
describe('订单财务状态机', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getPaymentChannelCredentialsByRevision.mockResolvedValue({
revisionId: CHANNEL_REVISION_ID,
apiKey: 'secret',
mchNo: 'M100',
appId: 'APP100',
});
mocks.creditPaidOrderWithClient.mockResolvedValue({ transitioned: true });
mocks.holdOrderCreditsForRefundWithClient.mockResolvedValue({
ledgerId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
});
mocks.releaseOrderRefundHoldWithClient.mockResolvedValue(true);
mocks.reverseOrderRefundHoldWithClient.mockResolvedValue(true);
mocks.enqueuePortraitEntitlementReconcileWithClient.mockResolvedValue(true);
});
it('用户订单按状态绑定四向复合游标且不再返回 hasMore', async () => {
const userId = '22222222-2222-4222-8222-222222222222';
const firstAt = '2026-07-17T10:00:00.123456Z';
const secondAt = '2026-07-17T09:00:00.123455Z';
const secondId = '44444444-4444-4444-8444-444444444444';
const client = {
query: vi.fn()
.mockResolvedValueOnce(queryResult([
orderRow({ status: 'paid', created_at: firstAt }),
orderRow({ id: secondId, status: 'paid', created_at: secondAt }),
]))
.mockResolvedValueOnce(queryResult([
orderRow({ id: secondId, status: 'paid', created_at: secondAt }),
])),
};
const firstQuery = {
...materializeExactCursorPageQuery(parseExactCursorPageRequest({
position: 'first',
limit: 1,
filters: { userId, status: 'paid', invoiceable: false },
resource: 'user-orders',
defaultLimit: 20,
maxLimit: 50,
}), { resourceVersion: '0', totalItems: 2, snapshotAt: SNAPSHOT_AT }),
status: 'paid' as const,
invoiceable: false,
};
const first = await listUserOrdersPage(client as never, userId, firstQuery);
expect(first.pagination).toEqual({
limit: 1,
hasPrevious: false,
hasNext: true,
previousCursor: null,
nextCursor: expect.any(String),
currentPage: 1,
totalPages: 2,
totalItems: 2,
snapshotCursor: expect.any(String),
});
expect(first.pagination).not.toHaveProperty('hasMore');
expect(client.query.mock.calls[0]?.[1]).toEqual([userId, null, null, 'paid', false, 2]);
const firstSql = String(client.query.mock.calls[0]?.[0]);
expect(firstSql).toContain('WITH paged_orders AS MATERIALIZED');
expect(firstSql).toContain('ORDER BY o.created_at DESC, o.id DESC');
expect(firstSql).not.toContain('__pagination_total_count');
expect(firstSql).not.toContain('count(*) OVER()::bigint');
expect(firstSql).not.toContain('OFFSET');
const nextQuery = {
...materializeExactCursorPageQuery(parseExactCursorPageRequest({
position: 'next',
cursor: first.pagination.nextCursor!,
snapshotCursor: first.pagination.snapshotCursor,
limit: 1,
filters: { userId, status: 'paid', invoiceable: false },
resource: 'user-orders',
defaultLimit: 20,
maxLimit: 50,
}), { resourceVersion: '0', snapshotAt: SNAPSHOT_AT }),
status: 'paid' as const,
invoiceable: false,
};
const next = await listUserOrdersPage(client as never, userId, nextQuery);
expect(next.pagination).toEqual({
limit: 1,
hasPrevious: true,
hasNext: false,
previousCursor: expect.any(String),
nextCursor: null,
currentPage: 2,
totalPages: 2,
totalItems: 2,
snapshotCursor: first.pagination.snapshotCursor,
});
expect(client.query.mock.calls[1]?.[1]).toEqual([
userId,
firstAt,
'11111111-1111-4111-8111-111111111111',
'paid',
false,
2,
]);
});
it('用户订单尾页先精确计数再关联支付和退款状态', async () => {
const userId = '22222222-2222-4222-8222-222222222222';
const newerId = '11111111-1111-4111-8111-111111111111';
const olderId = '44444444-4444-4444-8444-444444444444';
const client = {
query: vi.fn().mockResolvedValueOnce(queryResult([
orderRow({
id: olderId,
created_at: '2026-07-17T09:00:00.123455Z',
__pagination_total_count: 2,
}),
orderRow({
id: newerId,
created_at: '2026-07-17T10:00:00.123456Z',
__pagination_total_count: 2,
}),
])),
};
const query = materializeExactCursorPageQuery(parseExactCursorPageRequest({
position: 'last',
limit: 1,
filters: { userId, status: null, invoiceable: false },
resource: 'user-orders',
defaultLimit: 20,
maxLimit: 50,
}), { resourceVersion: '0', totalItems: 2, snapshotAt: SNAPSHOT_AT });
const page = await listUserOrdersPage(client as never, userId, query);
expect(page.items.map(item => item.id)).toEqual([olderId]);
expect(page.pagination).toEqual(expect.objectContaining({
limit: 1,
hasPrevious: true,
hasNext: false,
nextCursor: null,
}));
const sql = String(client.query.mock.calls[0]?.[0]);
expect(sql).not.toContain('count(*) OVER()::bigint');
expect(sql).not.toContain('OFFSET');
});
it('可开票订单筛选在键集分页 SQL 内执行,不再由客户端二次过滤', async () => {
const userId = '22222222-2222-4222-8222-222222222222';
const client = {
query: vi.fn().mockResolvedValueOnce(queryResult([
orderRow({ status: 'paid', invoice_status: 'unissued' }),
])),
};
const query = {
...materializeExactCursorPageQuery(parseExactCursorPageRequest({
position: 'first',
limit: 20,
filters: { userId, status: null, invoiceable: true },
resource: 'user-orders',
defaultLimit: 20,
maxLimit: 50,
}), { resourceVersion: '3', totalItems: 1, snapshotAt: SNAPSHOT_AT }),
invoiceable: true,
};
await listUserOrdersPage(client as never, userId, query);
const sql = String(client.query.mock.calls[0]?.[0]);
expect(sql).toContain("o.status = 'paid'");
expect(sql).toContain("o.invoice_status = 'unissued'");
expect(sql).toContain('NOT EXISTS (SELECT 1 FROM invoice_requests');
expect(sql).not.toContain('OFFSET');
expect(client.query.mock.calls[0]?.[1]?.[4]).toBe(true);
});
it('支付与退款商户单号满足供应商长度限制', () => {
expect(createMerchantOrderNo('ord')).toMatch(/^ord_[0-9A-Z]{24}$/);
expect(createMerchantOrderNo('ref')).toMatch(/^ref_[0-9A-Z]{24}$/);
expect(createMerchantOrderNo('ord').length).toBeLessThanOrEqual(30);
});
it('下架套餐不能创建订单', async () => {
const client = { query: vi.fn().mockResolvedValue(queryResult([])) };
useTransaction(client);
await expect(createOrder('user-1', 'plan-disabled', 'monthly')).rejects.toMatchObject({
statusCode: 404,
});
});
it('相同幂等键复用匹配订单,不匹配请求返回 409', async () => {
const existing = orderRow({ idempotency_key: 'payment-key', payment_surface: 'pc', payment_way_code: 'ALI_WAP' });
const client = {
query: vi.fn()
.mockResolvedValueOnce(queryResult([]))
.mockResolvedValueOnce(queryResult([existing])),
};
useTransaction(client);
await expect(createOrder(
existing.user_id,
existing.plan_id!,
'monthly',
'jeepay',
'ALI_WAP',
'pc',
'payment-key',
)).resolves.toMatchObject({ id: existing.id });
const conflictClient = {
query: vi.fn()
.mockResolvedValueOnce(queryResult([]))
.mockResolvedValueOnce(queryResult([existing])),
};
useTransaction(conflictClient);
await expect(createOrder(
existing.user_id,
'99999999-9999-4999-8999-999999999999',
'monthly',
'jeepay',
'ALI_WAP',
'pc',
'payment-key',
)).rejects.toMatchObject({ statusCode: 409, code: 'IDEMPOTENCY_CONFLICT' });
});
it('WAP 通道按终端映射为扫码或手机网页', () => {
expect(mapOrder(orderRow({ payment_way_code: 'ALI_WAP', payment_surface: 'pc' })).paymentWayLabel)
.toBe('支付宝扫码支付');
expect(mapOrder(orderRow({ payment_way_code: 'ALI_WAP', payment_surface: 'mobile' })).paymentWayLabel)
.toBe('支付宝手机网页');
});
it('退款重试倒计时优先读取权威 refund attempt,旧订单字段只作回退', () => {
const now = Date.now();
const authoritative = mapOrder(orderRow({
refund_attempt_next_at: new Date(now + 5_000).toISOString(),
refund_dispatch_next_at: new Date(now + 60_000).toISOString(),
}));
expect(authoritative.refundRetryAfterMs).toBeGreaterThan(0);
expect(authoritative.refundRetryAfterMs).toBeLessThanOrEqual(5_000);
const legacy = mapOrder(orderRow({
refund_attempt_next_at: null,
refund_dispatch_next_at: new Date(now + 60_000).toISOString(),
}));
expect(legacy.refundRetryAfterMs).toBeGreaterThan(50_000);
});
it('确认支付通过统一 lot 服务到账,并写 attempt 终态和通知 Outbox', async () => {
const order = orderRow({ provider_order_id: null, current_payment_attempt_id: PAYMENT_ATTEMPT_ID });
const client: MockClient = { query: vi.fn(async (sql: unknown) => {
const text = String(sql);
if (text.startsWith('SELECT * FROM orders')) return queryResult([order]);
if (text.includes('SELECT id, provider_order_id, status')) {
return queryResult([{ id: PAYMENT_ATTEMPT_ID, provider_order_id: null, status: 'processing' }]);
}
if (text.includes('SELECT plan_id')) return queryResult([userCreditRow()]);
if (text.includes('UPDATE users')) return queryResult([]);
if (text.includes("SET status = 'paid'")) return queryResult([{ ...order, status: 'paid', paid_at: NOW }]);
if (text.includes('UPDATE payment_attempts')) return queryResult([]);
if (text.includes('INSERT INTO payment_notification_outbox')) return queryResult([]);
throw new Error(`未预期查询:${text}`);
}) };
useTransaction(client);
await expect(markOrderPaid(order.id, 'PAY-100', PAYMENT_ATTEMPT_ID)).resolves.toMatchObject({
status: 'paid', paymentTransitioned: true,
});
expect(rewardReferralForOrderWithClient).toHaveBeenCalledWith(client, order);
expect(mocks.creditPaidOrderWithClient).toHaveBeenCalledWith(
client,
expect.objectContaining({ id: order.id, creditAmount: order.credit_amount }),
expect.objectContaining({ main_credits: 100 }),
);
expect(client.query).toHaveBeenCalledWith(
expect.stringContaining("SET status = 'paid'"),
expect.arrayContaining([order.id, 'PAY-100', PAYMENT_ATTEMPT_ID]),
);
expect(client.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO payment_notification_outbox'),
expect.arrayContaining([`payment-succeeded:${order.id}`]),
);
const notificationInsert = client.query.mock.calls.find(
([sql]) => String(sql).includes('INSERT INTO payment_notification_outbox'),
);
expect(String(notificationInsert?.[0])).toContain('VALUES ($1, $2::uuid');
expect(String(notificationInsert?.[0])).toContain("'orderId', $2::uuid");
expect(String(notificationInsert?.[0])).not.toContain('$2::text');
expect(mocks.enqueuePortraitEntitlementReconcileWithClient).toHaveBeenCalledWith(
client,
{
userId: order.user_id,
eventKey: `portrait-payment:${order.id}`,
reason: 'main-plan-purchased',
oldPlanId: null,
newPlanId: order.plan_id,
},
);
});
it('同一订单出现不同供应商支付号时转人工核对且不发积分', async () => {
const order = orderRow({ provider_order_id: 'PAY-OLD', current_payment_attempt_id: PAYMENT_ATTEMPT_ID });
const client: MockClient = { query: vi.fn(async (sql: unknown) => {
const text = String(sql);
if (text.startsWith('SELECT * FROM orders')) return queryResult([order]);
if (text.includes('SELECT id, provider_order_id, status')) {
return queryResult([{ id: PAYMENT_ATTEMPT_ID, provider_order_id: 'PAY-OLD', status: 'ready' }]);
}
if (text.includes("status = 'manual-review'")) return queryResult([]);
throw new Error(`未预期查询:${text}`);
}) };
useTransaction(client);
await expect(markOrderPaid(order.id, 'PAY-OTHER')).rejects.toMatchObject({
statusCode: 409,
code: 'PAYMENT_PROVIDER_ID_CONFLICT',
});
expect(mocks.creditPaidOrderWithClient).not.toHaveBeenCalled();
});
it('支付 worker 的旧 lease 不能到账、改订单或发积分', async () => {
const order = orderRow({ current_payment_attempt_id: PAYMENT_ATTEMPT_ID });
const client: MockClient = { query: vi.fn(async (sql: unknown) => {
const text = String(sql);
if (text.startsWith('SELECT * FROM orders')) return queryResult([order]);
if (text.includes('SELECT id FROM payment_attempts')) return queryResult([]);
throw new Error(`旧 lease 不应继续查询:${text}`);
}) };
useTransaction(client);
await expect(markOrderPaid(
order.id,
'PAY-100',
PAYMENT_ATTEMPT_ID,
'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
)).rejects.toMatchObject({ code: 'PAYMENT_ATTEMPT_STALE_CLAIM', statusCode: 409 });
expect(mocks.creditPaidOrderWithClient).not.toHaveBeenCalled();
expect(client.query.mock.calls.some(([sql]) => String(sql).includes("SET status = 'paid'"))).toBe(false);
});
it('支付 worker 的旧 lease 不能写 failed 或 closed 终态', async () => {
const order = orderRow({ current_payment_attempt_id: PAYMENT_ATTEMPT_ID });
const client: MockClient = { query: vi.fn(async (sql: unknown) => {
const text = String(sql);
if (text.startsWith('SELECT * FROM orders')) return queryResult([order]);
if (text.includes('SELECT id FROM payment_attempts')) return queryResult([]);
throw new Error(`旧 lease 不应继续查询:${text}`);
}) };
useTransaction(client);
await expect(markOrderPaymentTerminalFromClaim(order.id, {
id: PAYMENT_ATTEMPT_ID,
orderId: order.id,
leaseToken: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
}, 'failed', '支付未完成')).rejects.toMatchObject({ code: 'PAYMENT_ATTEMPT_STALE_CLAIM' });
expect(client.query.mock.calls.some(([sql]) => String(sql).includes('UPDATE orders'))).toBe(false);
});
it('旧支付 attempt 的延迟失败回调只终结自身,不得打断当前 retry', async () => {
const retryAttemptId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
const order = orderRow({ current_payment_attempt_id: retryAttemptId, provider_order_id: 'PAY-NEW' });
const client: MockClient = { query: vi.fn(async (sql: unknown) => {
const text = String(sql);
if (text.startsWith('SELECT * FROM orders')) return queryResult([order]);
if (text.includes('UPDATE payment_attempts')) return queryResult([{ id: PAYMENT_ATTEMPT_ID }]);
throw new Error(`延迟失败回调不应修改当前订单:${text}`);
}) };
useTransaction(client);
await expect(markOrderPaymentFailedFromProviderEvent(
order.id,
PAYMENT_ATTEMPT_ID,
'支付未完成',
'PAY-OLD',
)).resolves.toMatchObject({ status: 'pending' });
expect(client.query.mock.calls.some(([sql]) => String(sql).includes('UPDATE orders'))).toBe(false);
});
it('旧支付 attempt 的严格已支付结果仍可到账并关闭当前 retry', async () => {
const retryAttemptId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
const order = orderRow({ current_payment_attempt_id: retryAttemptId, provider_order_id: 'PAY-NEW' });
const client: MockClient = { query: vi.fn(async (sql: unknown) => {
const text = String(sql);
if (text.startsWith('SELECT * FROM orders')) return queryResult([order]);
if (text.includes('SELECT id, provider_order_id, status')) {
return queryResult([{ id: PAYMENT_ATTEMPT_ID, provider_order_id: 'PAY-OLD', status: 'ready' }]);
}
if (text.includes('SELECT plan_id')) return queryResult([userCreditRow()]);
if (text.includes('UPDATE users')) return queryResult([]);
if (text.includes("SET status = 'paid'")) {
if (text.includes('UPDATE orders')) {
return queryResult([{ ...order, status: 'paid', current_payment_attempt_id: PAYMENT_ATTEMPT_ID }]);
}
return queryResult([{ id: PAYMENT_ATTEMPT_ID }]);
}
if (text.includes("SET status = 'closed'")) return queryResult([]);
if (text.includes('INSERT INTO payment_notification_outbox')) return queryResult([]);
throw new Error(`未预期查询:${text}`);
}) };
useTransaction(client);
await expect(markOrderPaid(order.id, 'PAY-OLD', PAYMENT_ATTEMPT_ID)).resolves.toMatchObject({
status: 'paid', paymentTransitioned: true,
});
expect(mocks.creditPaidOrderWithClient).toHaveBeenCalledOnce();
});
it('发起退款冻结订单专属 lot,并创建绑定原通道 revision 的 attempt', async () => {
const order = orderRow({
status: 'paid',
provider_order_id: 'PAY-100',
current_payment_attempt_id: PAYMENT_ATTEMPT_ID,
});
const client: MockClient = { query: vi.fn(async (sql: unknown) => {
const text = String(sql);
if (text.startsWith('SELECT * FROM orders')) return queryResult([order]);
if (text.includes('SELECT main_credits')) return queryResult([{
main_credits: 500, addon_credits: 0, active_subscription_order_id: order.id,
}]);
if (text.includes('SELECT attempt.channel_revision_id')) {
return queryResult([{ channel_revision_id: CHANNEL_REVISION_ID }]);
}
if (text.includes('COALESCE(max(attempt)')) return queryResult([{ next_attempt: 1 }]);
if (text.includes('INSERT INTO order_refund_attempts')) return queryResult([{ id: REFUND_ATTEMPT_ID }]);
if (text.includes('UPDATE orders')) {
return queryResult([{ ...order, refund_status: 'pending', current_refund_attempt_id: REFUND_ATTEMPT_ID }]);
}
if (text.includes('INSERT INTO audit_logs')) return queryResult([]);
if (text.includes('SELECT o.*')) return queryResult([{ ...order, refund_status: 'pending' }]);
throw new Error(`未预期查询:${text}`);
}) };
await queueOrderRefundWithClient(client as never, order.id, 'admin-1', '127.0.0.1');
expect(mocks.holdOrderCreditsForRefundWithClient).toHaveBeenCalledOnce();
const attemptInsert = client.query.mock.calls.find(([sql]) => String(sql).includes('INSERT INTO order_refund_attempts'))!;
expect(attemptInsert[1]).toEqual(expect.arrayContaining([order.id, CHANNEL_REVISION_ID, 'PAY-100']));
});
it('退款创建和查单异常只转 uncertain,不释放积分', async () => {
const claim = claimedRefundRow({ previous_status: 'queued' });
mocks.withTransaction
.mockImplementationOnce(async (callback: (client: MockClient) => unknown) => callback({
query: vi.fn().mockResolvedValue(queryResult([claim])),
}))
.mockImplementationOnce(async (callback: (client: MockClient) => unknown) => callback({
query: vi.fn()
.mockResolvedValueOnce(queryResult([{ id: claim.refund_attempt_id }]))
.mockResolvedValueOnce(queryResult([])),
}));
mocks.createRefundOrder.mockRejectedValue(new Error('network'));
mocks.queryRefundOrder.mockRejectedValue(new Error('not found'));
await expect(processPendingOrderRefunds(1)).resolves.toEqual({ claimed: 1, processed: 1, failed: 0 });
expect(mocks.releaseOrderRefundHoldWithClient).not.toHaveBeenCalled();
expect(mocks.queryRefundOrder).toHaveBeenCalledWith(
expect.objectContaining({ revisionId: CHANNEL_REVISION_ID }),
claim.merchant_refund_no,
);
});
it('退款主动同步遇到 processing 租约时不查单也不清租约', async () => {
const stored = orderRow({
status: 'paid',
refund_status: 'pending',
merchant_refund_no: 'ref_123456789012345678901234',
current_refund_attempt_id: REFUND_ATTEMPT_ID,
});
mocks.poolQuery
.mockResolvedValueOnce(queryResult([stored]))
.mockResolvedValueOnce(queryResult([{
id: REFUND_ATTEMPT_ID,
order_id: stored.id,
merchant_order_no: stored.merchant_order_no,
merchant_refund_no: stored.merchant_refund_no,
channel_revision_id: CHANNEL_REVISION_ID,
provider_refund_id: null,
provider_order_id: 'PAY-100',
amount: stored.amount,
currency: 'CNY',
status: 'processing',
lease_token: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
lease_until: '2099-07-10T07:00:00.000Z',
}]));
await expect(syncOrderRefund(stored.id)).resolves.toMatchObject({ refundStatus: 'pending' });
expect(mocks.queryRefundOrder).not.toHaveBeenCalled();
expect(mocks.poolQuery).toHaveBeenCalledTimes(2);
});
it('退款创建响应即使声称成功也只进入 submitted,等待完整字段查单', async () => {
const claim = claimedRefundRow({ previous_status: 'queued' });
mocks.withTransaction
.mockImplementationOnce(async (callback: (client: MockClient) => unknown) => callback({
query: vi.fn().mockResolvedValue(queryResult([claim])),
}))
.mockImplementationOnce(async (callback: (client: MockClient) => unknown) => callback({
query: vi.fn()
.mockResolvedValueOnce(queryResult([{ current_refund_attempt_id: claim.refund_attempt_id }]))
.mockResolvedValueOnce({ rows: [], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 1 }),
}));
mocks.poolQuery
.mockResolvedValueOnce(queryResult([{
id: REFUND_ATTEMPT_ID,
order_id: claim.id,
merchant_order_no: claim.merchant_order_no,
merchant_refund_no: claim.merchant_refund_no,
channel_revision_id: CHANNEL_REVISION_ID,
provider_refund_id: null,
provider_order_id: 'PAY-100',
amount: claim.amount,
currency: 'CNY',
status: 'processing',
}]))
.mockResolvedValueOnce(queryResult([orderRow({
refund_status: 'pending',
merchant_refund_no: claim.merchant_refund_no,
current_refund_attempt_id: REFUND_ATTEMPT_ID,
})]));
mocks.createRefundOrder.mockResolvedValue({
refundOrderId: 'REFUND-100',
mchRefundNo: claim.merchant_refund_no,
state: 2,
});
await expect(processPendingOrderRefunds(1)).resolves.toEqual({ claimed: 1, processed: 1, failed: 0 });
expect(mocks.queryRefundOrder).not.toHaveBeenCalled();
expect(mocks.releaseOrderRefundHoldWithClient).not.toHaveBeenCalled();
expect(mocks.reverseOrderRefundHoldWithClient).not.toHaveBeenCalled();
});
it('恢复 attempt 只有先查到明确不存在,才用同一商户退款号补建', async () => {
const claim = claimedRefundRow({ previous_status: 'uncertain' });
mocks.withTransaction
.mockImplementationOnce(async (callback: (client: MockClient) => unknown) => callback({
query: vi.fn().mockResolvedValue(queryResult([claim])),
}))
.mockImplementationOnce(async (callback: (client: MockClient) => unknown) => callback({
query: vi.fn()
.mockResolvedValueOnce(queryResult([{ current_refund_attempt_id: claim.refund_attempt_id }]))
.mockResolvedValueOnce({ rows: [], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 1 }),
}));
mocks.poolQuery
.mockResolvedValueOnce(queryResult([{
id: REFUND_ATTEMPT_ID,
order_id: claim.id,
merchant_order_no: claim.merchant_order_no,
merchant_refund_no: claim.merchant_refund_no,
channel_revision_id: CHANNEL_REVISION_ID,
provider_refund_id: null,
provider_order_id: 'PAY-100',
amount: claim.amount,
currency: 'CNY',
status: 'processing',
}]))
.mockResolvedValueOnce(queryResult([orderRow({
refund_status: 'pending',
merchant_refund_no: claim.merchant_refund_no,
current_refund_attempt_id: REFUND_ATTEMPT_ID,
})]));
mocks.queryRefundOrder.mockRejectedValue(Object.assign(new Error('not found'), {
definitive: true,
providerMessage: '退款订单不存在',
}));
mocks.createRefundOrder.mockResolvedValue({
refundOrderId: 'REFUND-100',
mchRefundNo: claim.merchant_refund_no,
state: 1,
});
await expect(processPendingOrderRefunds(1)).resolves.toEqual({ claimed: 1, processed: 1, failed: 0 });
expect(mocks.queryRefundOrder).toHaveBeenCalledOnce();
expect(mocks.createRefundOrder).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ merchantRefundNo: claim.merchant_refund_no }),
);
expect(mocks.queryRefundOrder.mock.invocationCallOrder[0])
.toBeLessThan(mocks.createRefundOrder.mock.invocationCallOrder[0]!);
expect(mocks.releaseOrderRefundHoldWithClient).not.toHaveBeenCalled();
});
it('严格匹配的终态退款失败才释放冻结 lot', async () => {
const order = mapOrder(orderRow({
status: 'paid',
refund_status: 'pending',
merchant_refund_no: 'ref_123456789012345678901234',
current_refund_attempt_id: REFUND_ATTEMPT_ID,
}));
mocks.poolQuery
.mockResolvedValueOnce(queryResult([{
id: REFUND_ATTEMPT_ID,
order_id: order.id,
merchant_order_no: order.merchantOrderNo,
merchant_refund_no: order.merchantRefundNo,
channel_revision_id: CHANNEL_REVISION_ID,
provider_refund_id: null,
provider_order_id: order.paymentOrderNo,
amount: order.amount,
currency: 'CNY',
status: 'submitted',
}]));
const stored = orderRow({
status: 'paid', refund_status: 'pending', merchant_refund_no: order.merchantRefundNo,
current_refund_attempt_id: REFUND_ATTEMPT_ID,
});
const client: MockClient = { query: vi.fn(async (sql: unknown) => {
const text = String(sql);
if (text.startsWith('SELECT * FROM orders')) return queryResult([stored]);
if (text.includes('SELECT id FROM order_refund_attempts')) return queryResult([{ id: REFUND_ATTEMPT_ID }]);
if (text.includes('UPDATE credit_ledger')) return queryResult([]);
if (text.includes('UPDATE orders')) return queryResult([{ ...stored, refund_status: 'failed' }]);
if (text.includes('UPDATE order_refund_attempts')) return queryResult([]);
throw new Error(`未预期查询:${text}`);
}) };
useTransaction(client);
await applyRefundResult(order.id, {
refundOrderId: 'REFUND-100',
mchRefundNo: order.merchantRefundNo!,
state: 3,
});
expect(mocks.releaseOrderRefundHoldWithClient).toHaveBeenCalledOnce();
});
it('退款 mismatch 的旧 lease 不能改写新 owner 的 attempt', async () => {
const claim = claimedRefundRow();
mocks.poolQuery.mockResolvedValueOnce(queryResult([]));
await expect(markRefundAttemptManualReview(
claim.refund_attempt_id,
'字段冲突',
{ claim },
)).rejects.toMatchObject({ code: 'REFUND_ATTEMPT_STALE_CLAIM', statusCode: 409 });
});
it('旧退款 callback 晚到时只隔离原 attempt,不改当前 attempt、订单或 lot', async () => {
const currentAttemptId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
const stored = orderRow({
status: 'paid',
refund_status: 'pending',
merchant_refund_no: 'ref_CURRENT123456789012345678',
current_refund_attempt_id: currentAttemptId,
});
mocks.poolQuery
.mockResolvedValueOnce(queryResult([{
id: REFUND_ATTEMPT_ID,
order_id: stored.id,
merchant_order_no: stored.merchant_order_no,
merchant_refund_no: 'ref_123456789012345678901234',
channel_revision_id: CHANNEL_REVISION_ID,
provider_refund_id: 'REFUND-OLD',
provider_order_id: 'PAY-100',
amount: stored.amount,
currency: 'CNY',
status: 'submitted',
lease_token: null,
lease_until: null,
is_current: false,
}]))
.mockResolvedValueOnce(queryResult([{ id: REFUND_ATTEMPT_ID }]))
.mockResolvedValueOnce(queryResult([stored]));
await expect(applyRefundResult(stored.id, {
refundOrderId: 'REFUND-OLD',
mchRefundNo: 'ref_123456789012345678901234',
state: 2,
}, undefined, REFUND_ATTEMPT_ID)).resolves.toMatchObject({ refundStatus: 'pending' });
expect(mocks.reverseOrderRefundHoldWithClient).not.toHaveBeenCalled();
expect(mocks.releaseOrderRefundHoldWithClient).not.toHaveBeenCalled();
expect(mocks.withTransaction).not.toHaveBeenCalled();
const manualReviewSql = String(mocks.poolQuery.mock.calls[1]?.[0]);
expect(manualReviewSql).toContain('UPDATE order_refund_attempts attempt');
expect(mocks.poolQuery.mock.calls[1]?.[1]?.[0]).toBe(REFUND_ATTEMPT_ID);
});
});
const SNAPSHOT_AT = '2026-07-17T11:00:00.000000Z';
const NOW = '2026-07-10T06:00:00.000Z';
const PAYMENT_ATTEMPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const REFUND_ATTEMPT_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const CHANNEL_REVISION_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
function useTransaction(client: MockClient) {
mocks.withTransaction.mockImplementation(async (callback: (value: MockClient) => unknown) => callback(client));
}
function queryResult<T>(rows: T[]) {
return { rows, rowCount: rows.length };
}
function userCreditRow() {
return {
plan_id: null,
billing_cycle: 'monthly',
main_credits: 100,
addon_credits: 20,
total_credits: 120,
plan_cycle_started_at: '2026-07-01',
plan_reset_at: null,
plan_expires_at: null,
active_subscription_order_id: null,
email: 'user@example.com',
user_name: '用户',
};
}
type TestOrderRow = Parameters<typeof mapOrder>[0] & {
__pagination_total_count: string | number | bigint | null;
__pagination_cursor_timestamp: string;
};
function orderRow(
overrides: Partial<TestOrderRow> = {},
): TestOrderRow {
return {
id: '11111111-1111-4111-8111-111111111111',
user_id: '22222222-2222-4222-8222-222222222222',
plan_id: '33333333-3333-4333-8333-333333333333',
kind: 'main',
plan_name: '专业版',
amount: 22800,
billing: 'monthly',
status: 'pending',
invoice_status: 'unissued',
credit_status: 'none',
credit_amount: 500,
credit_bucket: 'main',
payment_method: '支付宝',
payment_provider: 'jeepay',
payment_way_code: 'ALI_WAP',
payment_surface: 'pc',
merchant_order_no: 'ord_123456789012345678901234',
provider_order_id: null,
provider_trade_id: null,
payment_error: null,
payment_expires_at: '2026-07-10T08:00:00.000Z',
refund_status: 'none',
merchant_refund_no: null,
provider_refund_id: null,
refund_error: null,
refund_credit_ledger_id: null,
refund_dispatch_status: 'idle',
refund_dispatch_attempts: 0,
refund_dispatch_next_at: null,
refund_dispatch_lease_until: null,
previous_plan_id: null,
previous_billing_cycle: null,
previous_plan_cycle_started_at: null,
previous_plan_reset_at: null,
previous_plan_expires_at: null,
previous_subscription_order_id: null,
current_payment_attempt_id: null,
current_refund_attempt_id: null,
created_at: NOW,
paid_at: null,
failed_at: null,
refunded_at: null,
__pagination_total_count: null,
__pagination_cursor_timestamp: String(overrides.created_at ?? NOW),
...overrides,
};
}
function claimedRefundRow(overrides: Record<string, unknown> = {}) {
return {
id: '11111111-1111-4111-8111-111111111111',
refund_attempt_id: REFUND_ATTEMPT_ID,
lease_token: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
channel_revision_id: CHANNEL_REVISION_ID,
merchant_order_no: 'ord_123456789012345678901234',
merchant_refund_no: 'ref_123456789012345678901234',
amount: 22800,
refund_client_ip: '127.0.0.1',
refund_dispatch_attempts: 1,
previous_status: 'queued' as const,
...overrides,
};
}