/** * 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 ): 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 };