feat: add platform invoice dunning workflow

This commit is contained in:
Codex
2026-06-30 05:27:58 +08:00
parent f9f96bee63
commit c74432ce98
25 changed files with 858 additions and 23 deletions

View File

@@ -6,10 +6,12 @@ import {
createTenantInvoiceFromSubscriptionRoute,
createTenantInvoicesBatchFromSubscriptionsRoute,
createTenantRoute,
invoiceRemindersRoute,
platformAuditLogsRoute,
platformOverviewRoute,
platformPlansRoute,
platformQuestionBanksRoute,
processOverdueInvoicesRoute,
questionBankGrantsRoute,
recordUsageRoute,
subscriptionInvoiceCandidatesRoute,
@@ -40,6 +42,8 @@ export const platformAdminRoutes: RouteDefinition[] = [
['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/process-overdue', processOverdueInvoicesRoute],
['GET', '/api/platform-admin/invoices/reminders', invoiceRemindersRoute],
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
['GET', '/api/platform-admin/usage', tenantUsageRoute],
['POST', '/api/platform-admin/usage', recordUsageRoute],

View File

@@ -17,6 +17,7 @@ import {
normalizeHost,
normalizeInvoiceItems,
normalizeSlug,
processOverduePlatformInvoices,
quantityFrom,
recalculateInvoiceTotals,
} from './service.js';
@@ -1167,6 +1168,78 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
return { item };
}
export async function processOverdueInvoicesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const dryRun = booleanFrom(body.dryRun, false);
const limit = Math.min(Math.max(Number(body.limit || 100), 1), 500);
const channel = optionalString(body, 'channel') || 'internal';
if (!['manual', 'internal', 'sms', 'email', 'wechat', 'crm'].includes(channel)) {
throw new HttpError(400, 'channel is invalid', 'INVALID_REMINDER_CHANNEL');
}
const session = currentSessionFromContext(ctx);
const item = await transaction(async client => {
const result = await processOverduePlatformInvoices(client, {
actorUserId: session?.id || null,
channel,
dryRun,
limit,
});
if (!dryRun) {
await recordPlatformAudit(client, ctx, 'platform.invoice.overdue_batch_processed', 'tenant_invoice_batch', null, {
processed: result.processed,
markedOverdue: result.markedOverdue,
reminderCreated: result.reminderCreated,
skippedReminder: result.skippedReminder,
channel,
});
}
return result;
});
return { item };
}
export async function invoiceRemindersRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const invoiceId = ctx.url.searchParams.get('invoiceId') || '';
const status = listQuery(ctx, 'status');
const reminderType = listQuery(ctx, 'reminderType');
const limit = intParam(ctx, 'limit', 100, 500);
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
if (invoiceId && !UUID_RE.test(invoiceId)) throw new HttpError(400, 'invoiceId is invalid', 'INVALID_UUID');
const items = await query(
`
select r.id, r.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
r.invoice_id as "invoiceId", i.invoice_no as "invoiceNo",
r.reminder_type as "reminderType", r.channel, r.status,
r.reminder_date as "reminderDate", r.reminder_level as "reminderLevel",
r.due_date as "dueDate", r.balance_cents_snapshot as "balanceCentsSnapshot",
r.message, r.metadata, r.sent_at as "sentAt", r.acknowledged_at as "acknowledgedAt",
r.created_at as "createdAt", r.updated_at as "updatedAt"
from public.tenant_invoice_reminders r
join public.tenants t on t.id = r.tenant_id
join public.tenant_invoices i on i.id = r.invoice_id
where ($1::uuid is null or r.tenant_id = $1::uuid)
and ($2::uuid is null or r.invoice_id = $2::uuid)
and ($3::text = '' or r.status = $3)
and ($4::text = '' or r.reminder_type = $4)
order by r.reminder_date desc, r.created_at desc
limit $5
`,
[tenantId || null, invoiceId || null, status, reminderType, limit],
);
return { items };
}
export async function recordUsageRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);

View File

@@ -116,3 +116,232 @@ export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId:
return updateResult.rows[0];
}
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,
};
}