feat: add usage overage billing

This commit is contained in:
Codex
2026-06-30 20:45:30 +08:00
parent ff2b0a83ee
commit dbefc0568a
17 changed files with 986 additions and 25 deletions

View File

@@ -5,6 +5,7 @@ import {
createSubscriptionRoute,
createTenantInvoiceFromSubscriptionRoute,
createTenantInvoicesBatchFromSubscriptionsRoute,
createTenantInvoicesFromUsageOverageRoute,
createTenantRoute,
invoiceRemindersRoute,
platformAuditAlertRulesRoute,
@@ -25,6 +26,7 @@ import {
questionBankGrantsRoute,
recordUsageRoute,
subscriptionInvoiceCandidatesRoute,
usageOverageInvoiceCandidatesRoute,
tenantDetailRoute,
tenantInvoicesRoute,
tenantsRoute,
@@ -70,8 +72,10 @@ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
['GET', '/api/platform-admin/invoices/subscription-candidates', subscriptionInvoiceCandidatesRoute],
['GET', '/api/platform-admin/invoices/usage-overage-candidates', usageOverageInvoiceCandidatesRoute],
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
['POST', '/api/platform-admin/invoices/from-subscriptions-batch', createTenantInvoicesBatchFromSubscriptionsRoute],
['POST', '/api/platform-admin/invoices/from-usage-overage', createTenantInvoicesFromUsageOverageRoute],
['POST', '/api/platform-admin/invoices/process-overdue', processOverdueInvoicesRoute],
['GET', '/api/platform-admin/invoices/reminders', invoiceRemindersRoute],
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],

View File

@@ -349,6 +349,36 @@ function tenantInvoiceStatusFrom(value: string) {
return status;
}
function usageOverageInvoiceStatusFrom(value: string) {
const status = tenantInvoiceStatusFrom(value);
if (!['draft', 'issued'].includes(status)) {
throw new HttpError(400, 'usage overage invoices can only be draft or issued', 'INVALID_INVOICE_STATUS');
}
return status;
}
function dateTextFrom(value: unknown, key: string, required = true) {
const text = typeof value === 'string' ? value.trim() : '';
if (!text) {
if (required) throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
return '';
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
throw new HttpError(400, `${key} must use YYYY-MM-DD format`, 'INVALID_DATE');
}
const date = new Date(`${text}T00:00:00.000Z`);
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== text) {
throw new HttpError(400, `${key} must be a valid date`, 'INVALID_DATE');
}
return text;
}
function assertDateRange(periodStart: string, periodEnd: string) {
if (periodStart > periodEnd) {
throw new HttpError(400, 'periodStart must be before or equal to periodEnd', 'INVALID_DATE_RANGE');
}
}
function platformAuditDetails(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
@@ -2688,9 +2718,9 @@ export async function recordUsageRoute(ctx: RequestContext) {
const tenantId = requiredString(body, 'tenantId');
const metricKey = requiredString(body, 'metricKey');
const metricValue = quantityFrom(body.metricValue, 0);
const periodStart = optionalString(body, 'periodStart');
const periodEnd = optionalString(body, 'periodEnd');
if (!periodStart || !periodEnd) throw new HttpError(400, 'periodStart and periodEnd are required', 'REQUIRED_FIELD');
const periodStart = dateTextFrom(body.periodStart, 'periodStart');
const periodEnd = dateTextFrom(body.periodEnd, 'periodEnd');
assertDateRange(periodStart, periodEnd);
const item = await queryOne(
`
@@ -2732,6 +2762,387 @@ export async function tenantUsageRoute(ctx: RequestContext) {
return { items };
}
interface UsageMetricSnapshot {
value: number;
recordId: string;
source: string | null;
createdAt: string | null;
}
interface UsageOverageItem {
itemType: string;
description: string;
quantity: number;
unitAmountCents: number;
metadata: Record<string, unknown>;
}
interface UsageOverageCandidate {
tenantId: string;
tenantSlug: string;
tenantName: string;
billingStatus: string;
subscriptionId: string;
planCode: string;
planName: string | null;
subscriptionStatus: string;
billingCycle: string | null;
periodStart: string;
periodEnd: string;
existingInvoiceId: string | null;
existingInvoiceNo: string | null;
existingInvoiceStatus: string | null;
hasExistingInvoice: boolean;
wouldCreate: boolean;
totalCents: number;
items: UsageOverageItem[];
}
const USAGE_METRIC_ALIASES: Record<string, string[]> = {
students: ['students', 'studentCount'],
active_students: ['active_students', 'activeStudents', 'activeStudentCount'],
questions: ['questions', 'questionCount'],
assets: ['assets', 'assetCount'],
storage_gb: ['storage_gb', 'storageGb', 'storageGB', 'storage'],
videos: ['videos', 'videoCount'],
video_plays: ['video_plays', 'videoPlays', 'videoPlayCount'],
video_quota_consumed: ['video_quota_consumed', 'videoQuotaConsumed', 'videoQuota'],
paid_orders: ['paid_orders', 'paidOrders', 'paidOrderCount'],
paid_order_amount_cents: ['paid_order_amount_cents', 'paidOrderAmountCents', 'paidOrderGmvCents'],
active_entitlements: ['active_entitlements', 'activeEntitlements', 'activeEntitlementCount'],
};
const USAGE_METRIC_LABELS: Record<string, string> = {
students: '学生数',
active_students: '活跃学生数',
questions: '题目数量',
assets: '资源数量',
storage_gb: '存储容量 GB',
videos: '视频数量',
video_plays: '视频播放次数',
video_quota_consumed: '视频次数消耗',
paid_orders: '已支付订单数',
paid_order_amount_cents: '已支付订单金额',
active_entitlements: '有效权益数',
};
function numberOrNull(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function positiveIntegerOrNull(value: unknown) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Math.max(1, Math.trunc(parsed));
}
function metricAliases(metricKey: string) {
return [...new Set([metricKey, ...(USAGE_METRIC_ALIASES[metricKey] || [])])];
}
function camelMetricKey(metricKey: string) {
return metricKey.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
}
function snakeMetricKey(metricKey: string) {
return metricKey.replace(/[A-Z]/g, char => `_${char.toLowerCase()}`);
}
function objectOrNull(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
function nestedMetricSpec(source: Record<string, unknown>, metricKey: string) {
for (const key of metricAliases(metricKey)) {
if (Object.prototype.hasOwnProperty.call(source, key)) return source[key];
}
return undefined;
}
function firstMetricValue(sources: Array<Record<string, unknown> | null>, metricKey: string, suffixes: string[]) {
const camel = camelMetricKey(metricKey);
const snake = snakeMetricKey(metricKey);
const directKeys = metricAliases(metricKey);
const generatedKeys = suffixes.flatMap(suffix => [
`${camel}${suffix}`,
`${snake}_${suffix.replace(/[A-Z]/g, char => `_${char.toLowerCase()}`).replace(/^_/, '')}`,
]);
for (const source of sources) {
if (!source) continue;
for (const key of [...directKeys, ...generatedKeys]) {
if (Object.prototype.hasOwnProperty.call(source, key)) return source[key];
}
}
return undefined;
}
function quotaForMetric(metricKey: string, planQuotas: Record<string, unknown>, subscriptionMetadata: Record<string, unknown>) {
const metadataQuotaSources = [
objectOrNull(subscriptionMetadata.includedQuotas),
objectOrNull(subscriptionMetadata.quotas),
objectOrNull(subscriptionMetadata.quotaOverrides),
];
const sources = [...metadataQuotaSources, planQuotas];
const direct = firstMetricValue(sources, metricKey, ['Included', 'Quota', 'Limit']);
if (direct && typeof direct === 'object' && !Array.isArray(direct)) {
const spec = direct as Record<string, unknown>;
return numberOrNull(spec.included ?? spec.includedQuota ?? spec.quota ?? spec.limit ?? spec.value);
}
const numeric = numberOrNull(direct);
if (numeric !== null) return numeric;
for (const source of sources) {
if (!source) continue;
const nested = objectOrNull(nestedMetricSpec(source, metricKey));
if (nested) {
const value = numberOrNull(nested.included ?? nested.includedQuota ?? nested.quota ?? nested.limit ?? nested.value);
if (value !== null) return value;
}
}
return null;
}
function priceForMetric(metricKey: string, planPrices: Record<string, unknown>, subscriptionMetadata: Record<string, unknown>) {
const metadataPriceSources = [
objectOrNull(subscriptionMetadata.overagePrices),
objectOrNull(subscriptionMetadata.overagePriceOverrides),
objectOrNull(subscriptionMetadata.prices),
];
const sources = [...metadataPriceSources, planPrices];
const suffixes = [
'UnitAmountCents',
'AmountCents',
'PriceCents',
'OverageCents',
'PerUnitCents',
'ExtraCents',
'ExtraPerMonthCents',
'ExtraPerYearCents',
'PerMonthCents',
'PerYearCents',
];
const direct = firstMetricValue(sources, metricKey, suffixes);
const directObject = objectOrNull(direct);
let unitAmountCents = directObject
? positiveIntegerOrNull(directObject.unitAmountCents ?? directObject.amountCents ?? directObject.priceCents ?? directObject.overageCents ?? directObject.cents ?? directObject.perUnitCents)
: positiveIntegerOrNull(direct);
let unitSize = directObject ? numberOrNull(directObject.unitSize ?? directObject.step ?? directObject.per ?? directObject.quantityUnit) : null;
for (const source of sources) {
if (!source) continue;
const nested = objectOrNull(nestedMetricSpec(source, metricKey));
if (!nested) continue;
unitAmountCents = unitAmountCents ?? positiveIntegerOrNull(nested.unitAmountCents ?? nested.amountCents ?? nested.priceCents ?? nested.overageCents ?? nested.cents ?? nested.perUnitCents);
unitSize = unitSize ?? numberOrNull(nested.unitSize ?? nested.step ?? nested.per ?? nested.quantityUnit);
}
if (!unitAmountCents) return null;
return {
unitAmountCents,
unitSize: unitSize && unitSize > 0 ? unitSize : 1,
};
}
function usageSnapshotMap(value: unknown) {
const usage = objectOrNull(value) || {};
const output: Record<string, UsageMetricSnapshot> = {};
for (const [metricKey, rawSnapshot] of Object.entries(usage)) {
const snapshot = objectOrNull(rawSnapshot);
if (!snapshot) continue;
const metricValue = numberOrNull(snapshot.value);
if (metricValue === null) continue;
output[metricKey] = {
value: metricValue,
recordId: String(snapshot.recordId || ''),
source: typeof snapshot.source === 'string' ? snapshot.source : null,
createdAt: typeof snapshot.createdAt === 'string' ? snapshot.createdAt : null,
};
}
return output;
}
function buildUsageOverageItems(row: {
planCode: string;
planName: string | null;
includedQuotas: Record<string, unknown> | null;
overagePrices: Record<string, unknown> | null;
subscriptionMetadata: Record<string, unknown> | null;
usageSnapshots: unknown;
}) {
const planQuotas = row.includedQuotas || {};
const planPrices = row.overagePrices || {};
const subscriptionMetadata = row.subscriptionMetadata || {};
const usage = usageSnapshotMap(row.usageSnapshots);
const items: UsageOverageItem[] = [];
for (const [metricKey, snapshot] of Object.entries(usage)) {
const includedQuota = quotaForMetric(metricKey, planQuotas, subscriptionMetadata);
const price = priceForMetric(metricKey, planPrices, subscriptionMetadata);
if (includedQuota === null || !price) continue;
const overageValue = snapshot.value - includedQuota;
if (overageValue <= 0) continue;
const billableUnits = Math.ceil(overageValue / price.unitSize);
if (billableUnits <= 0) continue;
const label = USAGE_METRIC_LABELS[metricKey] || metricKey;
items.push({
itemType: 'usage_overage',
description: `${row.planName || row.planCode} ${label}超额 ${Number(overageValue.toFixed(4))}`,
quantity: billableUnits,
unitAmountCents: price.unitAmountCents,
metadata: {
metricKey,
metricLabel: label,
metricValue: snapshot.value,
includedQuota,
overageValue: Number(overageValue.toFixed(4)),
billableUnits,
unitSize: price.unitSize,
unitAmountCents: price.unitAmountCents,
usageRecordId: snapshot.recordId || null,
usageSource: snapshot.source,
},
});
}
return items;
}
async function usageOverageCandidateQuery(params: {
tenantIds: string[];
periodStart: string;
periodEnd: string;
includeExisting: boolean;
includeZero: boolean;
limit: number;
}) {
const rows = await query<{
tenantId: string;
tenantSlug: string;
tenantName: string;
billingStatus: string;
subscriptionId: string;
planCode: string;
planName: string | null;
subscriptionStatus: string;
billingCycle: string | null;
includedQuotas: Record<string, unknown> | null;
overagePrices: Record<string, unknown> | null;
subscriptionMetadata: Record<string, unknown> | null;
usageSnapshots: unknown;
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 as "subscriptionStatus",
s.billing_cycle as "billingCycle",
p.included_quotas as "includedQuotas",
p.overage_prices as "overagePrices",
s.metadata as "subscriptionMetadata",
coalesce(usage_snapshots.metrics, '{}'::jsonb) as "usageSnapshots",
existing.id as "existingInvoiceId",
existing.invoice_no as "existingInvoiceNo",
existing.status as "existingInvoiceStatus"
from public.tenants t
join lateral (
select id, tenant_id, plan_code, status, billing_cycle, metadata, created_at, expires_at
from public.tenant_subscriptions
where tenant_id = t.id
and status in ('trial', 'active', 'past_due')
order by case status when 'active' then 0 when 'trial' then 1 else 2 end,
expires_at desc nulls last,
created_at desc
limit 1
) s on true
join public.platform_saas_plans p on p.code = s.plan_code
left join lateral (
select jsonb_object_agg(metric_key, jsonb_build_object(
'value', metric_value,
'recordId', id,
'source', metadata->>'source',
'createdAt', created_at
)) as metrics
from (
select distinct on (u.metric_key)
u.id, u.metric_key, u.metric_value, u.metadata, u.created_at
from public.tenant_usage_records u
where u.tenant_id = t.id
and u.period_start = $2::date
and u.period_end = $3::date
order by u.metric_key,
case when u.metadata->>'source' = 'platform_usage_worker' then 0 else 1 end,
u.created_at desc
) latest
) usage_snapshots on true
left join lateral (
select id, invoice_no, status
from public.tenant_invoices i
where i.tenant_id = t.id
and i.invoice_type = 'usage_overage'
and i.status <> 'void'
and i.billing_period_start = $2::date
and i.billing_period_end = $3::date
and i.metadata->>'source' = 'usage_overage_auto'
order by i.created_at desc
limit 1
) existing on true
where t.status = 'active'
and ($1::uuid[] = '{}'::uuid[] or t.id = any($1::uuid[]))
and ($4::boolean = true or existing.id is null)
order by t.created_at asc
limit $5
`,
[params.tenantIds, params.periodStart, params.periodEnd, params.includeExisting, params.limit],
);
const candidates: UsageOverageCandidate[] = rows.map(row => {
const items = buildUsageOverageItems(row);
return {
tenantId: row.tenantId,
tenantSlug: row.tenantSlug,
tenantName: row.tenantName,
billingStatus: row.billingStatus,
subscriptionId: row.subscriptionId,
planCode: row.planCode,
planName: row.planName,
subscriptionStatus: row.subscriptionStatus,
billingCycle: row.billingCycle,
periodStart: params.periodStart,
periodEnd: params.periodEnd,
existingInvoiceId: row.existingInvoiceId,
existingInvoiceNo: row.existingInvoiceNo,
existingInvoiceStatus: row.existingInvoiceStatus,
hasExistingInvoice: Boolean(row.existingInvoiceId),
wouldCreate: items.length > 0 && !row.existingInvoiceId,
totalCents: invoiceSubtotal(items),
items,
};
});
return candidates.filter(item => params.includeZero || item.items.length > 0);
}
export async function usageOverageInvoiceCandidatesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx, 'platform:billing:read');
const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds');
const periodStart = dateTextFrom(ctx.url.searchParams.get('periodStart'), 'periodStart');
const periodEnd = dateTextFrom(ctx.url.searchParams.get('periodEnd'), 'periodEnd');
assertDateRange(periodStart, periodEnd);
const includeExisting = listQuery(ctx, 'includeExisting') === 'true';
const includeZero = listQuery(ctx, 'includeZero') === 'true';
const limit = intParam(ctx, 'limit', 100, 500);
const items = await usageOverageCandidateQuery({ tenantIds, periodStart, periodEnd, includeExisting, includeZero, limit });
return { items };
}
async function subscriptionInvoiceCandidateQuery(params: {
tenantIds: string[];
subscriptionIds: string[];
@@ -3034,3 +3445,170 @@ export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: Reque
return { item: result };
}
export async function createTenantInvoicesFromUsageOverageRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx, 'platform:billing:write');
const body = await readJsonBody(ctx);
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds');
const periodStart = dateTextFrom(body.periodStart, 'periodStart');
const periodEnd = dateTextFrom(body.periodEnd, 'periodEnd');
assertDateRange(periodStart, periodEnd);
const dueDate = dateTextFrom(body.dueDate, 'dueDate', false) || null;
const status = usageOverageInvoiceStatusFrom(optionalString(body, 'status'));
const note = optionalString(body, 'note') || null;
const dryRun = booleanFrom(body.dryRun, false);
const limit = Math.min(Math.max(tenantIds.length || 0, 100), 500);
const candidates = await usageOverageCandidateQuery({
tenantIds,
periodStart,
periodEnd,
includeExisting: true,
includeZero: false,
limit,
});
if (!candidates.length) {
return { item: { dryRun, createdCount: 0, skippedCount: 0, totalCents: 0, items: [], skipped: [] } };
}
if (dryRun) {
const wouldCreate = candidates.filter(item => !item.hasExistingInvoice);
const existing = candidates.filter(item => item.hasExistingInvoice);
return {
item: {
dryRun: true,
createdCount: 0,
skippedCount: existing.length,
totalCents: wouldCreate.reduce((sum, item) => sum + item.totalCents, 0),
items: candidates.map(item => ({ ...item, wouldCreate: !item.hasExistingInvoice })),
skipped: existing.map(item => ({
tenantId: item.tenantId,
subscriptionId: item.subscriptionId,
reason: 'USAGE_OVERAGE_INVOICE_EXISTS',
invoiceId: item.existingInvoiceId,
invoiceNo: item.existingInvoiceNo,
})),
},
};
}
const result = await transaction(async client => {
const created: Array<UsageOverageCandidate & { invoice: Record<string, unknown> }> = [];
const skipped: unknown[] = [];
for (const candidate of candidates) {
const lock = await client.query(
`
select id
from public.tenant_subscriptions
where tenant_id = $1
and id = $2
for update
`,
[candidate.tenantId, candidate.subscriptionId],
);
if (!lock.rows[0]) {
skipped.push({ tenantId: candidate.tenantId, 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 = 'usage_overage'
and status <> 'void'
and billing_period_start = $2::date
and billing_period_end = $3::date
and metadata->>'source' = 'usage_overage_auto'
order by created_at desc
limit 1
`,
[candidate.tenantId, periodStart, periodEnd],
);
if (existing.rows[0]) {
skipped.push({
tenantId: candidate.tenantId,
subscriptionId: candidate.subscriptionId,
reason: 'USAGE_OVERAGE_INVOICE_EXISTS',
invoiceId: existing.rows[0].id,
invoiceNo: existing.rows[0].invoiceNo,
});
continue;
}
if (!candidate.items.length) {
skipped.push({ tenantId: candidate.tenantId, subscriptionId: candidate.subscriptionId, reason: 'NO_USAGE_OVERAGE' });
continue;
}
const invoice = await createInvoiceRecordWithClient(client, {
tenantId: candidate.tenantId,
invoiceType: 'usage_overage',
status,
dueDate,
billingPeriodStart: periodStart,
billingPeriodEnd: periodEnd,
note,
metadata: {
source: 'usage_overage_auto',
periodStart,
periodEnd,
subscriptionId: candidate.subscriptionId,
planCode: candidate.planCode,
tenantSlug: candidate.tenantSlug,
},
items: candidate.items.map(item => ({
...item,
metadata: {
...item.metadata,
periodStart,
periodEnd,
subscriptionId: candidate.subscriptionId,
planCode: candidate.planCode,
},
})),
});
await recordPlatformAudit(client, ctx, 'platform.invoice.usage_overage_created', 'tenant_invoice', invoice.id, {
tenantId: candidate.tenantId,
subscriptionId: candidate.subscriptionId,
planCode: candidate.planCode,
periodStart,
periodEnd,
totalCents: candidate.totalCents,
metrics: candidate.items.map(item => item.metadata.metricKey),
invoiceNo: invoice.invoiceNo,
}, candidate.tenantId);
created.push({ ...candidate, invoice });
}
const totalCents = created.reduce((sum, item) => sum + Number(item.totalCents || 0), 0);
await recordPlatformAudit(client, ctx, 'platform.invoice.usage_overage_batch_created', 'tenant_invoice_batch', null, {
createdCount: created.length,
skippedCount: skipped.length,
totalCents,
tenantIds,
periodStart,
periodEnd,
dueDate,
status,
});
return {
dryRun: false,
createdCount: created.length,
skippedCount: skipped.length,
totalCents,
items: created,
skipped,
};
});
return { item: result };
}

View File

@@ -97,7 +97,6 @@ export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId:
status = case
when status = 'void' then status
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then 'paid'
when status = 'draft' then 'issued'
else status
end,
paid_at = case

View File

@@ -6,11 +6,13 @@ import {
confirmPlatformInvoicePayment,
createPlatformInvoiceFromSubscription,
createPlatformInvoicesBatchFromSubscriptions,
createPlatformInvoicesFromUsageOverage,
createPlatformSubscription,
loadPlatformInvoiceReminders,
loadPlatformInvoices,
loadPlatformPlans,
loadPlatformSubscriptionInvoiceCandidates,
loadPlatformUsageOverageInvoiceCandidates,
loadPlatformUsage,
processPlatformOverdueInvoices,
recordPlatformUsage,
@@ -18,6 +20,7 @@ import {
type PlatformInvoiceItem,
type PlatformSaasPlan,
type PlatformSubscriptionInvoiceCandidate,
type PlatformUsageOverageInvoiceCandidate,
type PlatformUsageItem,
} from '@/services/platformAdmin';
import '../platform.css';
@@ -31,6 +34,11 @@ function todayText() {
return new Date().toISOString().slice(0, 10);
}
function monthStartText() {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
}
function centsFromYuan(value: string) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) return 0;
@@ -44,6 +52,7 @@ export default function PlatformBillingPage() {
const [reminders, setReminders] = useState<PlatformInvoiceReminderItem[]>([]);
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
const [candidates, setCandidates] = useState<PlatformSubscriptionInvoiceCandidate[]>([]);
const [overageCandidates, setOverageCandidates] = useState<PlatformUsageOverageInvoiceCandidate[]>([]);
const [batchResult, setBatchResult] = useState('');
const [subscriptionForm, setSubscriptionForm] = useState({
tenantId: '',
@@ -74,6 +83,12 @@ export default function PlatformBillingPage() {
dueDate: '',
note: '',
});
const [overageForm, setOverageForm] = useState({
periodStart: monthStartText(),
periodEnd: todayText(),
dueDate: '',
note: '',
});
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -84,7 +99,12 @@ export default function PlatformBillingPage() {
loadPlatformInvoiceReminders({ limit: 80 }).catch(() => ({ items: [] })),
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })),
]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload]) => {
loadPlatformUsageOverageInvoiceCandidates({
periodStart: overageForm.periodStart,
periodEnd: overageForm.periodEnd,
limit: 100,
}).catch(() => ({ items: [] })),
]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload, overagePayload]) => {
const nextPlans = planPayload.items || [];
setPlans(nextPlans);
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
@@ -92,6 +112,7 @@ export default function PlatformBillingPage() {
setReminders(reminderPayload.items || []);
setUsage(usagePayload.items || []);
setCandidates(candidatePayload.items || []);
setOverageCandidates(overagePayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
}
@@ -127,6 +148,10 @@ export default function PlatformBillingPage() {
setBatchInvoiceForm(current => ({ ...current, [key]: value }));
}
function updateOverageForm(key: keyof typeof overageForm, value: string) {
setOverageForm(current => ({ ...current, [key]: value }));
}
async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm;
@@ -289,6 +314,62 @@ export default function PlatformBillingPage() {
}
}
async function loadUsageOverageCandidates() {
setError('');
if (!overageForm.periodStart || !overageForm.periodEnd) {
setError('请填写超额计费账期。');
return;
}
setBusy('overage-candidates');
try {
const payload = await loadPlatformUsageOverageInvoiceCandidates({
periodStart: overageForm.periodStart,
periodEnd: overageForm.periodEnd,
limit: 100,
});
setOverageCandidates(payload.items || []);
setBatchResult('');
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '超额账单候选加载失败');
} finally {
setBusy('');
}
}
async function submitUsageOverageInvoices(dryRun: boolean) {
setError('');
if (!overageForm.periodStart || !overageForm.periodEnd) {
setError('请填写超额计费账期。');
return;
}
const ok = dryRun
? true
: await confirm('生成超额账单', `确认按 ${overageForm.periodStart}${overageForm.periodEnd} 的后端用量快照生成超额服务费账单?`);
if (!ok) return;
setBusy(dryRun ? 'overage-dry-run' : 'overage-create');
try {
const payload = await createPlatformInvoicesFromUsageOverage({
periodStart: overageForm.periodStart,
periodEnd: overageForm.periodEnd,
dueDate: overageForm.dueDate || undefined,
note: overageForm.note.trim() || undefined,
status: 'issued',
dryRun,
});
const item = payload.item || {};
setBatchResult(`${dryRun ? '超额预览' : '超额账单生成'}完成:创建 ${item.createdCount || 0},跳过 ${item.skippedCount || 0},金额 ${money(item.totalCents || 0)}`);
setOverageCandidates((item.items || []).map(candidate => ({ ...candidate, wouldCreate: dryRun ? true : candidate.wouldCreate })));
if (!dryRun) {
Taro.showToast({ title: '已生成', icon: 'success' });
reload(status);
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '超额账单处理失败');
} finally {
setBusy('');
}
}
async function submitOverdueProcess(dryRun: boolean) {
setError('');
const ok = dryRun
@@ -413,6 +494,34 @@ export default function PlatformBillingPage() {
{!candidates.length ? <View className='platform-empty'></View> : null}
</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='YYYY-MM-DD' value={overageForm.periodStart} onInput={event => updateOverageForm('periodStart', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={overageForm.periodEnd} onInput={event => updateOverageForm('periodEnd', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='YYYY-MM-DD可选' value={overageForm.dueDate} onInput={event => updateOverageForm('dueDate', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='超额服务费说明,可选' value={overageForm.note} onInput={event => updateOverageForm('note', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button' loading={busy === 'overage-candidates'} onClick={loadUsageOverageCandidates}></Button>
<Button className='platform-button' loading={busy === 'overage-dry-run'} onClick={() => submitUsageOverageInvoices(true)}></Button>
<Button className='platform-button primary' loading={busy === 'overage-create'} onClick={() => submitUsageOverageInvoices(false)}></Button>
</View>
<View className='platform-list'>
{overageCandidates.slice(0, 12).map(item => (
<View className='platform-row' key={`${item.tenantId}-${item.periodStart}-${item.periodEnd}`}>
<Text className='platform-row-main'>{item.tenantName || item.tenantSlug || item.tenantId}</Text>
<Text className='platform-row-meta'>{item.planName || item.planCode || 'plan'} · {item.periodStart || '-'} {item.periodEnd || '-'} · {money(item.totalCents)}</Text>
{(item.items || []).map(overage => (
<Text className='platform-row-meta' key={`${item.tenantId}-${overage.description}`}>{overage.description || '超额项'} · {String(overage.quantity || 0)} × {money(overage.unitAmountCents)}</Text>
))}
<Text className='platform-row-meta'>{item.hasExistingInvoice ? `已有超额账单 ${item.existingInvoiceNo || item.existingInvoiceId}` : item.wouldCreate ? '预览会生成超额账单' : '可生成超额账单'}</Text>
</View>
))}
</View>
{!overageCandidates.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-actions'>

View File

@@ -207,6 +207,35 @@ export interface PlatformSubscriptionInvoiceCandidate {
wouldCreate?: boolean | null;
}
export interface PlatformUsageOverageItem {
itemType?: string | null;
description?: string | null;
quantity?: number | string | null;
unitAmountCents?: number | string | null;
metadata?: Record<string, unknown> | null;
}
export interface PlatformUsageOverageInvoiceCandidate {
tenantId: string;
tenantSlug?: string | null;
tenantName?: string | null;
billingStatus?: string | null;
subscriptionId?: string | null;
planCode?: string | null;
planName?: string | null;
subscriptionStatus?: string | null;
billingCycle?: string | null;
periodStart?: string | null;
periodEnd?: string | null;
existingInvoiceId?: string | null;
existingInvoiceNo?: string | null;
existingInvoiceStatus?: string | null;
hasExistingInvoice?: boolean | null;
wouldCreate?: boolean | null;
totalCents?: number | string | null;
items?: PlatformUsageOverageItem[];
}
export interface PlatformQuestionBankItem {
id: string;
tenantId?: string | null;
@@ -459,6 +488,16 @@ export interface CreatePlatformInvoicesBatchFromSubscriptionsInput {
dryRun?: boolean;
}
export interface CreatePlatformInvoicesFromUsageOverageInput {
tenantIds?: string[];
periodStart: string;
periodEnd: string;
dueDate?: string;
note?: string;
status?: 'draft' | 'issued';
dryRun?: boolean;
}
export interface ConfirmPlatformInvoicePaymentInput {
tenantId: string;
invoiceId: string;
@@ -695,6 +734,20 @@ export async function loadPlatformSubscriptionInvoiceCandidates(query: { tenantI
});
}
export async function loadPlatformUsageOverageInvoiceCandidates(query: {
tenantIds?: string;
periodStart: string;
periodEnd: string;
includeExisting?: boolean;
includeZero?: boolean;
limit?: number;
}) {
return apiRequest<{ items?: PlatformUsageOverageInvoiceCandidate[] }>('/api/platform-admin/invoices/usage-overage-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 },
@@ -765,6 +818,23 @@ export async function createPlatformInvoicesBatchFromSubscriptions(input: Create
});
}
export async function createPlatformInvoicesFromUsageOverage(input: CreatePlatformInvoicesFromUsageOverageInput) {
return apiRequest<{
item?: {
dryRun?: boolean;
createdCount?: number;
skippedCount?: number;
totalCents?: number;
items?: Array<PlatformUsageOverageInvoiceCandidate & { invoice?: PlatformInvoiceItem }>;
skipped?: Array<Record<string, unknown>>;
};
}>('/api/platform-admin/invoices/from-usage-overage', {
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',