feat: add usage overage billing

This commit is contained in:
Codex
2026-06-30 20:45:30 +08:00
parent ff2b0a83ee
commit dbefc0568a
17 changed files with 986 additions and 25 deletions

View File

@@ -1944,6 +1944,181 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.ok(auditAfterBatch.items?.some(item => item.action === 'platform.invoice.subscription_batch_created'), 'batch invoice creation should write platform audit');
const usageOveragePeriodStart = '2026-08-01';
const usageOveragePeriodEnd = '2026-08-31';
const usageOveragePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await usageOveragePool.query(
`
update public.tenant_subscriptions
set metadata = metadata || $2::jsonb,
updated_at = now()
where id = $1
`,
[
ids.partnerSubscription,
JSON.stringify({
includedQuotas: { students: 100, questions: 500, storage_gb: 2 },
overagePrices: {
students: { unitAmountCents: 200, unitSize: 1 },
questions: { unitAmountCents: 50, unitSize: 10 },
storage_gb: { unitAmountCents: 12000, unitSize: 1 },
},
}),
],
);
await usageOveragePool.query(
`
delete from public.tenant_usage_records
where tenant_id = $1
and period_start = $2::date
and period_end = $3::date
`,
[PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd],
);
await usageOveragePool.query(
`
insert into public.tenant_usage_records (tenant_id, metric_key, metric_value, period_start, period_end, metadata)
values
($1, 'students', 120, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb),
($1, 'questions', 860, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb),
($1, 'storage_gb', 3.5, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb)
`,
[PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd],
);
await usageOveragePool.query(
`
delete from public.tenant_invoices
where tenant_id = $1
and invoice_type = 'usage_overage'
and billing_period_start = $2::date
and billing_period_end = $3::date
`,
[PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd],
);
} finally {
await usageOveragePool.end();
}
const usageOverageCandidates = await request('/api/platform-admin/invoices/usage-overage-candidates', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: {
tenantIds: PARTNER_TENANT_ID,
periodStart: usageOveragePeriodStart,
periodEnd: usageOveragePeriodEnd,
limit: 20,
},
});
const usageOverageCandidate = usageOverageCandidates.items?.find(item => item.tenantId === PARTNER_TENANT_ID);
assert.ok(usageOverageCandidate, 'platform admin should preview usage-overage invoice candidates');
assert.equal(usageOverageCandidate.hasExistingInvoice, false, 'usage-overage candidate should not have an existing invoice before generation');
assert.ok(usageOverageCandidate.totalCents > 0, 'usage-overage candidate should expose billable amount');
assert.ok(
usageOverageCandidate.items?.some(item => item.metadata?.metricKey === 'students'),
'usage-overage candidate should include student overage item',
);
assert.ok(
usageOverageCandidate.items?.some(item => item.metadata?.metricKey === 'storage_gb'),
'usage-overage candidate should include storage overage item',
);
const invalidUsageOverageDate = await request('/api/platform-admin/invoices/usage-overage-candidates', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: {
tenantIds: PARTNER_TENANT_ID,
periodStart: '2026-06-31',
periodEnd: '2026-06-30',
},
expectStatus: 400,
});
assert.equal(invalidUsageOverageDate.code, 'INVALID_DATE', 'usage-overage candidate query should reject invalid dates');
const usageOverageDryRun = await request('/api/platform-admin/invoices/from-usage-overage', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
tenantIds: [PARTNER_TENANT_ID],
periodStart: usageOveragePeriodStart,
periodEnd: usageOveragePeriodEnd,
dueDate: '2026-09-10',
note: 'integration usage overage dry run',
dryRun: true,
},
});
assert.equal(usageOverageDryRun.item?.dryRun, true, 'usage-overage dry-run should not create invoices');
assert.equal(usageOverageDryRun.item?.createdCount, 0, 'usage-overage dry-run should not create records');
assert.ok(usageOverageDryRun.item?.totalCents > 0, 'usage-overage dry-run should expose total cents');
const usageOverageCreated = await request('/api/platform-admin/invoices/from-usage-overage', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
tenantIds: [PARTNER_TENANT_ID],
periodStart: usageOveragePeriodStart,
periodEnd: usageOveragePeriodEnd,
dueDate: '2026-09-10',
note: 'integration usage overage invoice',
status: 'issued',
},
});
assert.equal(usageOverageCreated.item?.createdCount, 1, 'usage-overage invoice generation should create one invoice');
assert.equal(usageOverageCreated.item?.skippedCount, 0, 'first usage-overage invoice generation should not skip');
const usageOverageInvoice = usageOverageCreated.item?.items?.[0]?.invoice;
assert.equal(usageOverageInvoice?.invoiceType, 'usage_overage', 'usage-overage invoice should use usage_overage type');
assert.ok(usageOverageInvoice?.invoiceNo, 'usage-overage invoice should expose invoice number');
const usageOverageAfterCreate = await request('/api/platform-admin/invoices/usage-overage-candidates', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: {
tenantIds: PARTNER_TENANT_ID,
periodStart: usageOveragePeriodStart,
periodEnd: usageOveragePeriodEnd,
includeExisting: 'true',
limit: 20,
},
});
const existingOverageCandidate = usageOverageAfterCreate.items?.find(item => item.tenantId === PARTNER_TENANT_ID);
assert.equal(existingOverageCandidate?.hasExistingInvoice, true, 'usage-overage candidate should expose existing invoice after generation');
assert.equal(existingOverageCandidate?.existingInvoiceNo, usageOverageInvoice?.invoiceNo, 'usage-overage candidate should identify the generated invoice');
const duplicateUsageOverage = await request('/api/platform-admin/invoices/from-usage-overage', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
tenantIds: [PARTNER_TENANT_ID],
periodStart: usageOveragePeriodStart,
periodEnd: usageOveragePeriodEnd,
},
});
assert.equal(duplicateUsageOverage.item?.createdCount, 0, 'duplicate usage-overage run should not create another invoice');
const usageOverageAudit = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { q: 'platform.invoice.usage_overage', limit: 20 },
});
assert.ok(
usageOverageAudit.items?.some(item => item.action === 'platform.invoice.usage_overage_created'),
'usage-overage invoice generation should write invoice audit',
);
assert.ok(
usageOverageAudit.items?.some(item => item.action === 'platform.invoice.usage_overage_batch_created'),
'usage-overage invoice generation should write batch 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]);