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

View File

@@ -7,11 +7,14 @@ import {
createPlatformInvoiceFromSubscription,
createPlatformInvoicesBatchFromSubscriptions,
createPlatformSubscription,
loadPlatformInvoiceReminders,
loadPlatformInvoices,
loadPlatformPlans,
loadPlatformSubscriptionInvoiceCandidates,
loadPlatformUsage,
processPlatformOverdueInvoices,
recordPlatformUsage,
type PlatformInvoiceReminderItem,
type PlatformInvoiceItem,
type PlatformSaasPlan,
type PlatformSubscriptionInvoiceCandidate,
@@ -38,6 +41,7 @@ export default function PlatformBillingPage() {
const [status, setStatus] = useState('');
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [reminders, setReminders] = useState<PlatformInvoiceReminderItem[]>([]);
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
const [candidates, setCandidates] = useState<PlatformSubscriptionInvoiceCandidate[]>([]);
const [batchResult, setBatchResult] = useState('');
@@ -77,13 +81,15 @@ export default function PlatformBillingPage() {
Promise.all([
loadPlatformPlans(true).catch(() => ({ items: [] })),
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
loadPlatformInvoiceReminders({ limit: 80 }).catch(() => ({ items: [] })),
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })),
]).then(([planPayload, invoicePayload, usagePayload, candidatePayload]) => {
]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload]) => {
const nextPlans = planPayload.items || [];
setPlans(nextPlans);
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
setInvoices(invoicePayload.items || []);
setReminders(reminderPayload.items || []);
setUsage(usagePayload.items || []);
setCandidates(candidatePayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
@@ -283,6 +289,32 @@ export default function PlatformBillingPage() {
}
}
async function submitOverdueProcess(dryRun: boolean) {
setError('');
const ok = dryRun
? true
: await confirm('处理逾期账单', '确认扫描已过期未结清的平台服务费账单,并生成内部催缴记录?该动作不会自动停用租户。');
if (!ok) return;
setBusy(dryRun ? 'overdue-dry-run' : 'overdue-process');
try {
const payload = await processPlatformOverdueInvoices({
dryRun,
channel: 'internal',
limit: 100,
});
const item = payload.item || {};
setBatchResult(`${dryRun ? '逾期预览' : '逾期处理'}完成:处理 ${item.processed || 0},标记 ${item.markedOverdue || 0},催缴 ${item.reminderCreated || 0},跳过 ${item.skippedReminder || 0}`);
if (!dryRun) {
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'>
@@ -381,6 +413,25 @@ 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-actions'>
<Button className='platform-button' loading={busy === 'overdue-dry-run'} onClick={() => submitOverdueProcess(true)}></Button>
<Button className='platform-button primary' loading={busy === 'overdue-process'} onClick={() => submitOverdueProcess(false)}></Button>
</View>
<View className='platform-list'>
{reminders.slice(0, 12).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.invoiceNo || item.invoiceId}</Text>
<Text className='platform-row-meta'>{item.tenantName || item.tenantSlug || item.tenantId} · {item.reminderType || 'reminder'} · {item.channel || 'internal'} · {item.status || '-'}</Text>
<Text className='platform-row-meta'> {String(item.reminderLevel || 1)} · {item.dueDate ? String(item.dueDate).slice(0, 10) : '-'} · {money(item.balanceCentsSnapshot)}</Text>
<Text className='platform-row-meta'>{item.message || '暂无催缴备注'}</Text>
</View>
))}
</View>
{!reminders.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

@@ -126,6 +126,24 @@ export interface PlatformInvoiceItem {
note?: string | null;
}
export interface PlatformInvoiceReminderItem {
id: string;
tenantId: string;
tenantSlug?: string | null;
tenantName?: string | null;
invoiceId: string;
invoiceNo?: string | null;
reminderType?: string | null;
channel?: string | null;
status?: string | null;
reminderDate?: string | null;
reminderLevel?: number | string | null;
dueDate?: string | null;
balanceCentsSnapshot?: number | string | null;
message?: string | null;
createdAt?: string | null;
}
export interface PlatformUsageItem {
id: string;
tenantId: string;
@@ -265,6 +283,12 @@ export interface ConfirmPlatformInvoicePaymentInput {
providerTradeNo?: string;
}
export interface ProcessPlatformOverdueInvoicesInput {
dryRun?: boolean;
channel?: string;
limit?: number;
}
export interface RecordPlatformUsageInput {
tenantId: string;
metricKey: string;
@@ -417,6 +441,34 @@ export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoic
});
}
export async function processPlatformOverdueInvoices(input: ProcessPlatformOverdueInvoicesInput = {}) {
return apiRequest<{
item?: {
dryRun?: boolean;
processed?: number;
markedOverdue?: number;
reminderCreated?: number;
skippedReminder?: number;
items?: Array<PlatformInvoiceItem & {
wouldMarkOverdue?: boolean;
wouldCreateReminder?: boolean;
reminderCreated?: boolean;
}>;
};
}>('/api/platform-admin/invoices/process-overdue', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function loadPlatformInvoiceReminders(query: { tenantId?: string; invoiceId?: string; status?: string; reminderType?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceReminderItem[] }>('/api/platform-admin/invoices/reminders', {
query: { ...query, limit: query.limit || 100 },
tenantId: null,
});
}
export async function recordPlatformUsage(input: RecordPlatformUsageInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/usage', {
method: 'POST',

View File

@@ -12,6 +12,7 @@
"commerce:once": "tsx src/index.ts --once --job commerce",
"provider-bills:once": "tsx src/index.ts --once --job provider-bills",
"platform-billing:once": "tsx src/index.ts --once --job platform-billing",
"platform-dunning:once": "tsx src/index.ts --once --job platform-dunning",
"assets:once": "tsx src/index.ts --once --job assets",
"imports:once": "tsx src/index.ts --once --job imports",
"public-banks:once": "tsx src/index.ts --once --job public-banks",

View File

@@ -22,6 +22,8 @@ export interface WorkerConfig {
platformBillingDaysAhead: number;
platformBillingDueDays: number;
platformBillingWorkerId: string;
platformDunningBatchSize: number;
platformDunningWorkerId: string;
assetBatchSize: number;
assetMinAgeSeconds: number;
assetRecheckIntervalSeconds: number;
@@ -174,6 +176,8 @@ const loadedConfig: WorkerConfig = {
platformBillingDaysAhead: envNumber('WORKER_PLATFORM_BILLING_DAYS_AHEAD', 45),
platformBillingDueDays: envNumber('WORKER_PLATFORM_BILLING_DUE_DAYS', 15),
platformBillingWorkerId: envString('WORKER_PLATFORM_BILLING_ID', `platform-billing-${process.pid}`),
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
assetBatchSize: envNumber('WORKER_ASSET_BATCH_SIZE', 50),
assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300),
assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24),

View File

@@ -51,6 +51,16 @@ async function runOnce() {
);
return;
}
if (job === 'platform-dunning') {
const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js');
const result = await processPlatformDunningBatch();
console.log(
`[worker] platform-dunning batch processed=${result.processed}`
+ ` markedOverdue=${result.markedOverdue} reminderCreated=${result.reminderCreated}`
+ ` skippedReminder=${result.skippedReminder}`,
);
return;
}
if (job === 'assets') {
const result = await processAssetBatch();
console.log(

View File

@@ -0,0 +1,34 @@
import { pool } from '../db.js';
import { config } from '../config.js';
import { processOverduePlatformInvoices } from '../../../api/src/features/platform-admin/service.js';
interface PlatformDunningWorkerResult {
processed: number;
markedOverdue: number;
reminderCreated: number;
skippedReminder: number;
}
export async function processPlatformDunningBatch(limit = config.platformDunningBatchSize): Promise<PlatformDunningWorkerResult> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await processOverduePlatformInvoices(client, {
channel: 'internal',
limit,
workerId: config.platformDunningWorkerId,
});
await client.query('commit');
return {
processed: Number(result.processed || 0),
markedOverdue: Number(result.markedOverdue || 0),
reminderCreated: Number(result.reminderCreated || 0),
skippedReminder: Number(result.skippedReminder || 0),
};
} catch (error) {
await client.query('rollback').catch(() => {});
throw error;
} finally {
client.release();
}
}