diff --git a/src/app/[locale]/admin/cron/page.tsx b/src/app/[locale]/admin/cron/page.tsx new file mode 100644 index 0000000..7e71fcb --- /dev/null +++ b/src/app/[locale]/admin/cron/page.tsx @@ -0,0 +1,44 @@ +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { getSessionUser } from "@/lib/session"; +import { + getLastSuccessfulCronRuns, + listRecentCronRuns, +} from "@/lib/db"; +import { CronControls } from "@/components/admin/cron/cron-controls"; + +/** + * /admin/cron — automation dashboard. + * + * Shows: + * - Last successful run of each kind, with relative time + * - Two "Run now" buttons (admin-triggered manual sweeps) + * - Recent runs table (last 30) + * + * Platform-admin gated server-side. + */ +export default async function AdminCronPage() { + const user = await getSessionUser(); + if (!user || !user.isPlatform) redirect("/login"); + const t = await getTranslations("adminCron"); + + const [recent, lastSuccess] = await Promise.all([ + listRecentCronRuns(30), + getLastSuccessfulCronRuns(), + ]); + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ +
+ ); +} diff --git a/src/app/[locale]/admin/page.tsx b/src/app/[locale]/admin/page.tsx index 63322ca..fc05b28 100644 --- a/src/app/[locale]/admin/page.tsx +++ b/src/app/[locale]/admin/page.tsx @@ -61,6 +61,12 @@ export default async function AdminPage() { > {t("billingTool")} + + {t("cronTool")} + ({})); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 } + ); + } + if ( + (parsed.data.year && !parsed.data.month) || + (parsed.data.month && !parsed.data.year) + ) { + return NextResponse.json( + { error: "year and month must both be provided, or neither" }, + { status: 400 } + ); + } + try { + const { runId, summary } = await runMonthlyIssuance({ + triggeredBy: user.id, + year: parsed.data.year, + month: parsed.data.month, + }); + return NextResponse.json({ runId, ...summary }); + } catch (e) { + return NextResponse.json( + { error: safeError(e, "Issuance sweep failed.") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/cron/runs/route.ts b/src/app/api/admin/cron/runs/route.ts new file mode 100644 index 0000000..385801b --- /dev/null +++ b/src/app/api/admin/cron/runs/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { + getLastSuccessfulCronRuns, + listRecentCronRuns, +} from "@/lib/db"; + +/** + * GET /api/admin/cron/runs + * + * Returns recent cron run history plus per-kind "last successful" + * summary for the admin /admin/cron dashboard. + * + * Response: { recent: CronRun[]; lastSuccess: { monthlyIssue, reminders } } + */ +export async function GET() { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const [recent, lastSuccess] = await Promise.all([ + listRecentCronRuns(30), + getLastSuccessfulCronRuns(), + ]); + return NextResponse.json({ recent, lastSuccess }); +} diff --git a/src/app/api/admin/cron/send-reminders/route.ts b/src/app/api/admin/cron/send-reminders/route.ts new file mode 100644 index 0000000..c57c5c0 --- /dev/null +++ b/src/app/api/admin/cron/send-reminders/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getSessionUser, requirePlatformRole } from "@/lib/session"; +import { runReminderSweep } from "@/lib/cron"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/admin/cron/send-reminders + * + * Admin-side manual trigger for the reminder sweep. Same logic + * as the machine path; session-based platform-role auth. + */ +export async function POST() { + let user; + try { + await requirePlatformRole(); + user = await getSessionUser(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const { runId, summary } = await runReminderSweep({ + triggeredBy: user.id, + }); + return NextResponse.json({ runId, ...summary }); + } catch (e) { + return NextResponse.json( + { error: safeError(e, "Reminder sweep failed.") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cron/issue-monthly/route.ts b/src/app/api/cron/issue-monthly/route.ts new file mode 100644 index 0000000..a2632fb --- /dev/null +++ b/src/app/api/cron/issue-monthly/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { runMonthlyIssuance, verifyCronBearer } from "@/lib/cron"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/cron/issue-monthly + * + * Machine entry point for the monthly issuance sweep. Authentication + * is the shared bearer token in CRON_BEARER_TOKEN, injected from + * OpenBao via the portal-cron K8s Secret. The K8s CronJob sends: + * + * curl -X POST -H "Authorization: Bearer $CRON_BEARER_TOKEN" \ + * https://app.pieced.ch/api/cron/issue-monthly + * + * The sweep targets the calendar month that ended just before + * "now" in Europe/Zurich. Running it on June 1st at 00:30 Swiss + * time bills May; running it on July 5th bills June; etc. The + * uniqueness constraint on (org, period_start) makes re-runs + * harmless — already-issued orgs are counted as skipped. + * + * Returns the summary {success, failure, skipped} JSON. The + * CronJob doesn't look at the response body (just the status + * code) but having a useful one helps debugging via curl. + */ +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + if (!verifyCronBearer(request.headers.get("authorization"))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const { runId, summary } = await runMonthlyIssuance({ + triggeredBy: "cron", + }); + return NextResponse.json({ runId, ...summary }); + } catch (e) { + return NextResponse.json( + { error: safeError(e, "Issuance sweep failed.") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cron/send-reminders/route.ts b/src/app/api/cron/send-reminders/route.ts new file mode 100644 index 0000000..4a45d02 --- /dev/null +++ b/src/app/api/cron/send-reminders/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { runReminderSweep, verifyCronBearer } from "@/lib/cron"; +import { safeError } from "@/lib/errors"; + +/** + * POST /api/cron/send-reminders + * + * Machine entry point for the daily reminder sweep. Same auth + * (bearer token in CRON_BEARER_TOKEN) and the same response + * contract as /api/cron/issue-monthly. + * + * Schedule: 09:00 Europe/Zurich daily. Picks invoices that are + * past their due date and haven't received the corresponding + * reminder level yet; sends one email per invoice per run. + */ +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + if (!verifyCronBearer(request.headers.get("authorization"))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const { runId, summary } = await runReminderSweep({ + triggeredBy: "cron", + }); + return NextResponse.json({ runId, ...summary }); + } catch (e) { + return NextResponse.json( + { error: safeError(e, "Reminder sweep failed.") }, + { status: 500 } + ); + } +} diff --git a/src/components/admin/cron/cron-controls.tsx b/src/components/admin/cron/cron-controls.tsx new file mode 100644 index 0000000..b974874 --- /dev/null +++ b/src/components/admin/cron/cron-controls.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations, useFormatter } from "next-intl"; +import { Card } from "@/components/ui/card"; +import type { CronRun } from "@/types"; + +interface Props { + initialRecent: CronRun[]; + initialLastSuccess: { + monthlyIssue: CronRun | null; + reminders: CronRun | null; + }; +} + +/** + * Admin cron dashboard. Server pre-loads `initialRecent` and + * `initialLastSuccess`; "Run now" clicks POST to the admin + * endpoints, then re-fetch the history via GET /api/admin/cron/runs. + * + * The trigger buttons disable while busy and surface the resulting + * counters inline so the admin gets immediate feedback without + * needing to scroll to the history table. + */ +export function CronControls({ initialRecent, initialLastSuccess }: Props) { + const t = useTranslations("adminCron"); + const fmt = useFormatter(); + const [recent, setRecent] = useState(initialRecent); + const [lastSuccess, setLastSuccess] = useState(initialLastSuccess); + const [busy, setBusy] = useState(null); + const [flash, setFlash] = useState(null); + + const refresh = async () => { + try { + const res = await fetch("/api/admin/cron/runs"); + if (!res.ok) return; + const data = await res.json(); + setRecent(data.recent); + setLastSuccess(data.lastSuccess); + } catch { + // swallow — refresh is opportunistic + } + }; + + const triggerIssue = async () => { + setBusy("issue"); + setFlash(null); + try { + const res = await fetch("/api/admin/cron/issue-monthly", { + method: "POST", + }); + const j = await res.json(); + if (!res.ok) { + setFlash({ + kind: "issue", + ok: false, + summary: j.error ?? `HTTP ${res.status}`, + }); + } else { + setFlash({ + kind: "issue", + ok: true, + summary: t("flashIssueOk", { + success: j.successCount, + skipped: j.skippedCount, + failure: j.failureCount, + }), + }); + } + await refresh(); + } finally { + setBusy(null); + } + }; + + const triggerReminders = async () => { + setBusy("reminders"); + setFlash(null); + try { + const res = await fetch("/api/admin/cron/send-reminders", { + method: "POST", + }); + const j = await res.json(); + if (!res.ok) { + setFlash({ + kind: "reminders", + ok: false, + summary: j.error ?? `HTTP ${res.status}`, + }); + } else { + setFlash({ + kind: "reminders", + ok: true, + summary: t("flashRemindersOk", { + success: j.successCount, + skipped: j.skippedCount, + failure: j.failureCount, + }), + }); + } + await refresh(); + } finally { + setBusy(null); + } + }; + + const fmtRelative = (iso: string | null) => { + if (!iso) return t("never"); + return fmt.dateTime(new Date(iso), { + dateStyle: "medium", + timeStyle: "short", + }); + }; + + return ( +
+
+ +

+ {t("monthlyIssue")} +

+

+ {t("scheduleIssueLabel")}: {t("scheduleIssueValue")} +

+

+ {t("lastSuccess")}: {fmtRelative(lastSuccess.monthlyIssue?.startedAt ?? null)} +

+ +
+ +

+ {t("reminders")} +

+

+ {t("scheduleReminderLabel")}: {t("scheduleReminderValue")} +

+

+ {t("lastSuccess")}: {fmtRelative(lastSuccess.reminders?.startedAt ?? null)} +

+ +
+
+ + {flash && ( +
+ {flash.summary} +
+ )} + +
+

+ {t("recentRuns")} +

+ + {recent.length === 0 ? ( +

+ {t("noRunsYet")} +

+ ) : ( + + + + + + + + + + + + + {recent.map((r) => ( + + + + + + + + + ))} + +
{t("startedCol")}{t("kindCol")}{t("triggeredByCol")}{t("okCol")}{t("skipCol")}{t("failCol")}
+ {fmtRelative(r.startedAt)} + + {t(`kind.${r.runKind}` as any)} + + {r.triggeredBy === "cron" + ? t("triggeredByCron") + : r.triggeredBy.slice(0, 8) + "…"} + + {r.successCount} + + {r.skippedCount} + 0 ? "text-error" : "text-text-muted" + }`} + > + {r.failureCount} +
+ )} +
+
+
+ ); +} diff --git a/src/lib/cron.ts b/src/lib/cron.ts new file mode 100644 index 0000000..7b561dd --- /dev/null +++ b/src/lib/cron.ts @@ -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 +): 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 }; diff --git a/src/lib/db.ts b/src/lib/db.ts index 8bd9da6..33601c7 100644 --- a/src/lib/db.ts +++ b/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 '' + 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 { + 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 { + 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 { + 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 = {}; + 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 { + 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 { + 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> { + 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 { + 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; +} diff --git a/src/lib/email.ts b/src/lib/email.ts index a0f895e..573076b 100644 --- a/src/lib/email.ts +++ b/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 { + 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> = { + 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> = { + 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> = { + 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: ` +
+

${escapeHtml(SUBJECTS[L][params.level])}

+

${l.greeting} ${safeName},

+

${escapeHtml(INTROS[L][params.level])}

+ + + + + +
${l.num}${safeNumber}
${l.total}${escapeHtml(totalFmt)}
${l.due}${escapeHtml(dueFmt)}
${l.days}${params.daysPastDue}
+

+ + ${l.cta} + +

+
+

${l.brand}

+
+ `, + }); + } catch (err) { + console.error( + `Failed to send reminder L${params.level} for invoice ${params.invoiceNumber}:`, + err + ); + } +} diff --git a/src/messages/de.json b/src/messages/de.json index b9260e9..266644a 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -393,7 +393,8 @@ "resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.", "openclawTool": "OpenClaw-Versionen", "billingTool": "Abrechnung →", - "skillsQueueTool": "Aktivierungs-Warteschlange" + "skillsQueueTool": "Aktivierungs-Warteschlange", + "cronTool": "Automatisierung" }, "channelUsers": { "title": "Autorisierte Benutzer", @@ -742,5 +743,35 @@ "redirectingToStripe": "Weiterleitung…", "paymentReceived": "Zahlung erhalten — vielen Dank!", "paymentCancelled": "Zahlung abgebrochen." + }, + "adminCron": { + "title": "Abrechnungsautomatisierung", + "subtitle": "Monatliche Rechnungsstellung und tägliche Mahnungsläufe. Beides läuft automatisch; mit den Schaltflächen unten können Sie einen Lauf manuell auslösen.", + "monthlyIssue": "Monatliche Rechnungsstellung", + "reminders": "Mahnungen", + "scheduleIssueLabel": "Zeitplan", + "scheduleIssueValue": "00:30 Europe/Zurich am 1.", + "scheduleReminderLabel": "Zeitplan", + "scheduleReminderValue": "09:00 Europe/Zurich täglich", + "lastSuccess": "Letzter Erfolg", + "never": "nie", + "runIssueNow": "Letzten Monat jetzt abrechnen", + "runRemindersNow": "Mahnungslauf jetzt starten", + "running": "Läuft…", + "flashIssueOk": "Rechnungsstellung abgeschlossen: {success} Rechnungen erstellt, {skipped} übersprungen, {failure} fehlgeschlagen.", + "flashRemindersOk": "Mahnungen versendet: {success} erfolgreich, {skipped} übersprungen, {failure} fehlgeschlagen.", + "recentRuns": "Letzte Läufe (max. 30)", + "noRunsYet": "Noch keine Automatisierungsläufe erfasst.", + "startedCol": "Gestartet", + "kindCol": "Art", + "triggeredByCol": "Ausgelöst von", + "okCol": "OK", + "skipCol": "Übersprungen", + "failCol": "Fehler", + "triggeredByCron": "Cron", + "kind": { + "monthly_issue": "Rechnungsstellung", + "reminders": "Mahnungen" + } } } diff --git a/src/messages/en.json b/src/messages/en.json index 9e47d0e..08fbc21 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -393,7 +393,8 @@ "resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.", "openclawTool": "OpenClaw versions", "billingTool": "Billing →", - "skillsQueueTool": "Activation Queue" + "skillsQueueTool": "Activation Queue", + "cronTool": "Automation" }, "channelUsers": { "title": "Authorized Users", @@ -742,5 +743,35 @@ "redirectingToStripe": "Redirecting…", "paymentReceived": "Payment received — thank you!", "paymentCancelled": "Payment cancelled." + }, + "adminCron": { + "title": "Billing automation", + "subtitle": "Monthly issuance and daily reminder sweeps. Both run automatically; use the buttons below to trigger a sweep on demand.", + "monthlyIssue": "Monthly issuance", + "reminders": "Reminders", + "scheduleIssueLabel": "Schedule", + "scheduleIssueValue": "00:30 Europe/Zurich on the 1st", + "scheduleReminderLabel": "Schedule", + "scheduleReminderValue": "09:00 Europe/Zurich daily", + "lastSuccess": "Last success", + "never": "never", + "runIssueNow": "Run last month's issuance now", + "runRemindersNow": "Run reminder sweep now", + "running": "Running…", + "flashIssueOk": "Issuance complete: {success} invoices issued, {skipped} skipped, {failure} failed.", + "flashRemindersOk": "Reminders sent: {success} succeeded, {skipped} skipped, {failure} failed.", + "recentRuns": "Recent runs (last 30)", + "noRunsYet": "No automation runs recorded yet.", + "startedCol": "Started", + "kindCol": "Kind", + "triggeredByCol": "Triggered by", + "okCol": "OK", + "skipCol": "Skipped", + "failCol": "Failed", + "triggeredByCron": "cron", + "kind": { + "monthly_issue": "Issuance", + "reminders": "Reminders" + } } } diff --git a/src/messages/fr.json b/src/messages/fr.json index aea4167..8bd9b8c 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -393,7 +393,8 @@ "resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.", "openclawTool": "Versions OpenClaw", "billingTool": "Facturation →", - "skillsQueueTool": "File d'activation" + "skillsQueueTool": "File d'activation", + "cronTool": "Automatisation" }, "channelUsers": { "title": "Utilisateurs autorisés", @@ -742,5 +743,35 @@ "redirectingToStripe": "Redirection…", "paymentReceived": "Paiement reçu — merci !", "paymentCancelled": "Paiement annulé." + }, + "adminCron": { + "title": "Automatisation de la facturation", + "subtitle": "Émission mensuelle et balayage quotidien des rappels. Les deux s'exécutent automatiquement ; utilisez les boutons ci-dessous pour déclencher un lancement à la demande.", + "monthlyIssue": "Émission mensuelle", + "reminders": "Rappels", + "scheduleIssueLabel": "Planning", + "scheduleIssueValue": "00:30 Europe/Zurich le 1er", + "scheduleReminderLabel": "Planning", + "scheduleReminderValue": "09:00 Europe/Zurich quotidien", + "lastSuccess": "Dernière réussite", + "never": "jamais", + "runIssueNow": "Facturer le mois dernier maintenant", + "runRemindersNow": "Lancer les rappels maintenant", + "running": "En cours…", + "flashIssueOk": "Émission terminée : {success} factures émises, {skipped} ignorées, {failure} échouées.", + "flashRemindersOk": "Rappels envoyés : {success} réussis, {skipped} ignorés, {failure} échoués.", + "recentRuns": "Lancements récents (30 derniers)", + "noRunsYet": "Aucun lancement automatique enregistré pour le moment.", + "startedCol": "Démarré", + "kindCol": "Type", + "triggeredByCol": "Déclenché par", + "okCol": "OK", + "skipCol": "Ignorés", + "failCol": "Échoués", + "triggeredByCron": "cron", + "kind": { + "monthly_issue": "Émission", + "reminders": "Rappels" + } } } diff --git a/src/messages/it.json b/src/messages/it.json index 49a2536..93c8ce2 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -393,7 +393,8 @@ "resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.", "openclawTool": "Versioni OpenClaw", "billingTool": "Fatturazione →", - "skillsQueueTool": "Coda di attivazione" + "skillsQueueTool": "Coda di attivazione", + "cronTool": "Automazione" }, "channelUsers": { "title": "Utenti autorizzati", @@ -742,5 +743,35 @@ "redirectingToStripe": "Reindirizzamento…", "paymentReceived": "Pagamento ricevuto — grazie!", "paymentCancelled": "Pagamento annullato." + }, + "adminCron": { + "title": "Automazione fatturazione", + "subtitle": "Emissione mensile e invio quotidiano dei solleciti. Entrambi vengono eseguiti automaticamente; usa i pulsanti sotto per avviare un'esecuzione su richiesta.", + "monthlyIssue": "Emissione mensile", + "reminders": "Solleciti", + "scheduleIssueLabel": "Pianificazione", + "scheduleIssueValue": "00:30 Europe/Zurich il 1°", + "scheduleReminderLabel": "Pianificazione", + "scheduleReminderValue": "09:00 Europe/Zurich quotidianamente", + "lastSuccess": "Ultimo successo", + "never": "mai", + "runIssueNow": "Fattura il mese scorso ora", + "runRemindersNow": "Avvia solleciti ora", + "running": "In corso…", + "flashIssueOk": "Emissione completata: {success} fatture emesse, {skipped} ignorate, {failure} fallite.", + "flashRemindersOk": "Solleciti inviati: {success} riusciti, {skipped} ignorati, {failure} falliti.", + "recentRuns": "Esecuzioni recenti (ultime 30)", + "noRunsYet": "Nessuna esecuzione automatica registrata.", + "startedCol": "Avviata", + "kindCol": "Tipo", + "triggeredByCol": "Avviata da", + "okCol": "OK", + "skipCol": "Ignorati", + "failCol": "Falliti", + "triggeredByCron": "cron", + "kind": { + "monthly_issue": "Emissione", + "reminders": "Solleciti" + } } } diff --git a/src/types/index.ts b/src/types/index.ts index f82ef2c..46e598b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -542,6 +542,20 @@ export type InvoiceStatus = export type InvoicePaymentMethod = "invoice" | "card"; +// Phase 5 — Cron run history rows for the admin /admin/cron page. +export type CronRunKind = "monthly_issue" | "reminders"; +export interface CronRun { + id: string; + runKind: CronRunKind; + triggeredBy: string; + startedAt: string; + finishedAt: string | null; + successCount: number; + failureCount: number; + skippedCount: number; + errorDetails: unknown | null; +} + export type InvoiceLineKind = | "tenant_monthly" | "tenant_setup"