forked from wangziqi/gongxue-base
feat: add platform subscription invoice batching
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ import { Input } from '@tarojs/components';
|
||||
import {
|
||||
confirmPlatformInvoicePayment,
|
||||
createPlatformInvoiceFromSubscription,
|
||||
createPlatformInvoicesBatchFromSubscriptions,
|
||||
createPlatformSubscription,
|
||||
loadPlatformInvoices,
|
||||
loadPlatformPlans,
|
||||
loadPlatformSubscriptionInvoiceCandidates,
|
||||
loadPlatformUsage,
|
||||
recordPlatformUsage,
|
||||
type PlatformInvoiceItem,
|
||||
type PlatformSaasPlan,
|
||||
type PlatformSubscriptionInvoiceCandidate,
|
||||
type PlatformUsageItem,
|
||||
} from '@/services/platformAdmin';
|
||||
import '../platform.css';
|
||||
@@ -36,6 +39,8 @@ export default function PlatformBillingPage() {
|
||||
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
|
||||
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
|
||||
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
|
||||
const [candidates, setCandidates] = useState<PlatformSubscriptionInvoiceCandidate[]>([]);
|
||||
const [batchResult, setBatchResult] = useState('');
|
||||
const [subscriptionForm, setSubscriptionForm] = useState({
|
||||
tenantId: '',
|
||||
planCode: '',
|
||||
@@ -60,6 +65,11 @@ export default function PlatformBillingPage() {
|
||||
periodStart: todayText(),
|
||||
periodEnd: todayText(),
|
||||
});
|
||||
const [batchInvoiceForm, setBatchInvoiceForm] = useState({
|
||||
daysAhead: '45',
|
||||
dueDate: '',
|
||||
note: '',
|
||||
});
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -68,12 +78,14 @@ export default function PlatformBillingPage() {
|
||||
loadPlatformPlans(true).catch(() => ({ items: [] })),
|
||||
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
|
||||
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
|
||||
]).then(([planPayload, invoicePayload, usagePayload]) => {
|
||||
loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })),
|
||||
]).then(([planPayload, invoicePayload, usagePayload, candidatePayload]) => {
|
||||
const nextPlans = planPayload.items || [];
|
||||
setPlans(nextPlans);
|
||||
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
|
||||
setInvoices(invoicePayload.items || []);
|
||||
setUsage(usagePayload.items || []);
|
||||
setCandidates(candidatePayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
|
||||
}
|
||||
|
||||
@@ -105,6 +117,10 @@ export default function PlatformBillingPage() {
|
||||
setUsageForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateBatchInvoiceForm(key: keyof typeof batchInvoiceForm, value: string) {
|
||||
setBatchInvoiceForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function confirm(title: string, content: string) {
|
||||
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
|
||||
return result.confirm;
|
||||
@@ -215,6 +231,58 @@ export default function PlatformBillingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubscriptionCandidates() {
|
||||
setError('');
|
||||
setBusy('candidates');
|
||||
try {
|
||||
const payload = await loadPlatformSubscriptionInvoiceCandidates({
|
||||
daysAhead: Number(batchInvoiceForm.daysAhead || 45),
|
||||
limit: 100,
|
||||
});
|
||||
setCandidates(payload.items || []);
|
||||
setBatchResult('');
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '订阅账单候选加载失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBatchInvoices(dryRun: boolean) {
|
||||
setError('');
|
||||
const daysAhead = Number(batchInvoiceForm.daysAhead || 45);
|
||||
if (!Number.isFinite(daysAhead) || daysAhead < 0) {
|
||||
setError('请填写有效的候选天数。');
|
||||
return;
|
||||
}
|
||||
const ok = dryRun
|
||||
? true
|
||||
: await confirm('批量生成账单', `确认给 ${candidates.length} 个候选订阅批量生成服务费账单?后端会跳过已存在账单的订阅。`);
|
||||
if (!ok) return;
|
||||
setBusy(dryRun ? 'batch-dry-run' : 'batch-create');
|
||||
try {
|
||||
const payload = await createPlatformInvoicesBatchFromSubscriptions({
|
||||
daysAhead,
|
||||
dueDate: batchInvoiceForm.dueDate || undefined,
|
||||
note: batchInvoiceForm.note.trim() || undefined,
|
||||
status: 'issued',
|
||||
dryRun,
|
||||
});
|
||||
const item = payload.item || {};
|
||||
setBatchResult(`${dryRun ? '预览' : '生成'}完成:创建 ${item.createdCount || 0},跳过 ${item.skippedCount || 0}`);
|
||||
if (dryRun) {
|
||||
setCandidates((item.items || []).map(candidate => ({ ...candidate, wouldCreate: true })));
|
||||
} else {
|
||||
Taro.showToast({ title: '已批量生成', icon: 'success' });
|
||||
reload(status);
|
||||
}
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '批量账单处理失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='platform-page'>
|
||||
<View className='platform-shell'>
|
||||
@@ -288,6 +356,31 @@ export default function PlatformBillingPage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='platform-section'>
|
||||
<Text className='platform-section-title'>批量订阅账单</Text>
|
||||
<View className='platform-form compact'>
|
||||
<View className='platform-field'><Text className='platform-field-label'>候选天数</Text><Input className='platform-input' placeholder='45' type='number' value={batchInvoiceForm.daysAhead} onInput={event => updateBatchInvoiceForm('daysAhead', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>到期日</Text><Input className='platform-input' placeholder='YYYY-MM-DD,可选' value={batchInvoiceForm.dueDate} onInput={event => updateBatchInvoiceForm('dueDate', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>批量备注</Text><Input className='platform-input' placeholder='账单说明,可选' value={batchInvoiceForm.note} onInput={event => updateBatchInvoiceForm('note', String(event.detail.value || ''))} /></View>
|
||||
</View>
|
||||
<View className='platform-actions'>
|
||||
<Button className='platform-button' loading={busy === 'candidates'} onClick={loadSubscriptionCandidates}>刷新候选</Button>
|
||||
<Button className='platform-button' loading={busy === 'batch-dry-run'} onClick={() => submitBatchInvoices(true)}>预览批量</Button>
|
||||
<Button className='platform-button primary' loading={busy === 'batch-create'} onClick={() => submitBatchInvoices(false)}>批量生成账单</Button>
|
||||
</View>
|
||||
{batchResult ? <Text className='platform-row-meta'>{batchResult}</Text> : null}
|
||||
<View className='platform-list'>
|
||||
{candidates.slice(0, 12).map(item => (
|
||||
<View className='platform-row' key={item.subscriptionId}>
|
||||
<Text className='platform-row-main'>{item.tenantName || item.tenantSlug || item.tenantId}</Text>
|
||||
<Text className='platform-row-meta'>{item.planName || item.planCode || 'plan'} · {item.status || '-'} · {money(item.amountCents)} · 到期 {String(item.expiresAt || '').slice(0, 10) || '-'}</Text>
|
||||
<Text className='platform-row-meta'>{item.hasExistingInvoice ? `已有账单 ${item.existingInvoiceNo || item.existingInvoiceId}` : item.wouldCreate ? '预览会生成' : '可生成订阅服务费账单'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!candidates.length ? <View className='platform-empty'>暂无即将到期且未开票的订阅。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='platform-section'>
|
||||
<Text className='platform-section-title'>SaaS 套餐</Text>
|
||||
<View className='platform-list'>
|
||||
|
||||
@@ -138,6 +138,26 @@ export interface PlatformUsageItem {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PlatformSubscriptionInvoiceCandidate {
|
||||
tenantId: string;
|
||||
tenantSlug?: string | null;
|
||||
tenantName?: string | null;
|
||||
billingStatus?: string | null;
|
||||
subscriptionId: string;
|
||||
planCode?: string | null;
|
||||
planName?: string | null;
|
||||
status?: string | null;
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
billingCycle?: string | null;
|
||||
amountCents?: number | string | null;
|
||||
existingInvoiceId?: string | null;
|
||||
existingInvoiceNo?: string | null;
|
||||
existingInvoiceStatus?: string | null;
|
||||
hasExistingInvoice?: boolean | null;
|
||||
wouldCreate?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PlatformQuestionBankItem {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
@@ -226,6 +246,16 @@ export interface CreatePlatformInvoiceFromSubscriptionInput {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface CreatePlatformInvoicesBatchFromSubscriptionsInput {
|
||||
tenantIds?: string[];
|
||||
subscriptionIds?: string[];
|
||||
daysAhead?: number;
|
||||
status?: string;
|
||||
dueDate?: string;
|
||||
note?: string;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmPlatformInvoicePaymentInput {
|
||||
tenantId: string;
|
||||
invoiceId: string;
|
||||
@@ -302,6 +332,13 @@ export async function loadPlatformUsage(query: { tenantId?: string; limit?: numb
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPlatformSubscriptionInvoiceCandidates(query: { tenantIds?: string; subscriptionIds?: string; daysAhead?: number; includeExisting?: boolean; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: PlatformSubscriptionInvoiceCandidate[] }>('/api/platform-admin/invoices/subscription-candidates', {
|
||||
query: { ...query, limit: query.limit || 100 },
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPlatformQuestionBanks(query: { q?: string; status?: string; includeTenantBanks?: boolean; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: PlatformQuestionBankItem[] }>('/api/platform-admin/question-banks', {
|
||||
query: { status: 'active', ...query, limit: query.limit || 80 },
|
||||
@@ -356,6 +393,22 @@ export async function createPlatformInvoiceFromSubscription(input: CreatePlatfor
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlatformInvoicesBatchFromSubscriptions(input: CreatePlatformInvoicesBatchFromSubscriptionsInput) {
|
||||
return apiRequest<{
|
||||
item?: {
|
||||
dryRun?: boolean;
|
||||
createdCount?: number;
|
||||
skippedCount?: number;
|
||||
items?: Array<PlatformSubscriptionInvoiceCandidate & { invoice?: PlatformInvoiceItem }>;
|
||||
skipped?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}>('/api/platform-admin/invoices/from-subscriptions-batch', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/payments/manual-confirm', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user