feat: automate usage overage billing

This commit is contained in:
Codex
2026-06-30 21:24:53 +08:00
parent dbefc0568a
commit ac5d136b35
17 changed files with 1080 additions and 618 deletions

View File

@@ -13,6 +13,7 @@
"provider-bills:once": "tsx src/index.ts --once --job provider-bills",
"platform-billing:once": "tsx src/index.ts --once --job platform-billing",
"platform-usage:once": "tsx src/index.ts --once --job platform-usage",
"platform-usage-overage:once": "tsx src/index.ts --once --job platform-usage-overage",
"platform-dunning:once": "tsx src/index.ts --once --job platform-dunning",
"platform-dunning-notifications:once": "tsx src/index.ts --once --job platform-dunning-notifications",
"platform-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts",

View File

@@ -25,6 +25,10 @@ export interface WorkerConfig {
platformUsageBatchSize: number;
platformUsageWorkerId: string;
platformUsageMonth: string;
platformUsageOverageBatchSize: number;
platformUsageOverageWorkerId: string;
platformUsageOverageMonth: string;
platformUsageOverageDueDays: number;
platformDunningBatchSize: number;
platformDunningWorkerId: string;
platformDunningNotificationBatchSize: number;
@@ -204,6 +208,10 @@ const loadedConfig: WorkerConfig = {
platformUsageBatchSize: envNumber('WORKER_PLATFORM_USAGE_BATCH_SIZE', 100),
platformUsageWorkerId: envString('WORKER_PLATFORM_USAGE_ID', `platform-usage-${process.pid}`),
platformUsageMonth: envString('WORKER_PLATFORM_USAGE_MONTH', ''),
platformUsageOverageBatchSize: envNumber('WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE', 100),
platformUsageOverageWorkerId: envString('WORKER_PLATFORM_USAGE_OVERAGE_ID', `platform-usage-overage-${process.pid}`),
platformUsageOverageMonth: envString('WORKER_PLATFORM_USAGE_OVERAGE_MONTH', ''),
platformUsageOverageDueDays: envNumber('WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS', 15),
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50),

View File

@@ -61,6 +61,15 @@ async function runOnce() {
);
return;
}
if (job === 'platform-usage-overage') {
const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js');
const result = await processPlatformUsageOverageBatch();
console.log(
`[worker] platform-usage-overage batch processed=${result.processed}`
+ ` created=${result.created} skipped=${result.skipped} totalCents=${result.totalCents}`,
);
return;
}
if (job === 'platform-dunning') {
const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js');
const result = await processPlatformDunningBatch();

View File

@@ -0,0 +1,97 @@
import { pool } from '../db.js';
import { config } from '../config.js';
import { processUsageOverageInvoices } from '../../../api/src/features/platform-admin/service.js';
export interface PlatformUsageOverageWorkerResult {
processed: number;
created: number;
skipped: number;
totalCents: number;
}
const MONTH_RE = /^\d{4}-\d{2}$/;
function positiveInteger(value: number, fallback: number, max: number) {
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), max);
}
function nonNegativeInteger(value: number, fallback: number, max: number) {
if (!Number.isFinite(value) || value < 0) return fallback;
return Math.min(Math.trunc(value), max);
}
function shanghaiYearMonth(value = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
}).format(value);
}
function previousMonth(monthText: string) {
const [yearText, monthNumberText] = monthText.split('-');
const date = new Date(Date.UTC(Number(yearText), Number(monthNumberText) - 2, 1));
return date.toISOString().slice(0, 7);
}
function targetMonth(value?: string) {
const normalized = String(value || '').trim();
if (normalized) return normalized;
return previousMonth(shanghaiYearMonth());
}
function monthPeriod(monthText: string) {
const normalized = monthText.trim();
if (!MONTH_RE.test(normalized)) {
throw new Error(`Invalid platform usage overage month: ${monthText}. Expected YYYY-MM.`);
}
const [yearText, monthNumberText] = normalized.split('-');
const year = Number(yearText);
const monthNumber = Number(monthNumberText);
const periodStart = `${yearText}-${monthNumberText}-01`;
const periodEnd = new Date(Date.UTC(year, monthNumber, 0)).toISOString().slice(0, 10);
return { periodStart, periodEnd };
}
function dueDateText(days: number) {
const now = new Date();
now.setUTCDate(now.getUTCDate() + nonNegativeInteger(days, 15, 365));
return now.toISOString().slice(0, 10);
}
export async function processPlatformUsageOverageBatch(options: {
limit?: number;
month?: string;
dueDays?: number;
} = {}): Promise<PlatformUsageOverageWorkerResult> {
const limit = positiveInteger(options.limit ?? config.platformUsageOverageBatchSize, 100, 500);
const month = targetMonth(options.month || config.platformUsageOverageMonth);
const { periodStart, periodEnd } = monthPeriod(month);
const client = await pool.connect();
try {
await client.query('begin');
const result = await processUsageOverageInvoices(client, {
periodStart,
periodEnd,
dueDate: dueDateText(options.dueDays ?? config.platformUsageOverageDueDays),
status: 'issued',
note: `平台自动生成 ${periodStart}${periodEnd} 用量超额服务费账单`,
dryRun: false,
limit,
workerId: config.platformUsageOverageWorkerId,
});
await client.query('commit');
return {
processed: Number(result.createdCount || 0) + Number(result.skippedCount || 0),
created: Number(result.createdCount || 0),
skipped: Number(result.skippedCount || 0),
totalCents: Number(result.totalCents || 0),
};
} catch (error) {
await client.query('rollback').catch(() => {});
throw error;
} finally {
client.release();
}
}