feat: add platform invoice dunning workflow

This commit is contained in:
Codex
2026-06-30 05:27:58 +08:00
parent f9f96bee63
commit c74432ce98
25 changed files with 858 additions and 23 deletions

View File

@@ -62,6 +62,7 @@ const ids = {
pointExpensiveExchangeItem: '00000000-0000-0000-0000-000000000880',
questionBank: '00000000-0000-0000-0000-000000000400',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
platformOverdueInvoice: crypto.randomUUID(),
};
const paymentFixture = (() => {
@@ -1336,12 +1337,96 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.ok(auditAfterBatch.items?.some(item => item.action === 'platform.invoice.subscription_batch_created'), 'batch invoice creation should write platform audit');
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1 and invoice_id = $2', [tenantId, ids.platformOverdueInvoice]);
await pool.query('delete from public.tenant_invoice_items where tenant_id = $1 and invoice_id = $2', [tenantId, ids.platformOverdueInvoice]);
await pool.query('delete from public.tenant_invoices where tenant_id = $1 and id = $2', [tenantId, ids.platformOverdueInvoice]);
await pool.query(
`
insert into public.tenant_invoices (
id, tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, total_cents, paid_cents, balance_cents,
due_date, issued_at, note, metadata
)
values (
$1, $2, $3, 'service_fee', 'issued', 'CNY',
120000, 120000, 0, 120000,
current_date - interval '2 days', now(), 'integration overdue invoice',
'{"source":"api-integration-overdue"}'::jsonb
)
`,
[ids.platformOverdueInvoice, tenantId, `OD${Date.now()}`],
);
} finally {
await pool.end();
}
const overdueDryRun = await request('/api/platform-admin/invoices/process-overdue', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
dryRun: true,
limit: 20,
},
});
assert.equal(overdueDryRun.item?.dryRun, true, 'overdue processing dry-run should be supported');
assert.ok(overdueDryRun.item?.items?.some(item => item.id === ids.platformOverdueInvoice && item.wouldCreateReminder), 'dry-run should preview overdue reminder creation');
const overdueProcessed = await request('/api/platform-admin/invoices/process-overdue', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
channel: 'internal',
limit: 20,
},
});
assert.equal(overdueProcessed.item?.markedOverdue >= 1, true, 'overdue processing should mark issued overdue invoices');
assert.equal(overdueProcessed.item?.reminderCreated >= 1, true, 'overdue processing should create reminder records');
const reminders = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId, invoiceId: ids.platformOverdueInvoice, limit: 10 },
});
assert.ok(reminders.items?.some(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue'), 'platform admin should list invoice reminders');
const overdueAudit = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId, q: 'platform.invoice.overdue', limit: 20 },
});
assert.ok(overdueAudit.items?.some(item => item.action === 'platform.invoice.overdue_processed'), 'overdue processing should write invoice audit');
const invalidOverdueChannel = await request('/api/platform-admin/invoices/process-overdue', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: { channel: 'unsafe-channel' },
expectStatus: 400,
});
assert.equal(invalidOverdueChannel.code, 'INVALID_REMINDER_CHANNEL', 'overdue processing should reject invalid reminder channels');
const studentAuditDenied = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentAuditDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit logs');
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentReminderDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform invoice reminders');
}
function stopServer() {