feat: add platform subscription invoice batching

This commit is contained in:
Codex
2026-06-30 04:56:28 +08:00
parent d5d32e84b3
commit 5320558d13
16 changed files with 670 additions and 126 deletions

View File

@@ -4,6 +4,7 @@ import {
createInvoiceRoute,
createSubscriptionRoute,
createTenantInvoiceFromSubscriptionRoute,
createTenantInvoicesBatchFromSubscriptionsRoute,
createTenantRoute,
platformAuditLogsRoute,
platformOverviewRoute,
@@ -11,6 +12,7 @@ import {
platformQuestionBanksRoute,
questionBankGrantsRoute,
recordUsageRoute,
subscriptionInvoiceCandidatesRoute,
tenantDetailRoute,
tenantInvoicesRoute,
tenantsRoute,
@@ -35,7 +37,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
['GET', '/api/platform-admin/invoices/subscription-candidates', subscriptionInvoiceCandidatesRoute],
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
['POST', '/api/platform-admin/invoices/from-subscriptions-batch', createTenantInvoicesBatchFromSubscriptionsRoute],
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
['GET', '/api/platform-admin/usage', tenantUsageRoute],
['POST', '/api/platform-admin/usage', recordUsageRoute],

View File

@@ -39,6 +39,38 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
return optionalStringArray(body, key).filter(Boolean);
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
function optionalUuidList(value: unknown, key = 'ids', maxLength = 500) {
const values = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
: typeof value === 'string' && value.trim()
? value.split(',').map(item => item.trim()).filter(Boolean)
: [];
if (values.length > maxLength) throw new HttpError(400, `${key} contains too many values`, 'TOO_MANY_UUIDS');
for (const item of values) {
if (!UUID_RE.test(item)) throw new HttpError(400, `${key} contains an invalid UUID`, 'INVALID_UUID');
}
return values;
}
function daysAheadFrom(value: unknown, fallback = 45) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
return Math.min(Math.trunc(parsed), 1095);
}
function booleanFrom(value: unknown, fallback = false) {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
if (value === 'true') return true;
if (value === 'false') return false;
}
return fallback;
}
function billingInvoiceTypeFrom(value: string) {
const invoiceType = value || 'none';
if (!['none', 'normal_vat', 'special_vat'].includes(invoiceType)) {
@@ -47,6 +79,14 @@ function billingInvoiceTypeFrom(value: string) {
return invoiceType;
}
function tenantInvoiceStatusFrom(value: string) {
const status = value || 'issued';
if (!TENANT_INVOICE_STATUSES.has(status)) {
throw new HttpError(400, 'invoice status is invalid', 'INVALID_INVOICE_STATUS');
}
return status;
}
function platformAuditDetails(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
@@ -939,7 +979,7 @@ interface CreateInvoiceInput {
items: ReturnType<typeof normalizeInvoiceItems>;
}
async function createInvoiceRecord(input: CreateInvoiceInput) {
async function createInvoiceRecordWithClient(client: pg.PoolClient, input: CreateInvoiceInput) {
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
const discountCents = centsFrom(input.discountCents, 0);
@@ -948,70 +988,72 @@ async function createInvoiceRecord(input: CreateInvoiceInput) {
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
const invoiceNo = input.invoiceNo || createInvoiceNo();
return transaction(async client => {
const invoiceResult = await client.query(
const invoiceResult = await client.query(
`
insert into public.tenant_invoices (
tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
)
values (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, 0, $9,
$10::date, $11::date, $12::date,
case when $4 = 'draft' then null else now() end,
$13, $14::jsonb
)
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
invoice_type as "invoiceType", status, total_cents as "totalCents",
balance_cents as "balanceCents", due_date as "dueDate",
created_at as "createdAt"
`,
[
input.tenantId,
invoiceNo,
input.invoiceType || 'subscription',
input.status || 'issued',
input.currency || 'CNY',
subtotalCents,
discountCents,
taxCents,
totalCents,
input.billingPeriodStart || null,
input.billingPeriodEnd || null,
input.dueDate || null,
input.note || null,
JSON.stringify(input.metadata || {}),
],
);
const invoice = invoiceResult.rows[0];
for (const itemInput of input.items) {
await client.query(
`
insert into public.tenant_invoices (
tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
insert into public.tenant_invoice_items (
tenant_id, invoice_id, item_type, description,
quantity, unit_amount_cents, amount_cents, metadata
)
values (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, 0, $9,
$10::date, $11::date, $12::date,
case when $4 = 'draft' then null else now() end,
$13, $14::jsonb
)
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
invoice_type as "invoiceType", status, total_cents as "totalCents",
balance_cents as "balanceCents", due_date as "dueDate",
created_at as "createdAt"
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`,
[
input.tenantId,
invoiceNo,
input.invoiceType || 'subscription',
input.status || 'issued',
input.currency || 'CNY',
subtotalCents,
discountCents,
taxCents,
totalCents,
input.billingPeriodStart || null,
input.billingPeriodEnd || null,
input.dueDate || null,
input.note || null,
JSON.stringify(input.metadata || {}),
invoice.id,
itemInput.itemType,
itemInput.description,
itemInput.quantity,
itemInput.unitAmountCents,
Math.round(itemInput.quantity * itemInput.unitAmountCents),
JSON.stringify(itemInput.metadata || {}),
],
);
}
const invoice = invoiceResult.rows[0];
for (const itemInput of input.items) {
await client.query(
`
insert into public.tenant_invoice_items (
tenant_id, invoice_id, item_type, description,
quantity, unit_amount_cents, amount_cents, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`,
[
input.tenantId,
invoice.id,
itemInput.itemType,
itemInput.description,
itemInput.quantity,
itemInput.unitAmountCents,
Math.round(itemInput.quantity * itemInput.unitAmountCents),
JSON.stringify(itemInput.metadata || {}),
],
);
}
await recalculateInvoiceTotals(client, invoice.id);
return invoice;
}
await recalculateInvoiceTotals(client, invoice.id);
return invoice;
});
async function createInvoiceRecord(input: CreateInvoiceInput) {
return transaction(async client => createInvoiceRecordWithClient(client, input));
}
export async function createInvoiceRoute(ctx: RequestContext) {
@@ -1176,6 +1218,87 @@ export async function tenantUsageRoute(ctx: RequestContext) {
return { items };
}
async function subscriptionInvoiceCandidateQuery(params: {
tenantIds: string[];
subscriptionIds: string[];
daysAhead: number;
includeExisting: boolean;
limit: number;
}) {
return query<{
tenantId: string;
tenantSlug: string;
tenantName: string;
billingStatus: string;
subscriptionId: string;
planCode: string;
planName: string | null;
status: string;
startsAt: string | null;
expiresAt: string | null;
billingCycle: string | null;
amountCents: number;
existingInvoiceId: string | null;
existingInvoiceNo: string | null;
existingInvoiceStatus: string | null;
}>(
`
select t.id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
t.billing_status as "billingStatus",
s.id as "subscriptionId", s.plan_code as "planCode",
p.name as "planName", s.status, s.starts_at as "startsAt",
s.expires_at as "expiresAt", s.billing_cycle as "billingCycle",
s.amount_cents as "amountCents",
existing.id as "existingInvoiceId",
existing.invoice_no as "existingInvoiceNo",
existing.status as "existingInvoiceStatus"
from public.tenant_subscriptions s
join public.tenants t on t.id = s.tenant_id
left join public.platform_saas_plans p on p.code = s.plan_code
left join lateral (
select id, invoice_no, status
from public.tenant_invoices i
where i.tenant_id = s.tenant_id
and i.invoice_type = 'subscription'
and i.status <> 'void'
and i.metadata->>'subscriptionId' = s.id::text
order by i.created_at desc
limit 1
) existing on true
where t.status = 'active'
and s.status in ('trial', 'active', 'past_due')
and ($1::uuid[] = '{}'::uuid[] or s.tenant_id = any($1::uuid[]))
and ($2::uuid[] = '{}'::uuid[] or s.id = any($2::uuid[]))
and (
s.expires_at is null
or s.expires_at <= now() + ($3::integer * interval '1 day')
)
and ($4::boolean = true or existing.id is null)
order by s.expires_at asc nulls last, t.created_at asc
limit $5
`,
[params.tenantIds, params.subscriptionIds, params.daysAhead, params.includeExisting, params.limit],
);
}
export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds');
const subscriptionIds = optionalUuidList(ctx.url.searchParams.get('subscriptionIds'), 'subscriptionIds');
const daysAhead = daysAheadFrom(ctx.url.searchParams.get('daysAhead'), 45);
const includeExisting = listQuery(ctx, 'includeExisting') === 'true';
const limit = intParam(ctx, 'limit', 100, 500);
const items = await subscriptionInvoiceCandidateQuery({ tenantIds, subscriptionIds, daysAhead, includeExisting, limit });
return {
items: items.map(item => ({
...item,
hasExistingInvoice: Boolean(item.existingInvoiceId),
})),
};
}
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
@@ -1183,54 +1306,217 @@ export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestConte
const tenantId = requiredString(body, 'tenantId');
const subscriptionId = optionalString(body, 'subscriptionId');
const subscription = await queryOne<{
id: string;
planCode: string;
amountCents: number;
startsAt: string | null;
expiresAt: string | null;
}>(
`
select id, plan_code as "planCode", amount_cents as "amountCents",
starts_at as "startsAt", expires_at as "expiresAt"
from public.tenant_subscriptions
where tenant_id = $1
and ($2::uuid is null or id = $2::uuid)
order by created_at desc
limit 1
`,
[tenantId, subscriptionId || null],
);
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
const item = await transaction(async client => {
const subscriptionResult = await client.query<{
id: string;
planCode: string;
amountCents: number;
startsAt: string | null;
expiresAt: string | null;
}>(
`
select id, plan_code as "planCode", amount_cents as "amountCents",
starts_at as "startsAt", expires_at as "expiresAt"
from public.tenant_subscriptions
where tenant_id = $1
and ($2::uuid is null or id = $2::uuid)
order by created_at desc
limit 1
for update
`,
[tenantId, subscriptionId || null],
);
const subscription = subscriptionResult.rows[0];
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
const plan = await queryOne<{ name: string }>(
`
select name from public.platform_saas_plans
where code = $1
limit 1
`,
[subscription.planCode],
);
const planResult = await client.query<{ name: string }>(
`
select name from public.platform_saas_plans
where code = $1
limit 1
`,
[subscription.planCode],
);
const plan = planResult.rows[0];
const item = await createInvoiceRecord({
tenantId,
invoiceType: 'subscription',
status: optionalString(body, 'status') || 'issued',
dueDate: optionalString(body, 'dueDate') || null,
billingPeriodStart: toDateText(subscription.startsAt),
billingPeriodEnd: toDateText(subscription.expiresAt),
note: optionalString(body, 'note') || null,
metadata: { source: 'subscription', subscriptionId: subscription.id },
items: [
{
itemType: 'subscription',
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
quantity: 1,
unitAmountCents: subscription.amountCents,
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
},
],
const existing = await client.query<{ id: string; invoiceNo: string; status: string }>(
`
select id, invoice_no as "invoiceNo", status
from public.tenant_invoices
where tenant_id = $1
and invoice_type = 'subscription'
and status <> 'void'
and metadata->>'subscriptionId' = $2
order by created_at desc
limit 1
`,
[tenantId, subscription.id],
);
if (existing.rows[0]) {
throw new HttpError(409, 'Invoice already exists for this subscription', 'SUBSCRIPTION_INVOICE_EXISTS');
}
const invoice = await createInvoiceRecordWithClient(client, {
tenantId,
invoiceType: 'subscription',
status: tenantInvoiceStatusFrom(optionalString(body, 'status')),
dueDate: optionalString(body, 'dueDate') || null,
billingPeriodStart: toDateText(subscription.startsAt),
billingPeriodEnd: toDateText(subscription.expiresAt),
note: optionalString(body, 'note') || null,
metadata: { source: 'subscription', subscriptionId: subscription.id },
items: [
{
itemType: 'subscription',
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
quantity: 1,
unitAmountCents: subscription.amountCents,
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
},
],
});
await recordPlatformAudit(client, ctx, 'platform.invoice.subscription_created', 'tenant_invoice', invoice.id, {
tenantId,
subscriptionId: subscription.id,
planCode: subscription.planCode,
invoiceNo: invoice.invoiceNo,
source: 'single',
}, tenantId);
return invoice;
});
return { item };
}
export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds');
const requestedSubscriptionIds = optionalUuidList(body.subscriptionIds, 'subscriptionIds');
const daysAhead = daysAheadFrom(body.daysAhead, 45);
const status = tenantInvoiceStatusFrom(optionalString(body, 'status'));
const dueDate = optionalString(body, 'dueDate') || null;
const note = optionalString(body, 'note') || null;
const dryRun = booleanFrom(body.dryRun, false);
const limit = Math.min(Math.max(requestedSubscriptionIds.length || 0, 100), 500);
const candidates = await subscriptionInvoiceCandidateQuery({
tenantIds,
subscriptionIds: requestedSubscriptionIds,
daysAhead,
includeExisting: false,
limit,
});
const scopedCandidates = candidates;
if (!scopedCandidates.length) {
return { item: { dryRun, createdCount: 0, skippedCount: 0, items: [] } };
}
if (dryRun) {
return {
item: {
dryRun: true,
createdCount: 0,
skippedCount: 0,
items: scopedCandidates.map(item => ({
...item,
hasExistingInvoice: false,
wouldCreate: true,
})),
},
};
}
const result = await transaction(async client => {
const created: unknown[] = [];
const skipped: unknown[] = [];
for (const candidate of scopedCandidates) {
const lock = await client.query(
`
select id
from public.tenant_subscriptions
where id = $1
for update
`,
[candidate.subscriptionId],
);
if (!lock.rows[0]) {
skipped.push({ subscriptionId: candidate.subscriptionId, reason: 'SUBSCRIPTION_NOT_FOUND' });
continue;
}
const existing = await client.query<{ id: string; invoiceNo: string; status: string }>(
`
select id, invoice_no as "invoiceNo", status
from public.tenant_invoices
where tenant_id = $1
and invoice_type = 'subscription'
and status <> 'void'
and metadata->>'subscriptionId' = $2
order by created_at desc
limit 1
`,
[candidate.tenantId, candidate.subscriptionId],
);
if (existing.rows[0]) {
skipped.push({
tenantId: candidate.tenantId,
subscriptionId: candidate.subscriptionId,
reason: 'SUBSCRIPTION_INVOICE_EXISTS',
invoiceId: existing.rows[0].id,
invoiceNo: existing.rows[0].invoiceNo,
});
continue;
}
const invoice = await createInvoiceRecordWithClient(client, {
tenantId: candidate.tenantId,
invoiceType: 'subscription',
status,
dueDate,
billingPeriodStart: toDateText(candidate.startsAt),
billingPeriodEnd: toDateText(candidate.expiresAt),
note,
metadata: {
source: 'subscription_batch',
subscriptionId: candidate.subscriptionId,
tenantSlug: candidate.tenantSlug,
},
items: [
{
itemType: 'subscription',
description: `${candidate.planName || candidate.planCode} ${candidate.planCode}`,
quantity: 1,
unitAmountCents: Number(candidate.amountCents || 0),
metadata: { subscriptionId: candidate.subscriptionId, planCode: candidate.planCode },
},
],
});
created.push({ ...candidate, invoice });
}
await recordPlatformAudit(client, ctx, 'platform.invoice.subscription_batch_created', 'tenant_invoice_batch', null, {
createdCount: created.length,
skippedCount: skipped.length,
tenantIds,
subscriptionIds: requestedSubscriptionIds,
daysAhead,
dueDate,
status,
});
return {
dryRun: false,
createdCount: created.length,
skippedCount: skipped.length,
items: created,
skipped,
};
});
return { item: result };
}