forked from wangziqi/gongxue-base
feat: automate usage overage billing
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
97
apps/worker/src/jobs/platform-usage-overage.ts
Normal file
97
apps/worker/src/jobs/platform-usage-overage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user