Files
gongxue-base/apps/api/src/features/platform-admin/service.ts
2026-06-30 21:24:53 +08:00

1041 lines
34 KiB
TypeScript

import type pg from 'pg';
import { HttpError } from '../../core/errors.js';
export function createInvoiceNo(prefix = 'BILL') {
const now = new Date();
const stamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('');
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `${prefix}${stamp}${random}`;
}
export function normalizeSlug(slug: string) {
return slug
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function normalizeHost(host: string) {
return host.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0]?.split(':')[0] || '';
}
export function centsFrom(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : fallback;
}
export function quantityFrom(value: unknown, fallback = 1) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export interface InvoiceItemInput {
itemType: string;
description: string;
quantity: number;
unitAmountCents: number;
metadata?: Record<string, unknown>;
}
export interface CreateInvoiceInput {
tenantId: string;
invoiceNo?: string;
invoiceType?: string;
status?: string;
currency?: string;
discountCents?: number;
taxCents?: number;
billingPeriodStart?: string | null;
billingPeriodEnd?: string | null;
dueDate?: string | null;
note?: string | null;
metadata?: Record<string, unknown>;
items: InvoiceItemInput[];
}
export function normalizeInvoiceItems(items: unknown): InvoiceItemInput[] {
if (!Array.isArray(items)) return [];
return items
.map(item => (item && typeof item === 'object' ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => !!item)
.map(item => ({
itemType: typeof item.itemType === 'string' && item.itemType.trim() ? item.itemType.trim() : 'service_fee',
description: typeof item.description === 'string' && item.description.trim() ? item.description.trim() : '服务费',
quantity: quantityFrom(item.quantity, 1),
unitAmountCents: centsFrom(item.unitAmountCents, 0),
metadata: item.metadata && typeof item.metadata === 'object' && !Array.isArray(item.metadata) ? (item.metadata as Record<string, unknown>) : {},
}))
.filter(item => item.unitAmountCents > 0);
}
export function invoiceSubtotal(items: InvoiceItemInput[]) {
return items.reduce((sum, item) => sum + Math.round(item.quantity * item.unitAmountCents), 0);
}
export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId: string) {
const itemResult = await client.query<{ subtotal: string }>(
`
select coalesce(sum(amount_cents), 0)::text as subtotal
from public.tenant_invoice_items
where invoice_id = $1
`,
[invoiceId],
);
const paymentResult = await client.query<{ paid: string }>(
`
select coalesce(sum(amount_cents), 0)::text as paid
from public.tenant_invoice_payments
where invoice_id = $1 and status = 'paid'
`,
[invoiceId],
);
const subtotal = Number(itemResult.rows[0]?.subtotal || 0);
const paid = Number(paymentResult.rows[0]?.paid || 0);
const updateResult = await client.query(
`
update public.tenant_invoices
set subtotal_cents = $2,
total_cents = greatest(0, $2 - discount_cents + tax_cents),
paid_cents = $3,
balance_cents = greatest(0, greatest(0, $2 - discount_cents + tax_cents) - $3),
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'
else status
end,
paid_at = case
when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then coalesce(paid_at, now())
else paid_at
end,
updated_at = now()
where id = $1
returning id, invoice_no as "invoiceNo", status, subtotal_cents as "subtotalCents",
discount_cents as "discountCents", tax_cents as "taxCents",
total_cents as "totalCents", paid_cents as "paidCents",
balance_cents as "balanceCents", paid_at as "paidAt"
`,
[invoiceId, subtotal, paid],
);
return updateResult.rows[0];
}
export 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);
const taxCents = centsFrom(input.taxCents, 0);
const subtotalCents = invoiceSubtotal(input.items);
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
const invoiceNo = input.invoiceNo || createInvoiceNo();
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_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;
}
export interface UsageMetricSnapshot {
value: number;
recordId: string;
source: string | null;
createdAt: string | null;
}
export interface UsageOverageItem {
itemType: string;
description: string;
quantity: number;
unitAmountCents: number;
metadata: Record<string, unknown>;
}
export 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[];
}
export interface UsageOverageInvoiceBatchOptions {
tenantIds?: string[];
periodStart: string;
periodEnd: string;
dueDate?: string | null;
status?: 'draft' | 'issued';
note?: string | null;
dryRun?: boolean;
limit?: number;
workerId?: string | null;
actorUserId?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
}
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;
}
function clampUsageOverageLimit(value: unknown, fallback = 100) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.trunc(parsed), 500);
}
async function insertUsageOverageAudit(
client: pg.PoolClient,
input: {
tenantId: string | null;
actorUserId?: string | null;
action: string;
targetType: string;
targetId: string | null;
details: Record<string, unknown>;
ipAddress?: string | null;
userAgent?: string | null;
},
) {
await client.query(
`
insert into public.audit_logs (
tenant_id, actor_user_id, action, target_type, target_id,
details, ip_address, user_agent
)
values ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb, $7, $8)
`,
[
input.tenantId,
input.actorUserId || null,
input.action,
input.targetType,
input.targetId,
JSON.stringify(input.details || {}),
'ipAddress' in input ? input.ipAddress || null : null,
'userAgent' in input ? input.userAgent || null : null,
],
);
}
export async function usageOverageCandidateQuery(
db: pg.Pool | pg.PoolClient,
params: {
tenantIds?: string[];
periodStart: string;
periodEnd: string;
includeExisting?: boolean;
includeZero?: boolean;
limit?: number;
},
) {
const tenantIds = params.tenantIds || [];
const includeExisting = Boolean(params.includeExisting);
const includeZero = Boolean(params.includeZero);
const limit = clampUsageOverageLimit(params.limit, 100);
const rows = await db.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
`,
[tenantIds, params.periodStart, params.periodEnd, includeExisting, limit],
);
const candidates: UsageOverageCandidate[] = rows.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 => includeZero || item.items.length > 0);
}
export async function processUsageOverageInvoices(
client: pg.PoolClient,
options: UsageOverageInvoiceBatchOptions,
) {
const tenantIds = options.tenantIds || [];
const dryRun = Boolean(options.dryRun);
const status = options.status || 'issued';
const limit = clampUsageOverageLimit(options.limit, Math.min(Math.max(tenantIds.length || 0, 100), 500));
const candidates = await usageOverageCandidateQuery(client, {
tenantIds,
periodStart: options.periodStart,
periodEnd: options.periodEnd,
includeExisting: true,
includeZero: false,
limit,
});
if (!candidates.length) {
return { 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 {
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 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, options.periodStart, options.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: options.dueDate || null,
billingPeriodStart: options.periodStart,
billingPeriodEnd: options.periodEnd,
note: options.note || null,
metadata: {
source: 'usage_overage_auto',
periodStart: options.periodStart,
periodEnd: options.periodEnd,
subscriptionId: candidate.subscriptionId,
planCode: candidate.planCode,
tenantSlug: candidate.tenantSlug,
workerId: options.workerId || null,
},
items: candidate.items.map(item => ({
...item,
metadata: {
...item.metadata,
periodStart: options.periodStart,
periodEnd: options.periodEnd,
subscriptionId: candidate.subscriptionId,
planCode: candidate.planCode,
},
})),
});
await insertUsageOverageAudit(client, {
tenantId: candidate.tenantId,
actorUserId: options.actorUserId || null,
ipAddress: options.ipAddress || null,
userAgent: options.userAgent || null,
action: 'platform.invoice.usage_overage_created',
targetType: 'tenant_invoice',
targetId: String(invoice.id),
details: {
tenantId: candidate.tenantId,
subscriptionId: candidate.subscriptionId,
planCode: candidate.planCode,
periodStart: options.periodStart,
periodEnd: options.periodEnd,
totalCents: candidate.totalCents,
metrics: candidate.items.map(item => item.metadata.metricKey),
invoiceNo: invoice.invoiceNo,
workerId: options.workerId || null,
},
});
created.push({ ...candidate, invoice });
}
const totalCents = created.reduce((sum, item) => sum + Number(item.totalCents || 0), 0);
await insertUsageOverageAudit(client, {
tenantId: null,
actorUserId: options.actorUserId || null,
ipAddress: options.ipAddress || null,
userAgent: options.userAgent || null,
action: 'platform.invoice.usage_overage_batch_created',
targetType: 'tenant_invoice_batch',
targetId: null,
details: {
createdCount: created.length,
skippedCount: skipped.length,
totalCents,
tenantIds,
periodStart: options.periodStart,
periodEnd: options.periodEnd,
dueDate: options.dueDate || null,
status,
workerId: options.workerId || null,
},
});
return {
dryRun: false,
createdCount: created.length,
skippedCount: skipped.length,
totalCents,
items: created,
skipped,
};
}
interface ProcessOverduePlatformInvoicesOptions {
actorUserId?: string | null;
channel?: string;
reminderType?: string;
dryRun?: boolean;
limit?: number;
today?: string | null;
workerId?: string | null;
}
function clampPositiveInteger(value: unknown, fallback: number, max: number) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.trunc(parsed), max);
}
function yyyyMmDd(value: Date) {
return value.toISOString().slice(0, 10);
}
function currentDateText(value?: string | null) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
return yyyyMmDd(new Date());
}
export async function processOverduePlatformInvoices(
client: pg.PoolClient,
options: ProcessOverduePlatformInvoicesOptions = {},
) {
const limit = clampPositiveInteger(options.limit, 100, 1000);
const today = currentDateText(options.today);
const channel = options.channel || 'internal';
const reminderType = options.reminderType || 'overdue';
const invoiceResult = await client.query<{
id: string;
tenantId: string;
tenantSlug: string;
tenantName: string;
invoiceNo: string;
status: string;
balanceCents: number;
dueDate: string | null;
existingReminderId: string | null;
reminderCount: number;
}>(
`
select i.id,
i.tenant_id as "tenantId",
t.slug::text as "tenantSlug",
t.name as "tenantName",
i.invoice_no as "invoiceNo",
i.status,
i.balance_cents as "balanceCents",
i.due_date as "dueDate",
existing.id as "existingReminderId",
coalesce(history.reminder_count, 0)::integer as "reminderCount"
from public.tenant_invoices i
join public.tenants t on t.id = i.tenant_id
left join lateral (
select id
from public.tenant_invoice_reminders r
where r.tenant_id = i.tenant_id
and r.invoice_id = i.id
and r.reminder_type = $2
and r.channel = $3
and r.reminder_date = $1::date
limit 1
) existing on true
left join lateral (
select count(*)::integer as reminder_count
from public.tenant_invoice_reminders r
where r.tenant_id = i.tenant_id
and r.invoice_id = i.id
and r.reminder_type = $2
) history on true
where i.status in ('issued', 'overdue')
and i.balance_cents > 0
and i.due_date is not null
and i.due_date < $1::date
and t.status = 'active'
order by i.due_date asc, i.created_at asc
limit $4
for update of i skip locked
`,
[today, reminderType, channel, limit],
);
if (options.dryRun) {
return {
dryRun: true,
processed: invoiceResult.rowCount,
markedOverdue: 0,
reminderCreated: 0,
skippedReminder: invoiceResult.rows.filter(row => row.existingReminderId).length,
items: invoiceResult.rows.map(row => ({
...row,
wouldMarkOverdue: row.status !== 'overdue',
wouldCreateReminder: !row.existingReminderId,
})),
};
}
const items: unknown[] = [];
let markedOverdue = 0;
let reminderCreated = 0;
let skippedReminder = 0;
for (const invoice of invoiceResult.rows) {
if (invoice.status !== 'overdue') {
await client.query(
`
update public.tenant_invoices
set status = 'overdue',
metadata = metadata || $3::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
invoice.tenantId,
invoice.id,
JSON.stringify({
overdueMarkedAt: new Date().toISOString(),
overdueMarkedBy: options.workerId || options.actorUserId || 'platform-admin',
}),
],
);
markedOverdue += 1;
}
await client.query(
`
update public.tenants
set billing_status = case when billing_status = 'active' then 'past_due' else billing_status end,
updated_at = now()
where id = $1
`,
[invoice.tenantId],
);
let reminder = null;
if (invoice.existingReminderId) {
skippedReminder += 1;
} else {
const reminderResult = await client.query(
`
insert into public.tenant_invoice_reminders (
tenant_id, invoice_id, reminder_type, channel, status,
reminder_date, reminder_level, due_date,
balance_cents_snapshot, message, metadata, created_by
)
values (
$1, $2, $3, $4, 'pending',
$5::date, $6, $7::date,
$8, $9, $10::jsonb, $11::uuid
)
on conflict (tenant_id, invoice_id, reminder_type, channel, reminder_date)
do nothing
returning id, tenant_id as "tenantId", invoice_id as "invoiceId",
reminder_type as "reminderType", channel, status,
reminder_date as "reminderDate", reminder_level as "reminderLevel",
due_date as "dueDate", balance_cents_snapshot as "balanceCentsSnapshot",
message, metadata, created_at as "createdAt"
`,
[
invoice.tenantId,
invoice.id,
reminderType,
channel,
today,
Number(invoice.reminderCount || 0) + 1,
invoice.dueDate,
Number(invoice.balanceCents || 0),
`租户 ${invoice.tenantName} 的平台服务费账单 ${invoice.invoiceNo} 已逾期,请跟进收款。`,
JSON.stringify({
source: options.workerId ? 'worker' : 'platform_admin',
workerId: options.workerId || null,
invoiceNo: invoice.invoiceNo,
tenantSlug: invoice.tenantSlug,
}),
options.actorUserId || null,
],
);
reminder = reminderResult.rows[0] || null;
if (reminder) reminderCreated += 1;
else skippedReminder += 1;
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2::uuid, 'platform.invoice.overdue_processed', 'tenant_invoice', $3, $4::jsonb)
`,
[
invoice.tenantId,
options.actorUserId || null,
invoice.id,
JSON.stringify({
invoiceNo: invoice.invoiceNo,
dueDate: invoice.dueDate,
balanceCents: invoice.balanceCents,
markedOverdue: invoice.status !== 'overdue',
reminderCreated: Boolean(reminder),
channel,
reminderType,
workerId: options.workerId || null,
}),
],
);
items.push({
...invoice,
status: 'overdue',
markedOverdue: invoice.status !== 'overdue',
reminderCreated: Boolean(reminder),
reminder,
});
}
return {
dryRun: false,
processed: invoiceResult.rowCount,
markedOverdue,
reminderCreated,
skippedReminder,
items,
};
}