forked from wangziqi/gongxue-base
feat: add usage overage billing
This commit is contained in:
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user