forked from wangziqi/gongxue-base
203 lines
8.0 KiB
JavaScript
203 lines
8.0 KiB
JavaScript
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-00000000b901',
|
|
subscription: '00000000-0000-0000-0000-00000000b902',
|
|
noExpirySubscription: '00000000-0000-0000-0000-00000000b903',
|
|
};
|
|
|
|
function runWorkerOnce() {
|
|
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-billing'], {
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
DATABASE_URL: databaseUrl,
|
|
WORKER_PLATFORM_BILLING_BATCH_SIZE: '10',
|
|
WORKER_PLATFORM_BILLING_DAYS_AHEAD: '90',
|
|
WORKER_PLATFORM_BILLING_DUE_DAYS: '20',
|
|
WORKER_PLATFORM_BILLING_ID: 'platform-billing-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-billing batch processed=\d+/, 'worker output should include platform billing 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_payments 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_invoices where tenant_id = $1', [ids.tenant]);
|
|
await pool.query('delete from public.tenant_subscriptions 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 createTenantAndSubscription(pool) {
|
|
await pool.query(
|
|
`
|
|
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
|
|
values ($1, 'platform-billing-worker', '平台自动计费测试租户', '平台自动计费测试有限公司', 'active', 'saas', 'active', '{"source":"platform-billing-worker-test"}'::jsonb)
|
|
`,
|
|
[ids.tenant],
|
|
);
|
|
await pool.query(
|
|
`
|
|
insert into public.tenant_billing_profiles (
|
|
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
|
|
invoice_title, invoice_type
|
|
)
|
|
values (
|
|
$1, '平台自动计费测试有限公司', '91120000BILLING', '财务测试', '13900001111',
|
|
'billing-worker@example.test', '平台自动计费测试有限公司', 'normal_vat'
|
|
)
|
|
`,
|
|
[ids.tenant],
|
|
);
|
|
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, $2, 'starter_yearly', 'active',
|
|
now() - interval '330 days',
|
|
now() + interval '20 days',
|
|
'yearly', 980000, '{"source":"platform-billing-worker-test"}'::jsonb
|
|
)
|
|
`,
|
|
[ids.subscription, ids.tenant],
|
|
);
|
|
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, $2, 'starter_yearly', 'active',
|
|
now() - interval '30 days',
|
|
null,
|
|
'yearly', 980000, '{"source":"platform-billing-worker-test","case":"no-expiry"}'::jsonb
|
|
)
|
|
`,
|
|
[ids.noExpirySubscription, ids.tenant],
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const pool = new pg.Pool({ connectionString: databaseUrl });
|
|
try {
|
|
await cleanup(pool);
|
|
await createTenantAndSubscription(pool);
|
|
|
|
const firstOutput = await runWorkerOnce();
|
|
assert.match(firstOutput, /processed=1/, 'worker should process one subscription candidate');
|
|
assert.match(firstOutput, /created=1/, 'worker should create one subscription invoice');
|
|
assert.match(firstOutput, /failed=0/, 'worker should not fail the subscription invoice');
|
|
|
|
const invoices = await pool.query(
|
|
`
|
|
select id, invoice_no, status, invoice_type, total_cents, balance_cents,
|
|
billing_period_start, billing_period_end, due_date, note, metadata
|
|
from public.tenant_invoices
|
|
where tenant_id = $1 and metadata->>'subscriptionId' = $2
|
|
`,
|
|
[ids.tenant, ids.subscription],
|
|
);
|
|
assert.equal(invoices.rowCount, 1, 'worker should create exactly one invoice for the subscription');
|
|
const invoice = invoices.rows[0];
|
|
assert.equal(invoice.status, 'issued', 'worker invoice should be issued');
|
|
assert.equal(invoice.invoice_type, 'subscription', 'worker invoice should be subscription type');
|
|
assert.equal(Number(invoice.total_cents), 980000, 'worker invoice should use subscription amount');
|
|
assert.equal(Number(invoice.balance_cents), 980000, 'worker invoice should start unpaid');
|
|
assert.equal(invoice.metadata?.source, 'subscription_auto', 'worker invoice metadata should record auto source');
|
|
assert.equal(invoice.metadata?.workerId, 'platform-billing-integration-test', 'worker invoice metadata should record worker id');
|
|
assert.ok(invoice.due_date, 'worker invoice should set a due date');
|
|
|
|
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
|
|
`,
|
|
[ids.tenant, invoice.id],
|
|
);
|
|
assert.equal(items.rowCount, 1, 'worker invoice should create one invoice item');
|
|
assert.equal(items.rows[0].item_type, 'subscription', 'invoice item should be subscription');
|
|
assert.equal(Number(items.rows[0].amount_cents), 980000, 'invoice item amount should match subscription amount');
|
|
assert.equal(items.rows[0].metadata?.planCode, 'starter_yearly', 'invoice item should record plan code');
|
|
|
|
const audit = await pool.query(
|
|
`
|
|
select action, target_type, target_id, details
|
|
from public.audit_logs
|
|
where tenant_id = $1 and action = 'platform.invoice.subscription_auto_created'
|
|
order by created_at desc
|
|
limit 1
|
|
`,
|
|
[ids.tenant],
|
|
);
|
|
assert.equal(audit.rows[0]?.target_type, 'tenant_invoice', 'worker should audit the generated invoice');
|
|
assert.equal(audit.rows[0]?.target_id, invoice.id, 'worker audit target should be the invoice id');
|
|
assert.equal(audit.rows[0]?.details?.subscriptionId, ids.subscription, 'worker audit should record subscription id');
|
|
|
|
const secondOutput = await runWorkerOnce();
|
|
assert.match(secondOutput, /created=0/, 'second worker run should not duplicate subscription invoice');
|
|
|
|
const invoiceCount = await pool.query(
|
|
`
|
|
select count(*)::integer as count
|
|
from public.tenant_invoices
|
|
where tenant_id = $1 and metadata->>'subscriptionId' = $2
|
|
`,
|
|
[ids.tenant, ids.subscription],
|
|
);
|
|
assert.equal(Number(invoiceCount.rows[0]?.count), 1, 'worker should be idempotent for subscription invoices');
|
|
|
|
const noExpiryInvoiceCount = await pool.query(
|
|
`
|
|
select count(*)::integer as count
|
|
from public.tenant_invoices
|
|
where tenant_id = $1 and metadata->>'subscriptionId' = $2
|
|
`,
|
|
[ids.tenant, ids.noExpirySubscription],
|
|
);
|
|
assert.equal(Number(noExpiryInvoiceCount.rows[0]?.count), 0, 'worker should not auto invoice subscriptions without an expiry date');
|
|
|
|
console.log('Platform billing worker integration test complete.');
|
|
} finally {
|
|
await cleanup(pool).catch(() => {});
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|