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 };
}

View File

@@ -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'>

View File

@@ -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',