forked from wangziqi/gongxue-base
feat: add platform invoice dunning workflow
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
34
apps/worker/src/jobs/platform-dunning.ts
Normal file
34
apps/worker/src/jobs/platform-dunning.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user