Phase5: Automate bill creation
All checks were successful
Build and Push / build (push) Successful in 1m43s
All checks were successful
Build and Push / build (push) Successful in 1m43s
This commit is contained in:
360
src/lib/cron.ts
Normal file
360
src/lib/cron.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 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 };
|
||||
214
src/lib/db.ts
214
src/lib/db.ts
@@ -598,6 +598,26 @@ const MIGRATION_SQL = `
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stripe_events_type_received
|
||||
ON stripe_events (event_type, received_at DESC);
|
||||
|
||||
-- Phase 5: Cron run history. One row per invocation of either the
|
||||
-- monthly issuance sweep or the daily reminder sweep, regardless of
|
||||
-- whether it ran from K8s CronJob or an admin's manual trigger.
|
||||
-- The summary counters let the admin UI render "last run: 12 issued,
|
||||
-- 0 failed" without joining against invoices/reminders. Detail rows
|
||||
-- live in the JSONB error_details on failure for diagnosis.
|
||||
CREATE TABLE IF NOT EXISTS cron_run_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
run_kind TEXT NOT NULL CHECK (run_kind IN ('monthly_issue','reminders')),
|
||||
triggered_by TEXT NOT NULL, -- 'cron' or '<admin-user-id>'
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
success_count INT NOT NULL DEFAULT 0,
|
||||
failure_count INT NOT NULL DEFAULT 0,
|
||||
skipped_count INT NOT NULL DEFAULT 0,
|
||||
error_details JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_run_history_kind_started
|
||||
ON cron_run_history (run_kind, started_at DESC);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -2961,3 +2981,197 @@ export async function setInvoiceStripePaymentIntent(
|
||||
[invoiceId, paymentIntentId]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5 — Cron run history + reminder helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { CronRun, CronRunKind } from "@/types";
|
||||
|
||||
function rowToCronRun(row: any): CronRun {
|
||||
return {
|
||||
id: row.id,
|
||||
runKind: row.run_kind,
|
||||
triggeredBy: row.triggered_by,
|
||||
startedAt:
|
||||
row.started_at?.toISOString?.() ?? String(row.started_at),
|
||||
finishedAt: row.finished_at
|
||||
? row.finished_at.toISOString?.() ?? String(row.finished_at)
|
||||
: null,
|
||||
successCount: Number(row.success_count ?? 0),
|
||||
failureCount: Number(row.failure_count ?? 0),
|
||||
skippedCount: Number(row.skipped_count ?? 0),
|
||||
errorDetails: row.error_details ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new cron-run row in 'started' state. Returns the row's
|
||||
* id which the caller passes to finishCronRun() with the summary
|
||||
* stats once the sweep completes.
|
||||
*
|
||||
* Separating start/finish lets the admin UI distinguish an in-
|
||||
* progress run from a finished one, and lets a crashed pod leave
|
||||
* a forensic trace ("started but never finished — investigate").
|
||||
*/
|
||||
export async function startCronRun(
|
||||
runKind: CronRunKind,
|
||||
triggeredBy: string
|
||||
): Promise<string> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO cron_run_history (run_kind, triggered_by)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id`,
|
||||
[runKind, triggeredBy]
|
||||
);
|
||||
return result.rows[0].id;
|
||||
}
|
||||
|
||||
export async function finishCronRun(
|
||||
id: string,
|
||||
summary: {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
skippedCount: number;
|
||||
errorDetails?: unknown;
|
||||
}
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
`UPDATE cron_run_history
|
||||
SET finished_at = now(),
|
||||
success_count = $2,
|
||||
failure_count = $3,
|
||||
skipped_count = $4,
|
||||
error_details = $5::jsonb
|
||||
WHERE id = $1`,
|
||||
[
|
||||
id,
|
||||
summary.successCount,
|
||||
summary.failureCount,
|
||||
summary.skippedCount,
|
||||
summary.errorDetails ? JSON.stringify(summary.errorDetails) : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export async function listRecentCronRuns(
|
||||
limit = 30
|
||||
): Promise<CronRun[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM cron_run_history
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows.map(rowToCronRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent successful run of each kind. Drives the admin
|
||||
* dashboard's "last issuance: N days ago" indicator. Returns
|
||||
* null for a kind that has never run successfully.
|
||||
*/
|
||||
export async function getLastSuccessfulCronRuns(): Promise<{
|
||||
monthlyIssue: CronRun | null;
|
||||
reminders: CronRun | null;
|
||||
}> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT DISTINCT ON (run_kind) *
|
||||
FROM cron_run_history
|
||||
WHERE finished_at IS NOT NULL AND failure_count = 0
|
||||
ORDER BY run_kind, started_at DESC`
|
||||
);
|
||||
const map: Record<string, CronRun> = {};
|
||||
for (const row of result.rows) {
|
||||
map[row.run_kind] = rowToCronRun(row);
|
||||
}
|
||||
return {
|
||||
monthlyIssue: map["monthly_issue"] ?? null,
|
||||
reminders: map["reminders"] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of all orgs with auto-issue enabled. Drives the monthly
|
||||
* issuance sweep. Returns just the zitadel_org_id strings — the
|
||||
* caller fetches OrgBilling per-org during the sweep so a bad
|
||||
* row doesn't poison the whole list at SELECT time.
|
||||
*/
|
||||
export async function listAutoIssueOrgIds(): Promise<string[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT zitadel_org_id FROM org_billing_config
|
||||
WHERE auto_invoice_enabled = TRUE`
|
||||
);
|
||||
return result.rows.map((r) => r.zitadel_org_id as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or overdue invoices whose org has auto-reminders enabled
|
||||
* and whose due_at is at least 7 days in the past. The reminder
|
||||
* sweep takes this list and picks the right level (1/2/3) per
|
||||
* invoice based on days-past-due AND which levels have already
|
||||
* been sent.
|
||||
*
|
||||
* We don't filter by "needs reminder X yet" in SQL because the
|
||||
* level logic is more readable in TypeScript and the candidate
|
||||
* set is small (only past-due invoices for opted-in orgs).
|
||||
*/
|
||||
export async function listInvoicesPendingReminders(): Promise<Invoice[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT ${INVOICE_LIST_COLUMNS}
|
||||
FROM invoices i
|
||||
JOIN org_billing_config c
|
||||
ON c.zitadel_org_id = i.zitadel_org_id
|
||||
AND c.auto_reminders_enabled = TRUE
|
||||
WHERE i.status IN ('open','overdue')
|
||||
AND i.due_at < now() - INTERVAL '7 days'
|
||||
ORDER BY i.due_at ASC`
|
||||
);
|
||||
return result.rows.map(rowToInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which reminder levels have already been sent for this invoice?
|
||||
* Returns a Set of {1, 2, 3} subset. Drives the "send the next
|
||||
* level only" logic in the reminder sweep.
|
||||
*/
|
||||
export async function getReminderLevelsSent(
|
||||
invoiceId: string
|
||||
): Promise<Set<number>> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT level FROM invoice_reminders WHERE invoice_id = $1`,
|
||||
[invoiceId]
|
||||
);
|
||||
return new Set(result.rows.map((r) => Number(r.level)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a reminder as sent. Wrapped in an INSERT ... ON CONFLICT
|
||||
* DO NOTHING so a retry of the same level after a partial failure
|
||||
* is a no-op rather than a 23505 explosion. Returns true if a row
|
||||
* was inserted (first send), false on conflict (already sent).
|
||||
*/
|
||||
export async function recordReminderSent(params: {
|
||||
invoiceId: string;
|
||||
level: 1 | 2 | 3;
|
||||
sentBy: string;
|
||||
emailSentTo: string;
|
||||
}): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO invoice_reminders
|
||||
(invoice_id, level, sent_by, email_sent_to)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (invoice_id, level) DO NOTHING
|
||||
RETURNING id`,
|
||||
[params.invoiceId, params.level, params.sentBy, params.emailSentTo]
|
||||
);
|
||||
return result.rowCount === 1;
|
||||
}
|
||||
|
||||
144
src/lib/email.ts
144
src/lib/email.ts
@@ -1014,3 +1014,147 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
console.error("Failed to send invoice issued email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reminder emails — Phase 5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a payment reminder for an open/overdue invoice.
|
||||
*
|
||||
* Three escalation levels:
|
||||
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
|
||||
* you missed it".
|
||||
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
|
||||
* outstanding, please pay.
|
||||
* 3 — Final notice: ~30 days past due. Explicit consequences
|
||||
* (service may be suspended). Last automated touch — beyond
|
||||
* this, admin involvement is expected.
|
||||
*
|
||||
* Failure is logged, never thrown — the cron sweep must continue
|
||||
* past a single failed send.
|
||||
*/
|
||||
export async function sendInvoiceReminderEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
companyName: string;
|
||||
invoiceNumber: string;
|
||||
totalChf: number;
|
||||
currency: string;
|
||||
dueAt: string;
|
||||
daysPastDue: number;
|
||||
level: 1 | 2 | 3;
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
const L = params.locale;
|
||||
// Per-locale strings keyed by the three escalation levels.
|
||||
// Kept inline (rather than the next-intl message files) because
|
||||
// the email layer doesn't import from React's i18n context.
|
||||
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||||
en: {
|
||||
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
|
||||
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
|
||||
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
|
||||
},
|
||||
de: {
|
||||
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
|
||||
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
|
||||
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
|
||||
},
|
||||
fr: {
|
||||
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
|
||||
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
|
||||
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
|
||||
},
|
||||
it: {
|
||||
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
|
||||
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
|
||||
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
|
||||
},
|
||||
};
|
||||
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||||
en: {
|
||||
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
|
||||
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
|
||||
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
|
||||
},
|
||||
de: {
|
||||
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
|
||||
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
|
||||
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
|
||||
},
|
||||
fr: {
|
||||
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
|
||||
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
|
||||
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
|
||||
},
|
||||
it: {
|
||||
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
|
||||
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
|
||||
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
|
||||
},
|
||||
};
|
||||
const LABELS: Record<typeof L, Record<string, string>> = {
|
||||
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
|
||||
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
|
||||
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
|
||||
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
|
||||
};
|
||||
const l = LABELS[L];
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeCompany = escapeHtml(params.companyName);
|
||||
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||
const dueFmt = params.dueAt.slice(0, 10);
|
||||
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||
// Final-notice gets red accent; earlier levels keep the brand green.
|
||||
const accent = params.level === 3 ? "#dc2626" : "#10B981";
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: SUBJECTS[L][params.level],
|
||||
text: [
|
||||
`${l.greeting} ${params.contactName},`,
|
||||
"",
|
||||
INTROS[L][params.level],
|
||||
"",
|
||||
`${l.num}: ${params.invoiceNumber}`,
|
||||
`${l.total}: ${totalFmt}`,
|
||||
`${l.due}: ${dueFmt}`,
|
||||
`${l.days}: ${params.daysPastDue}`,
|
||||
"",
|
||||
`${l.cta}: ${link}`,
|
||||
"",
|
||||
`${l.signoff},`,
|
||||
l.brand,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:560px;padding:24px;background:#1a1a1a;color:#e5e5e5;">
|
||||
<h2 style="margin:0 0 16px;color:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
|
||||
<p>${l.greeting} ${safeName},</p>
|
||||
<p>${escapeHtml(INTROS[L][params.level])}</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
|
||||
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||
<tr><td style="color:#888;padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||
<tr><td style="color:#888;padding:6px 0;">${l.days}</td><td>${params.daysPastDue}</td></tr>
|
||||
</table>
|
||||
<p>
|
||||
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};color:#fff;text-decoration:none;border-radius:8px;font-weight:500;">
|
||||
${l.cta}
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #333;margin:24px 0;" />
|
||||
<p style="color:#666;font-size:12px;">${l.brand}</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to send reminder L${params.level} for invoice ${params.invoiceNumber}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user