361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
/**
|
|
* Phase 5 — Automated billing cron logic.
|
|
*
|
|
* This module hosts the two sweeps:
|
|
* - runMonthlyIssuance() — invoked monthly to generate invoices
|
|
* for orgs opted into auto-issuance. Idempotent via the
|
|
* uniq_invoices_org_period constraint on invoices: a re-run
|
|
* for an org that's already been billed for the target period
|
|
* gets caught as a duplicate and counted as a skip, not a
|
|
* failure.
|
|
* - runReminderSweep() — invoked daily. Walks open/overdue
|
|
* invoices, sends the appropriate reminder level (1/2/3) once
|
|
* per invoice via the invoice_reminders unique-key constraint.
|
|
*
|
|
* Both entry points return a summary {success, failure, skipped}
|
|
* that the caller persists via finishCronRun(). The shared
|
|
* structure means the HTTP routes (machine + admin variants) are
|
|
* trivial wrappers.
|
|
*
|
|
* Time-of-month math is timezone-aware: we read the calendar in
|
|
* Europe/Zurich rather than UTC, because the K8s CronJob schedules
|
|
* at 00:30 local time on the 1st — UTC at that moment is still in
|
|
* the previous month, and a naive `getUTCMonth() - 1` would bill
|
|
* the wrong period.
|
|
*/
|
|
|
|
import {
|
|
finishCronRun,
|
|
getLastSuccessfulCronRuns,
|
|
getOrgBilling,
|
|
getReminderLevelsSent,
|
|
listAutoIssueOrgIds,
|
|
listInvoicesPendingReminders,
|
|
recordReminderSent,
|
|
startCronRun,
|
|
syncOverdueInvoices,
|
|
} from "./db";
|
|
import { generateInvoice } from "./billing";
|
|
import { sendInvoiceReminderEmail } from "./email";
|
|
|
|
// The org_billing snapshot's company_name field doubles as the
|
|
// recipient name when no separate "billing contact" exists in
|
|
// our schema. Same convention as Phase 3's issuance email.
|
|
|
|
// All cron timing assumes Switzerland's calendar — the operator,
|
|
// the customers, and the legal basis (Swiss MWST) are all here.
|
|
const TZ = "Europe/Zurich";
|
|
|
|
export type CronSummary = {
|
|
successCount: number;
|
|
failureCount: number;
|
|
skippedCount: number;
|
|
errorDetails: Array<{
|
|
orgId?: string;
|
|
invoiceId?: string;
|
|
reason: string;
|
|
}>;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Monthly issuance
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The (year, month) of the calendar month that ended JUST BEFORE
|
|
* `now` in the configured timezone. This is what the issuance
|
|
* sweep bills.
|
|
*
|
|
* Reading the local-time calendar avoids a UTC-vs-local off-by-one
|
|
* when the sweep runs at 00:30 Zurich and UTC is still in the
|
|
* previous month.
|
|
*/
|
|
export function previousLocalMonth(
|
|
now: Date = new Date()
|
|
): { year: number; month: number } {
|
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: TZ,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
});
|
|
const parts = fmt.formatToParts(now);
|
|
const year = Number(parts.find((p) => p.type === "year")!.value);
|
|
const month = Number(parts.find((p) => p.type === "month")!.value);
|
|
if (month === 1) return { year: year - 1, month: 12 };
|
|
return { year, month: month - 1 };
|
|
}
|
|
|
|
export async function runMonthlyIssuance(opts: {
|
|
triggeredBy: string;
|
|
/** Override target year/month — defaults to previous local month. */
|
|
year?: number;
|
|
month?: number;
|
|
}): Promise<{ runId: string; summary: CronSummary }> {
|
|
const target =
|
|
opts.year && opts.month
|
|
? { year: opts.year, month: opts.month }
|
|
: previousLocalMonth();
|
|
const runId = await startCronRun("monthly_issue", opts.triggeredBy);
|
|
const summary: CronSummary = {
|
|
successCount: 0,
|
|
failureCount: 0,
|
|
skippedCount: 0,
|
|
errorDetails: [],
|
|
};
|
|
|
|
try {
|
|
const orgIds = await listAutoIssueOrgIds();
|
|
for (const orgId of orgIds) {
|
|
try {
|
|
const orgBilling = await getOrgBilling(orgId);
|
|
if (!orgBilling) {
|
|
// Auto-issue is enabled but billing details are missing.
|
|
// Skip rather than fail — the admin needs to complete the
|
|
// address before invoicing can succeed.
|
|
summary.skippedCount += 1;
|
|
summary.errorDetails.push({
|
|
orgId,
|
|
reason: "org_billing not configured",
|
|
});
|
|
continue;
|
|
}
|
|
// Derive invoice locale from the org's country. PieCed is
|
|
// Swiss-default; CH/LI/AT/DE customers get the German PDF,
|
|
// FR/BE/LU customers get French, IT customers get Italian,
|
|
// anything else falls through to English. Customers needing
|
|
// a different locale can still trigger a manual issuance
|
|
// with an explicit override from the admin UI.
|
|
const locale = pickLocaleForCountry(orgBilling.country);
|
|
const { invoice } = await generateInvoice({
|
|
zitadelOrgId: orgId,
|
|
year: target.year,
|
|
month: target.month,
|
|
locale,
|
|
});
|
|
if (invoice) {
|
|
summary.successCount += 1;
|
|
} else {
|
|
// dryRun path — shouldn't happen in production. Defensive.
|
|
summary.skippedCount += 1;
|
|
}
|
|
} catch (e: any) {
|
|
// The uniqueness constraint on (zitadel_org_id, period_start)
|
|
// surfaces as "An invoice already exists for this org and
|
|
// billing period" from createInvoice. Re-running the cron
|
|
// mid-month or after a partial completion is therefore safe:
|
|
// already-billed orgs end up as skipped, not failed.
|
|
const msg = String(e?.message ?? e);
|
|
const isAlreadyIssued = /already exists for this org and billing period/i.test(
|
|
msg
|
|
);
|
|
if (isAlreadyIssued) {
|
|
summary.skippedCount += 1;
|
|
} else {
|
|
summary.failureCount += 1;
|
|
summary.errorDetails.push({ orgId, reason: msg });
|
|
console.error(
|
|
`runMonthlyIssuance: org ${orgId} failed:`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
await finishCronRun(runId, summary);
|
|
return { runId, summary };
|
|
} catch (e) {
|
|
// Catastrophic — the sweep itself failed (DB down, etc).
|
|
summary.failureCount += 1;
|
|
summary.errorDetails.push({
|
|
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
|
|
});
|
|
await finishCronRun(runId, summary).catch(() => undefined);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reminder sweep
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Which reminder level (if any) is due now for this invoice?
|
|
*
|
|
* Logic:
|
|
* - days_past_due >= 30 AND level 3 not yet sent → 3 (final)
|
|
* - else days_past_due >= 14 AND level 2 not yet sent → 2
|
|
* - else days_past_due >= 7 AND level 1 not yet sent → 1
|
|
* - else → null (nothing to do this run)
|
|
*
|
|
* One reminder per cron run per invoice — highest applicable
|
|
* un-sent level wins. If a customer fell behind quickly and is
|
|
* already 35 days past due without ever having received levels
|
|
* 1 or 2 (e.g. the cron was broken for a while), they get level
|
|
* 3 directly. We don't backfill lower levels.
|
|
*/
|
|
function nextReminderLevel(
|
|
daysPastDue: number,
|
|
sent: Set<number>
|
|
): 1 | 2 | 3 | null {
|
|
if (daysPastDue >= 30 && !sent.has(3)) return 3;
|
|
if (daysPastDue >= 14 && !sent.has(2)) return 2;
|
|
if (daysPastDue >= 7 && !sent.has(1)) return 1;
|
|
return null;
|
|
}
|
|
|
|
function daysBetween(later: Date, earlier: Date): number {
|
|
const ms = later.getTime() - earlier.getTime();
|
|
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
/**
|
|
* Pick a default invoice locale based on the org's country
|
|
* (ISO 3166-1 alpha-2 code from org_billing.country). PieCed is
|
|
* primarily a Swiss-German operator; CH/LI/AT/DE get German,
|
|
* FR/BE/LU get French, IT gets Italian, anything else falls
|
|
* through to English.
|
|
*
|
|
* This only drives the automated issuance default. Manual
|
|
* issuance from the admin UI takes an explicit override.
|
|
*/
|
|
function pickLocaleForCountry(country: string): "de" | "en" | "fr" | "it" {
|
|
const c = country.toUpperCase();
|
|
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
|
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
|
if (c === "IT") return "it";
|
|
return "en";
|
|
}
|
|
|
|
export async function runReminderSweep(opts: {
|
|
triggeredBy: string;
|
|
}): Promise<{ runId: string; summary: CronSummary }> {
|
|
const runId = await startCronRun("reminders", opts.triggeredBy);
|
|
const summary: CronSummary = {
|
|
successCount: 0,
|
|
failureCount: 0,
|
|
skippedCount: 0,
|
|
errorDetails: [],
|
|
};
|
|
|
|
try {
|
|
// Flip stale 'open' → 'overdue' first so the listing reflects
|
|
// current status, and audit trails stay accurate.
|
|
await syncOverdueInvoices().catch((e) => {
|
|
console.warn("syncOverdueInvoices failed during reminder sweep:", e);
|
|
});
|
|
|
|
const candidates = await listInvoicesPendingReminders();
|
|
const now = new Date();
|
|
|
|
for (const inv of candidates) {
|
|
try {
|
|
const sent = await getReminderLevelsSent(inv.id);
|
|
const dueAt = new Date(inv.dueAt);
|
|
const days = daysBetween(now, dueAt);
|
|
const level = nextReminderLevel(days, sent);
|
|
if (level === null) {
|
|
summary.skippedCount += 1;
|
|
continue;
|
|
}
|
|
const billing = inv.billingSnapshot;
|
|
if (!billing.billingEmail) {
|
|
summary.skippedCount += 1;
|
|
summary.errorDetails.push({
|
|
invoiceId: inv.id,
|
|
reason: "no billing email on snapshot",
|
|
});
|
|
continue;
|
|
}
|
|
const supportedLocales: Array<"de" | "en" | "fr" | "it"> = [
|
|
"de", "en", "fr", "it",
|
|
];
|
|
const locale = supportedLocales.includes(inv.locale as any)
|
|
? (inv.locale as "de" | "en" | "fr" | "it")
|
|
: "de";
|
|
|
|
await sendInvoiceReminderEmail({
|
|
to: billing.billingEmail,
|
|
contactName: billing.companyName,
|
|
companyName: billing.companyName,
|
|
invoiceNumber: inv.invoiceNumber,
|
|
totalChf: inv.totalChf,
|
|
currency: "CHF",
|
|
dueAt: inv.dueAt,
|
|
daysPastDue: days,
|
|
level,
|
|
locale,
|
|
});
|
|
// Record AFTER the send. If the SMTP send fails the email
|
|
// helper logs and doesn't throw, so we'd still record — but
|
|
// that's a tradeoff we accept: at-least-once delivery semantics
|
|
// with logged warnings is better than at-most-once where a
|
|
// transient failure stops the customer from ever getting
|
|
// reminded. If duplicate-reminder fatigue becomes a real
|
|
// problem in production, switch to: send first, only record
|
|
// on confirmed transporter success.
|
|
await recordReminderSent({
|
|
invoiceId: inv.id,
|
|
level,
|
|
sentBy: opts.triggeredBy,
|
|
emailSentTo: billing.billingEmail,
|
|
});
|
|
summary.successCount += 1;
|
|
} catch (e: any) {
|
|
summary.failureCount += 1;
|
|
summary.errorDetails.push({
|
|
invoiceId: inv.id,
|
|
reason: String(e?.message ?? e),
|
|
});
|
|
console.error(
|
|
`runReminderSweep: invoice ${inv.id} failed:`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
await finishCronRun(runId, summary);
|
|
return { runId, summary };
|
|
} catch (e) {
|
|
summary.failureCount += 1;
|
|
summary.errorDetails.push({
|
|
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
|
|
});
|
|
await finishCronRun(runId, summary).catch(() => undefined);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth — bearer token for the machine endpoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Constant-time bearer token check. The CRON_BEARER_TOKEN env var
|
|
* is injected from OpenBao via the portal-cron K8s Secret. Both
|
|
* the CronJob and the portal Deployment reference it; the
|
|
* CronJob sends it in the Authorization header, the portal checks
|
|
* with timing-safe equals to defeat character-by-character probing.
|
|
*/
|
|
export function verifyCronBearer(authHeader: string | null): boolean {
|
|
if (!authHeader) return false;
|
|
const expected = process.env.CRON_BEARER_TOKEN;
|
|
if (!expected || expected.length < 16) {
|
|
// Treat misconfiguration as a hard refusal so a missing/
|
|
// accidentally-empty token doesn't silently grant access.
|
|
return false;
|
|
}
|
|
if (!authHeader.startsWith("Bearer ")) return false;
|
|
const got = authHeader.slice("Bearer ".length).trim();
|
|
if (got.length !== expected.length) return false;
|
|
// Constant-time byte compare. Node's Buffer.compare and the
|
|
// crypto.timingSafeEqual function both work, but the latter
|
|
// throws on length mismatch; the length pre-check above
|
|
// protects against that.
|
|
let diff = 0;
|
|
for (let i = 0; i < got.length; i++) {
|
|
diff |= got.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
}
|
|
return diff === 0;
|
|
}
|
|
|
|
// Re-export for the admin UI to render "last run X ago" indicators.
|
|
export { getLastSuccessfulCronRuns };
|