forked from wangziqi/gongxue-base
feat: automate usage overage billing
This commit is contained in:
217
scripts/platform-usage-overage-worker-integration-test.js
Normal file
217
scripts/platform-usage-overage-worker-integration-test.js
Normal file
@@ -0,0 +1,217 @@
|
||||
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-00000000c901',
|
||||
noOverageTenant: '00000000-0000-0000-0000-00000000c902',
|
||||
subscription: '00000000-0000-0000-0000-00000000c903',
|
||||
noOverageSubscription: '00000000-0000-0000-0000-00000000c904',
|
||||
};
|
||||
|
||||
function runWorkerOnce() {
|
||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-usage-overage'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE: '20',
|
||||
WORKER_PLATFORM_USAGE_OVERAGE_MONTH: '2026-07',
|
||||
WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS: '21',
|
||||
WORKER_PLATFORM_USAGE_OVERAGE_ID: 'platform-usage-overage-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-usage-overage batch processed=\d+/, 'worker output should include usage overage summary');
|
||||
resolve(output);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dateOnly(value) {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
return String(value || '').slice(0, 10);
|
||||
}
|
||||
|
||||
async function cleanup(pool) {
|
||||
await pool.query('delete from public.audit_logs where tenant_id = any($1::uuid[]) or action = $2', [
|
||||
[ids.tenant, ids.noOverageTenant],
|
||||
'platform.invoice.usage_overage_batch_created',
|
||||
]);
|
||||
await pool.query('delete from public.tenant_invoice_payments where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||
await pool.query('delete from public.tenant_invoice_items where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||
await pool.query('delete from public.tenant_invoices where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||
await pool.query('delete from public.tenant_usage_records where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||
await pool.query('delete from public.tenant_subscriptions where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||
await pool.query('delete from public.tenants where id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||
}
|
||||
|
||||
async function createFixture(pool) {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
|
||||
values
|
||||
($1, 'platform-usage-overage-worker', '平台超额账单测试租户', '平台超额账单测试有限公司', 'active', 'saas', 'active', '{"source":"platform-usage-overage-worker-test"}'::jsonb),
|
||||
($2, 'platform-usage-overage-none', '平台无超额测试租户', '平台无超额测试有限公司', 'active', 'saas', 'active', '{"source":"platform-usage-overage-worker-test"}'::jsonb)
|
||||
`,
|
||||
[ids.tenant, ids.noOverageTenant],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenant_subscriptions (
|
||||
id, tenant_id, plan_code, status, starts_at, expires_at,
|
||||
billing_cycle, amount_cents, metadata
|
||||
)
|
||||
values
|
||||
(
|
||||
$1, $3, 'starter_yearly', 'active',
|
||||
'2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00',
|
||||
'yearly', 980000,
|
||||
'{"includedQuotas":{"students":100,"storageGb":2},"overagePrices":{"students":{"unitAmountCents":200,"unitSize":10},"storage_gb":{"unitAmountCents":1200,"unitSize":1}},"source":"platform-usage-overage-worker-test"}'::jsonb
|
||||
),
|
||||
(
|
||||
$2, $4, 'starter_yearly', 'active',
|
||||
'2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00',
|
||||
'yearly', 980000,
|
||||
'{"includedQuotas":{"students":100,"storageGb":2},"overagePrices":{"students":{"unitAmountCents":200,"unitSize":10},"storage_gb":{"unitAmountCents":1200,"unitSize":1}},"source":"platform-usage-overage-worker-test"}'::jsonb
|
||||
)
|
||||
`,
|
||||
[ids.subscription, ids.noOverageSubscription, ids.tenant, ids.noOverageTenant],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenant_usage_records (
|
||||
tenant_id, metric_key, metric_value, period_start, period_end, metadata, created_at
|
||||
)
|
||||
values
|
||||
($1, 'students', 125, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:00+00'),
|
||||
($1, 'storage_gb', 3.5, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:01+00'),
|
||||
($1, 'questions', 4000, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:02+00'),
|
||||
($2, 'students', 90, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:00+00'),
|
||||
($2, 'storage_gb', 1.75, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:01+00')
|
||||
`,
|
||||
[ids.tenant, ids.noOverageTenant],
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
try {
|
||||
await cleanup(pool);
|
||||
await createFixture(pool);
|
||||
|
||||
const firstOutput = await runWorkerOnce();
|
||||
assert.match(firstOutput, /created=1/, 'first worker run should create one usage overage invoice');
|
||||
assert.match(firstOutput, /skipped=0/, 'first worker run should not skip existing overage invoices');
|
||||
assert.match(firstOutput, /totalCents=3000/, 'worker should report the generated overage amount');
|
||||
|
||||
const invoices = await pool.query(
|
||||
`
|
||||
select id, invoice_no, invoice_type, status, total_cents, balance_cents,
|
||||
billing_period_start::text as billing_period_start,
|
||||
billing_period_end::text as billing_period_end,
|
||||
due_date::text as due_date,
|
||||
note, metadata
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1 and invoice_type = 'usage_overage'
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
assert.equal(invoices.rowCount, 1, 'worker should create exactly one usage overage invoice');
|
||||
const invoice = invoices.rows[0];
|
||||
assert.equal(invoice.status, 'issued', 'worker overage invoice should be issued');
|
||||
assert.equal(Number(invoice.total_cents), 3000, 'worker invoice should include all overage items');
|
||||
assert.equal(Number(invoice.balance_cents), 3000, 'worker invoice should start unpaid');
|
||||
assert.equal(dateOnly(invoice.billing_period_start), '2026-07-01', 'invoice period start should match usage month');
|
||||
assert.equal(dateOnly(invoice.billing_period_end), '2026-07-31', 'invoice period end should match usage month');
|
||||
assert.equal(invoice.metadata?.source, 'usage_overage_auto', 'invoice metadata should record auto source');
|
||||
assert.equal(invoice.metadata?.workerId, 'platform-usage-overage-integration-test', 'invoice metadata should record worker id');
|
||||
|
||||
const items = await pool.query(
|
||||
`
|
||||
select item_type, description, quantity, unit_amount_cents, amount_cents, metadata
|
||||
from public.tenant_invoice_items
|
||||
where tenant_id = $1 and invoice_id = $2
|
||||
order by metadata->>'metricKey'
|
||||
`,
|
||||
[ids.tenant, invoice.id],
|
||||
);
|
||||
assert.equal(items.rowCount, 2, 'worker invoice should include two overage items');
|
||||
const studentItem = items.rows.find(item => item.metadata?.metricKey === 'students');
|
||||
const storageItem = items.rows.find(item => item.metadata?.metricKey === 'storage_gb');
|
||||
assert.ok(studentItem, 'student overage item should exist');
|
||||
assert.ok(storageItem, 'storage overage item should exist');
|
||||
assert.equal(Number(studentItem.quantity), 3, '125 students over quota 100 should bill three 10-student units');
|
||||
assert.equal(Number(studentItem.amount_cents), 600, 'student overage amount should match unit pricing');
|
||||
assert.equal(Number(storageItem.quantity), 2, '3.5GB over quota 2GB should bill two 1GB units');
|
||||
assert.equal(Number(storageItem.amount_cents), 2400, 'storage overage amount should match unit pricing');
|
||||
|
||||
const noOverageInvoice = await pool.query(
|
||||
`
|
||||
select count(*)::integer as count
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1 and invoice_type = 'usage_overage'
|
||||
`,
|
||||
[ids.noOverageTenant],
|
||||
);
|
||||
assert.equal(noOverageInvoice.rows[0]?.count, 0, 'tenant under quota should not get an overage invoice');
|
||||
|
||||
const audit = await pool.query(
|
||||
`
|
||||
select action, target_type, target_id, details
|
||||
from public.audit_logs
|
||||
where tenant_id = $1 and action = 'platform.invoice.usage_overage_created'
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
assert.equal(audit.rows[0]?.target_type, 'tenant_invoice', 'worker should audit generated invoice');
|
||||
assert.equal(audit.rows[0]?.target_id, invoice.id, 'worker audit should target invoice id');
|
||||
assert.equal(audit.rows[0]?.details?.workerId, 'platform-usage-overage-integration-test', 'worker audit should include worker id');
|
||||
|
||||
const secondOutput = await runWorkerOnce();
|
||||
assert.match(secondOutput, /created=0/, 'second worker run should not duplicate usage overage invoice');
|
||||
assert.match(secondOutput, /skipped=1/, 'second worker run should report the existing invoice as skipped');
|
||||
|
||||
const invoiceCount = await pool.query(
|
||||
`
|
||||
select count(*)::integer as count
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1 and invoice_type = 'usage_overage'
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
assert.equal(invoiceCount.rows[0]?.count, 1, 'worker should be idempotent for usage overage invoices');
|
||||
|
||||
console.log('Platform usage overage 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