forked from wangziqi/gongxue-base
feat: add platform invoice dunning workflow
This commit is contained in:
@@ -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() {
|
||||
|
||||
158
scripts/platform-dunning-worker-integration-test.js
Normal file
158
scripts/platform-dunning-worker-integration-test.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import pg from 'pg';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
|
||||
const ids = {
|
||||
tenant: '00000000-0000-0000-0000-00000000d901',
|
||||
invoice: '00000000-0000-0000-0000-00000000d902',
|
||||
};
|
||||
|
||||
function runWorkerOnce() {
|
||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-dunning'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
WORKER_PLATFORM_DUNNING_BATCH_SIZE: '10',
|
||||
WORKER_PLATFORM_DUNNING_ID: 'platform-dunning-integration-test',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let output = '';
|
||||
child.stdout.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('exit', code => {
|
||||
try {
|
||||
assert.equal(code, 0, `worker should exit 0\n${output}`);
|
||||
assert.match(output, /platform-dunning batch processed=\d+/, 'worker output should include platform dunning summary');
|
||||
resolve(output);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(pool) {
|
||||
await pool.query('delete from public.audit_logs where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_invoice_items where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_invoice_payments where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_invoices where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
|
||||
}
|
||||
|
||||
async function createTenantAndInvoice(pool) {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
|
||||
values ($1, 'platform-dunning-worker', '平台催缴测试租户', '平台催缴测试有限公司', 'active', 'saas', 'active', '{"source":"platform-dunning-worker-test"}'::jsonb)
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
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, 'ODWORKER202606300001', 'service_fee', 'issued', 'CNY',
|
||||
660000, 660000, 0, 660000,
|
||||
current_date - interval '5 days', now(), 'platform dunning worker invoice',
|
||||
'{"source":"platform-dunning-worker-test"}'::jsonb
|
||||
)
|
||||
`,
|
||||
[ids.invoice, ids.tenant],
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
try {
|
||||
await cleanup(pool);
|
||||
await createTenantAndInvoice(pool);
|
||||
|
||||
const firstOutput = await runWorkerOnce();
|
||||
assert.match(firstOutput, /processed=\d+/, 'worker should process overdue invoice candidates');
|
||||
assert.match(firstOutput, /markedOverdue=\d+/, 'worker should mark overdue invoice candidates');
|
||||
assert.match(firstOutput, /reminderCreated=\d+/, 'worker should create reminder candidates');
|
||||
|
||||
const invoice = await pool.query(
|
||||
`
|
||||
select status, metadata
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[ids.tenant, ids.invoice],
|
||||
);
|
||||
assert.equal(invoice.rows[0]?.status, 'overdue', 'worker should update invoice status to overdue');
|
||||
assert.equal(invoice.rows[0]?.metadata?.overdueMarkedBy, 'platform-dunning-integration-test', 'worker should record overdue marker id');
|
||||
|
||||
const tenant = await pool.query('select billing_status from public.tenants where id = $1', [ids.tenant]);
|
||||
assert.equal(tenant.rows[0]?.billing_status, 'past_due', 'worker should mark tenant billing status past_due');
|
||||
|
||||
const reminders = await pool.query(
|
||||
`
|
||||
select reminder_type, channel, status, reminder_level, balance_cents_snapshot, metadata
|
||||
from public.tenant_invoice_reminders
|
||||
where tenant_id = $1 and invoice_id = $2
|
||||
`,
|
||||
[ids.tenant, ids.invoice],
|
||||
);
|
||||
assert.equal(reminders.rowCount, 1, 'worker should create exactly one reminder for the day');
|
||||
assert.equal(reminders.rows[0].reminder_type, 'overdue', 'worker reminder type should be overdue');
|
||||
assert.equal(reminders.rows[0].channel, 'internal', 'worker reminder channel should be internal');
|
||||
assert.equal(Number(reminders.rows[0].balance_cents_snapshot), 660000, 'worker reminder should snapshot balance');
|
||||
assert.equal(reminders.rows[0].metadata?.workerId, 'platform-dunning-integration-test', 'worker reminder should record worker id');
|
||||
|
||||
const audit = await pool.query(
|
||||
`
|
||||
select action, target_type, target_id, details
|
||||
from public.audit_logs
|
||||
where tenant_id = $1 and action = 'platform.invoice.overdue_processed'
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
assert.equal(audit.rows[0]?.target_type, 'tenant_invoice', 'worker should audit overdue invoice processing');
|
||||
assert.equal(audit.rows[0]?.target_id, ids.invoice, 'worker audit target should be the invoice id');
|
||||
assert.equal(audit.rows[0]?.details?.reminderCreated, true, 'worker audit should record reminder creation');
|
||||
|
||||
const secondOutput = await runWorkerOnce();
|
||||
assert.match(secondOutput, /markedOverdue=0/, 'second worker run should not re-mark overdue status for processed candidates');
|
||||
|
||||
const reminderCount = await pool.query(
|
||||
`
|
||||
select count(*)::integer as count
|
||||
from public.tenant_invoice_reminders
|
||||
where tenant_id = $1 and invoice_id = $2
|
||||
`,
|
||||
[ids.tenant, ids.invoice],
|
||||
);
|
||||
assert.equal(Number(reminderCount.rows[0]?.count), 1, 'worker should be idempotent for daily reminders');
|
||||
|
||||
console.log('Platform dunning worker integration test complete.');
|
||||
} finally {
|
||||
await cleanup(pool).catch(() => {});
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user