diff --git a/app/(control)/admin/cron/page.tsx b/app/(control)/admin/cron/page.tsx new file mode 100644 index 0000000..e1b3c97 --- /dev/null +++ b/app/(control)/admin/cron/page.tsx @@ -0,0 +1,78 @@ +import { getTranslations } from "next-intl/server"; +import { requireAdmin } from "@/lib/rbac"; +import { controlLocale } from "@/lib/control-locale"; +import { cronFreshness, type CronFreshnessRow } from "@/lib/cron-freshness"; + +// Always fresh: a cached "everything ran recently" is the one answer this page must +// never give. It is the page you open BECAUSE you suspect nothing has run. +export const dynamic = "force-dynamic"; + +function humanAge(ageMs: number, t: (k: string, v?: Record) => string): string { + const minutes = Math.floor(ageMs / 60000); + if (minutes < 60) return t("ageMinutes", { count: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 48) return t("ageHours", { count: hours }); + return t("ageDays", { count: Math.floor(hours / 24) }); +} + +export default async function AdminCronPage() { + await requireAdmin(); + const locale = await controlLocale(); + const t = await getTranslations({ locale, namespace: "control.admin.cron" }); + + const rows: CronFreshnessRow[] = await cronFreshness(); + const stale = rows.filter((r) => r.status !== "fresh"); + + return ( +
+
+

{t("title")}

+

{t("intro")}

+
+ + {/* The headline first. A reader who has to assemble the verdict by scanning four + rows is a reader who assembles it wrong at 2am. */} + {stale.length > 0 ? ( +

+ {t("attention", { count: stale.length, total: rows.length })} +

+ ) : ( +

+ {t("allFresh", { count: rows.length })} +

+ )} + + + +

{t("footnote")}

+
+ ); +} diff --git a/app/(control)/admin/layout.tsx b/app/(control)/admin/layout.tsx index 513b70c..7e2f01d 100644 --- a/app/(control)/admin/layout.tsx +++ b/app/(control)/admin/layout.tsx @@ -35,6 +35,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN { href: "/admin/onboard", label: t("nav.onboard") }, { href: "/admin/fleet", label: t("nav.fleet") }, { href: "/admin/backups", label: t("nav.backups") }, + { href: "/admin/cron", label: t("nav.cron") }, ], }, { diff --git a/app/api/cron/backup-alerts/route.ts b/app/api/cron/backup-alerts/route.ts index e626044..0263422 100644 --- a/app/api/cron/backup-alerts/route.ts +++ b/app/api/cron/backup-alerts/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { cronAuthorized } from "@/lib/cron-auth"; +import { recordCronRun } from "@/lib/cron-freshness"; import { runBackupAlertSweep } from "@/lib/backup-alert-notify"; // Machine-to-machine cron endpoint (called by .github/workflows/backup-alert-cron.yml). @@ -27,6 +28,12 @@ export async function POST(request: Request) { } const result = await runBackupAlertSweep(); + // Heartbeat on EVERY run, whatever the sweep found (#209). The sweeps' own audit rows + // are written only when they SEND something, so during the six-day Actions outage — + // when every sweep had nothing to send anyway — a readout built on those rows would + // have looked identical to a healthy one. This is the row that distinguishes "ran and + // found nothing" from "never ran". + await recordCronRun("backup-alerts", result); // Counts, slugs-free: the recipient address never appears in a response or a log. return NextResponse.json(result); } diff --git a/app/api/cron/go-live/route.ts b/app/api/cron/go-live/route.ts index 8650bee..d455953 100644 --- a/app/api/cron/go-live/route.ts +++ b/app/api/cron/go-live/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { cronAuthorized } from "@/lib/cron-auth"; +import { recordCronRun } from "@/lib/cron-freshness"; import { runGoLiveSweep } from "@/lib/go-live-notify"; // Machine-to-machine cron endpoint (called by .github/workflows/go-live-cron.yml). @@ -20,6 +21,12 @@ export async function POST(request: Request) { } const result = await runGoLiveSweep(); + // Heartbeat on EVERY run, whatever the sweep found (#209). The sweeps' own audit rows + // are written only when they SEND something, so during the six-day Actions outage — + // when every sweep had nothing to send anyway — a readout built on those rows would + // have looked identical to a healthy one. This is the row that distinguishes "ran and + // found nothing" from "never ran". + await recordCronRun("go-live", result); // Counts only — never a recipient address in the response or the logs. return NextResponse.json(result); } diff --git a/app/api/cron/retention/route.ts b/app/api/cron/retention/route.ts index 2fd36ab..493c5ec 100644 --- a/app/api/cron/retention/route.ts +++ b/app/api/cron/retention/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { cronAuthorized } from "@/lib/cron-auth"; +import { recordCronRun } from "@/lib/cron-freshness"; import { retentionConfig } from "@/lib/retention-policy"; import { runRetention } from "@/lib/retention"; @@ -22,6 +23,12 @@ export async function POST(request: Request) { } const deleted = await runRetention(new Date(), config); + // Heartbeat on EVERY run, whatever the sweep found (#209). The sweeps' own audit rows + // are written only when they SEND something, so during the six-day Actions outage — + // when every sweep had nothing to send anyway — a readout built on those rows would + // have looked identical to a healthy one. This is the row that distinguishes "ran and + // found nothing" from "never ran". + await recordCronRun("retention", { deleted }); // Counts only — never any PII in the response or logs. return NextResponse.json({ enabled: true, deleted }); } diff --git a/app/api/cron/trial-warnings/route.ts b/app/api/cron/trial-warnings/route.ts index 296d488..495bd45 100644 --- a/app/api/cron/trial-warnings/route.ts +++ b/app/api/cron/trial-warnings/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { cronAuthorized } from "@/lib/cron-auth"; +import { recordCronRun } from "@/lib/cron-freshness"; import { runTrialWarningSweep } from "@/lib/trial-warning-notify"; // Machine-to-machine cron endpoint (called by .github/workflows/trial-warning-cron.yml). @@ -23,6 +24,12 @@ export async function POST(request: Request) { } const result = await runTrialWarningSweep(); + // Heartbeat on EVERY run, whatever the sweep found (#209). The sweeps' own audit rows + // are written only when they SEND something, so during the six-day Actions outage — + // when every sweep had nothing to send anyway — a readout built on those rows would + // have looked identical to a healthy one. This is the row that distinguishes "ran and + // found nothing" from "never ran". + await recordCronRun("trial-warnings", result); // Counts only — never a recipient address in the response or the logs. return NextResponse.json(result); } diff --git a/docs/adr/ADR-011-payments-split.md b/docs/adr/ADR-011-payments-split.md index 671bca3..cff35ba 100644 --- a/docs/adr/ADR-011-payments-split.md +++ b/docs/adr/ADR-011-payments-split.md @@ -1,6 +1,8 @@ # ADR-011 — Payments split: Mollie for Sofra billing, Stripe Connect for tenant payments -**Status:** accepted 2026-07-05 (owner decision) +**Status:** accepted 2026-07-05 (owner decision); **amended 2026-09-04** — Job B +gains the application-fee mechanism, still defaulting to 0 for every tenant. See +§Amendment. Two distinct payment jobs, two providers: @@ -20,6 +22,66 @@ application fee per transaction. Chosen over Mollie-for-Platforms for platform maturity, Swiss-merchant + TWINT coverage, and worldwide headroom as markets expand beyond CH/NL/FR. +## Amendment (2026-09-04) — the commission mechanism exists now; the rate is still zero + +Job B said "Sofra takes an application fee per transaction." It never shipped one: +v1 is Direct charges with `application_fee_amount` deliberately unset +(`IStripeGateway.BuildRequestOptions` docstring, backend — *"Sofra takes no +commission and the money never touches its balance"*). This amendment adds the +mechanism. **It does not turn it on for anyone.** + +**The mechanism.** `application_fee_amount` on the existing Connect **Standard +direct charge** — charge type, connected account, onboarding and settlement are +all unchanged. Money still never passes through Sofra's balance; Stripe transfers +the fee to the platform after the charge settles on the connected account. + +**Configuration.** Backend setting `Stripe:Commission:Bps` (basis points), +overridden per tenant via the deploy registry key `payments_commission_bps`. +**Default 0.** Putting a tenant on a rate is a registry edit, not a deploy. + +**Measured 2026-09-04**, platform `acct_1TpwTNCAHTt6eZ8i` (NL) against a +throwaway CH connected account, Stripe test mode: + +- An NL platform **can** collect an application fee from a CH connected account — + a real `ApplicationFee` object was produced. Stripe's docs only say fees work in + "most countries with a few exceptions based on the country pair"; NL→CH is not + one of the exceptions. +- The fee arrives **in CHF**, so the EUR-booked NL platform accrues a CHF + balance. FX applies on conversion. +- **An oversized fee is CAPPED, not rejected.** A requested fee of 5000 on a + 4000 charge produced an `ApplicationFee` of 4000 with no error — Stripe took + the whole order rather than refuse the request. Hence the backend enforces its + own ceiling of **1000 bps (10%)**; nothing upstream will stop a misconfigured + value above that. +- `POST /v1/checkout/sessions` validates the fee **not at all** — it accepted a + fee larger than the order, and accepted a session on an account with + `charges_enabled: false`. Only confirming a PaymentIntent discriminates. + +**Two consequences, recorded as open — not solved by this change:** + +1. **Refunds.** Stripe does not auto-refund the application fee when a charge is + refunded; the platform must pass `refund_application_fee=true` or the + connected account eats the fee. RUMI today deliberately refuses to refund + Stripe-captured payments (`TenderCustody` — the platform key has no refunds + write), so the restaurant refunds from its own Stripe dashboard and nothing + returns the fee. **A non-zero rate must not be switched on for any tenant + until this is closed.** The intended fix is a platform-level Connect webhook + (`connect: true`) on `charge.refunded`, which IS permitted — unlike + registering a webhook on a connected account. +2. **No commission reporting surface.** Fees accrue in the platform's own + Stripe balance; nothing in the control plane reports them per tenant. + `CommissionEntry` is the **partner** ledger (ADR-009) and must not be + overloaded for this — different entity, different money, different source + of truth. + +**What this amendment does not touch:** no tenant's rate, the module's price, or +its marketing copy. `online-payments`' "no commission" promise — the payments +FAQ answer (`faq.items.payments.a`) and the competitor-comparison row +(`compare.table.rows.payments.sofra`), twice per locale across `en de nl fr tr +ar` (twelve strings, verified by grep 2026-09-04) — is still **true**, because +every tenant is at 0. That copy is exactly what must change, in all twelve +places, the day a tenant is first put on a non-zero rate — and not before. + ## Why split rather than one provider - Sofra-the-merchant is EU/NL-anchored — Mollie's home turf, cheapest diff --git a/lib/cron-freshness.ts b/lib/cron-freshness.ts new file mode 100644 index 0000000..502afd9 --- /dev/null +++ b/lib/cron-freshness.ts @@ -0,0 +1,97 @@ +import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; + +/** + * Cron freshness (#209) — "when did each sweep last actually RUN?". + * + * ## Why this needed a new write path after all + * + * The issue proposed deriving this from the audit rows the sweeps already write, with no + * new write path. That does not work, and the reason is the whole point of the feature: + * **a sweep only writes an audit row when it SENDS something.** + * `runTrialWarningSweep` returns early on `todo.length === 0` and writes nothing; the + * go-live marker is written inside the per-candidate loop. + * + * During the six-day outage every sweep had nothing to send anyway — the incident report + * records both hand-dispatched sweeps returning `considered: 0`. So a readout built on + * those rows would have shown exactly the same thing whether the crons ran or not: it + * would have been GREEN for all six days. A freshness signal that cannot distinguish + * "ran and found nothing" from "never ran" is not a freshness signal. + * + * So each sweep records a heartbeat on EVERY run. It goes in `AuditLog`, which already + * exists — no schema change, and therefore no Prisma migration against the database + * holding partner, billing and CRM records. + */ + +export const CRON_RAN_ACTION = "cron.ran"; +export const CRON_ENTITY = "Cron"; + +/** + * Every sweep, with how stale it is allowed to get before the readout calls it overdue. + * + * The budget is deliberately GENEROUS — roughly two-and-a-bit missed runs, not one. + * GitHub's scheduled runs are best-effort and routinely drift by tens of minutes under + * load; a threshold of one interval would cry most days, and an alarm that cries most + * days is the one nobody reads. The failure being caught here lasted six DAYS. + */ +export const CRON_SWEEPS = { + "trial-warnings": { label: "Trial warnings", everyMs: 24 * 60 * 60 * 1000, budgetMs: 30 * 60 * 60 * 1000 }, + "go-live": { label: "Go-live announcements", everyMs: 15 * 60 * 1000, budgetMs: 2 * 60 * 60 * 1000 }, + "backup-alerts": { label: "Backup alerts", everyMs: 12 * 60 * 60 * 1000, budgetMs: 18 * 60 * 60 * 1000 }, + retention: { label: "Retention sweep", everyMs: 24 * 60 * 60 * 1000, budgetMs: 30 * 60 * 60 * 1000 }, +} as const; + +export type CronSweep = keyof typeof CRON_SWEEPS; + +export type CronFreshnessRow = { + sweep: CronSweep; + label: string; + lastRunAt: Date | null; + ageMs: number | null; + budgetMs: number; + /** `never` is its own state: "no heartbeat ever" and "a heartbeat six days old" need different words. */ + status: "fresh" | "overdue" | "never"; +}; + +/** + * Called by each cron route AFTER the sweep returns, whatever it found. Fire-and-forget, + * like every other `audit()` call: a heartbeat that could fail a sweep would make the + * monitoring more dangerous than the thing it monitors. + * + * `result` is the sweep's own counts — no addresses, no tenant PII (CLAUDE.md §5.8). + */ +export async function recordCronRun(sweep: CronSweep, result: unknown): Promise { + await audit(null, CRON_RAN_ACTION, CRON_ENTITY, sweep, { result }); +} + +/** One row per sweep, newest heartbeat each, ordered as declared. */ +export async function cronFreshness(now: Date = new Date()): Promise { + const sweeps = Object.keys(CRON_SWEEPS) as CronSweep[]; + + // One query, not one per sweep: `groupBy` with `_max` is what makes this a single + // index scan on (action, entityId) rather than four round trips to the same table. + const latest = await db.auditLog.groupBy({ + by: ["entityId"], + where: { action: CRON_RAN_ACTION, entityType: CRON_ENTITY, entityId: { in: sweeps } }, + _max: { createdAt: true }, + }); + + const seen = new Map(latest.map((r) => [r.entityId, r._max.createdAt])); + + return sweeps.map((sweep) => { + const { label, budgetMs } = CRON_SWEEPS[sweep]; + const lastRunAt = seen.get(sweep) ?? null; + if (!lastRunAt) { + return { sweep, label, lastRunAt: null, ageMs: null, budgetMs, status: "never" as const }; + } + const ageMs = now.getTime() - lastRunAt.getTime(); + return { + sweep, + label, + lastRunAt, + ageMs, + budgetMs, + status: ageMs > budgetMs ? ("overdue" as const) : ("fresh" as const), + }; + }); +} diff --git a/lib/module-catalog.ts b/lib/module-catalog.ts index b852896..35e85e7 100644 --- a/lib/module-catalog.ts +++ b/lib/module-catalog.ts @@ -57,6 +57,13 @@ export const MODULES: readonly CatalogModule[] = [ { id: "reservations", priceCents: 900, surface: "/reservations + admin management" }, { id: "loyalty", priceCents: 900, surface: "fidelity points, customer groups, discounts" }, { id: "printing", priceCents: 900, surface: "printer-app companion + printer feed" }, + // ADR-011 amendment (2026-09-04): a per-transaction commission mechanism now exists + // (Stripe `application_fee_amount` on the existing Connect direct charge) but is not + // priced here — it defaults to 0 bps for every tenant and is configured per tenant in + // the deploy registry (`payments_commission_bps`), not in this catalog. Do NOT add a + // commission-priced variant of this module until the ADR's refund gap is closed: Stripe + // does not auto-refund the application fee, and RUMI does not refund Stripe-captured + // payments today, so a live non-zero rate would keep the fee on every refund. { id: "online-payments", priceCents: 1900, diff --git a/messages/ar.json b/messages/ar.json index 6a85831..9db6c04 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -483,7 +483,8 @@ "icp": "ICP", "billingDetails": "بيانات الفوترة", "domains": "النطاقات", - "brand": "العلامة" + "brand": "العلامة", + "cron": "المهام المجدولة" }, "groups": { "pipeline": "المسار", @@ -993,6 +994,24 @@ "submit": "تمديد الفترة المجانية", "saving": "جارٍ الحفظ…", "saved": "تم الحفظ." + }, + "cron": { + "title": "Scheduled sweeps", + "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", + "attention": "{count} of {total} sweeps have not run inside their budget.", + "allFresh": "All {count} sweeps have run recently.", + "lastRan": "Last ran {when} UTC — {ago} ago", + "neverRan": "No run has ever been recorded.", + "budget": "Considered overdue after {hours}h.", + "ageMinutes": "{count}m", + "ageHours": "{count}h", + "ageDays": "{count}d", + "footnote": "A heartbeat is written on every run, whether or not the sweep had anything to do — a sweep that runs and finds nothing must not look like one that never ran. The box also triggers these independently of GitHub Actions (deploy #165), so either path alone keeps them running.", + "status": { + "fresh": "on time", + "overdue": "OVERDUE", + "never": "never run" + } } }, "errors": { diff --git a/messages/de.json b/messages/de.json index d447d0f..33229cb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -483,7 +483,8 @@ "icp": "ICP", "billingDetails": "Rechnungsdaten", "domains": "Domains", - "brand": "Marke" + "brand": "Marke", + "cron": "Geplante Läufe" }, "groups": { "pipeline": "Pipeline", @@ -993,6 +994,24 @@ "submit": "Gratiszeitraum verlängern", "saving": "Wird gespeichert…", "saved": "Gespeichert." + }, + "cron": { + "title": "Scheduled sweeps", + "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", + "attention": "{count} of {total} sweeps have not run inside their budget.", + "allFresh": "All {count} sweeps have run recently.", + "lastRan": "Last ran {when} UTC — {ago} ago", + "neverRan": "No run has ever been recorded.", + "budget": "Considered overdue after {hours}h.", + "ageMinutes": "{count}m", + "ageHours": "{count}h", + "ageDays": "{count}d", + "footnote": "A heartbeat is written on every run, whether or not the sweep had anything to do — a sweep that runs and finds nothing must not look like one that never ran. The box also triggers these independently of GitHub Actions (deploy #165), so either path alone keeps them running.", + "status": { + "fresh": "on time", + "overdue": "OVERDUE", + "never": "never run" + } } }, "errors": { diff --git a/messages/en.json b/messages/en.json index 0755dd0..48bd912 100644 --- a/messages/en.json +++ b/messages/en.json @@ -483,7 +483,8 @@ "icp": "ICP", "billingDetails": "Billing details", "domains": "Domains", - "brand": "Brand" + "brand": "Brand", + "cron": "Scheduled sweeps" }, "groups": { "pipeline": "Pipeline", @@ -993,6 +994,24 @@ "submit": "Extend free period", "saving": "Saving…", "saved": "Saved." + }, + "cron": { + "title": "Scheduled sweeps", + "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", + "attention": "{count} of {total} sweeps have not run inside their budget.", + "allFresh": "All {count} sweeps have run recently.", + "lastRan": "Last ran {when} UTC — {ago} ago", + "neverRan": "No run has ever been recorded.", + "budget": "Considered overdue after {hours}h.", + "ageMinutes": "{count}m", + "ageHours": "{count}h", + "ageDays": "{count}d", + "footnote": "A heartbeat is written on every run, whether or not the sweep had anything to do — a sweep that runs and finds nothing must not look like one that never ran. The box also triggers these independently of GitHub Actions (deploy #165), so either path alone keeps them running.", + "status": { + "fresh": "on time", + "overdue": "OVERDUE", + "never": "never run" + } } }, "errors": { diff --git a/messages/fr.json b/messages/fr.json index 9cec320..8e9186e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -483,7 +483,8 @@ "icp": "ICP", "billingDetails": "Infos de facturation", "domains": "Domaines", - "brand": "Marque" + "brand": "Marque", + "cron": "Tâches planifiées" }, "groups": { "pipeline": "Pipeline", @@ -993,6 +994,24 @@ "submit": "Prolonger la période gratuite", "saving": "Enregistrement…", "saved": "Enregistré." + }, + "cron": { + "title": "Scheduled sweeps", + "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", + "attention": "{count} of {total} sweeps have not run inside their budget.", + "allFresh": "All {count} sweeps have run recently.", + "lastRan": "Last ran {when} UTC — {ago} ago", + "neverRan": "No run has ever been recorded.", + "budget": "Considered overdue after {hours}h.", + "ageMinutes": "{count}m", + "ageHours": "{count}h", + "ageDays": "{count}d", + "footnote": "A heartbeat is written on every run, whether or not the sweep had anything to do — a sweep that runs and finds nothing must not look like one that never ran. The box also triggers these independently of GitHub Actions (deploy #165), so either path alone keeps them running.", + "status": { + "fresh": "on time", + "overdue": "OVERDUE", + "never": "never run" + } } }, "errors": { diff --git a/messages/nl.json b/messages/nl.json index 8bf2a36..de10294 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -483,7 +483,8 @@ "icp": "ICP", "billingDetails": "Factuurgegevens", "domains": "Domeinen", - "brand": "Merk" + "brand": "Merk", + "cron": "Geplande taken" }, "groups": { "pipeline": "Pijplijn", @@ -993,6 +994,24 @@ "submit": "Gratis periode verlengen", "saving": "Opslaan…", "saved": "Opgeslagen." + }, + "cron": { + "title": "Scheduled sweeps", + "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", + "attention": "{count} of {total} sweeps have not run inside their budget.", + "allFresh": "All {count} sweeps have run recently.", + "lastRan": "Last ran {when} UTC — {ago} ago", + "neverRan": "No run has ever been recorded.", + "budget": "Considered overdue after {hours}h.", + "ageMinutes": "{count}m", + "ageHours": "{count}h", + "ageDays": "{count}d", + "footnote": "A heartbeat is written on every run, whether or not the sweep had anything to do — a sweep that runs and finds nothing must not look like one that never ran. The box also triggers these independently of GitHub Actions (deploy #165), so either path alone keeps them running.", + "status": { + "fresh": "on time", + "overdue": "OVERDUE", + "never": "never run" + } } }, "errors": { diff --git a/messages/tr.json b/messages/tr.json index 863ca3b..2e79eaf 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -483,7 +483,8 @@ "icp": "ICP", "billingDetails": "Fatura bilgileri", "domains": "Alan adları", - "brand": "Marka" + "brand": "Marka", + "cron": "Zamanlanmış görevler" }, "groups": { "pipeline": "Süreç", @@ -993,6 +994,24 @@ "submit": "Ücretsiz dönemi uzat", "saving": "Kaydediliyor…", "saved": "Kaydedildi." + }, + "cron": { + "title": "Scheduled sweeps", + "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", + "attention": "{count} of {total} sweeps have not run inside their budget.", + "allFresh": "All {count} sweeps have run recently.", + "lastRan": "Last ran {when} UTC — {ago} ago", + "neverRan": "No run has ever been recorded.", + "budget": "Considered overdue after {hours}h.", + "ageMinutes": "{count}m", + "ageHours": "{count}h", + "ageDays": "{count}d", + "footnote": "A heartbeat is written on every run, whether or not the sweep had anything to do — a sweep that runs and finds nothing must not look like one that never ran. The box also triggers these independently of GitHub Actions (deploy #165), so either path alone keeps them running.", + "status": { + "fresh": "on time", + "overdue": "OVERDUE", + "never": "never run" + } } }, "errors": { diff --git a/tests/unit/cron-freshness.test.ts b/tests/unit/cron-freshness.test.ts new file mode 100644 index 0000000..cf198c5 --- /dev/null +++ b/tests/unit/cron-freshness.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// The DB is the only thing mocked. `cronFreshness` IS the logic under test, and the +// question — "would this have gone red during the six-day outage?" — is answered by +// feeding it the heartbeat rows that outage would have produced (namely, none recent). +const groupBy = vi.fn(); +vi.mock("@/lib/db", () => ({ db: { auditLog: { groupBy: (...a: unknown[]) => groupBy(...a) } } })); +vi.mock("@/lib/audit", () => ({ audit: vi.fn() })); + +const { cronFreshness, CRON_SWEEPS, recordCronRun, CRON_RAN_ACTION } = await import("@/lib/cron-freshness"); +const { audit } = await import("@/lib/audit"); + +const NOW = new Date("2026-09-04T12:00:00.000Z"); +const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3600_000); +const rows = (m: Record) => + Object.entries(m).map(([entityId, createdAt]) => ({ entityId, _max: { createdAt } })); + +const bySweep = async (m: Record) => { + groupBy.mockResolvedValueOnce(rows(m)); + const out = await cronFreshness(NOW); + return Object.fromEntries(out.map((r) => [r.sweep, r])); +}; + +const ALL_FRESH = { + "trial-warnings": hoursAgo(2), + "go-live": hoursAgo(0.2), + "backup-alerts": hoursAgo(3), + retention: hoursAgo(5), +}; + +beforeEach(() => { + groupBy.mockReset(); + vi.mocked(audit).mockClear(); +}); + +describe("cron freshness", () => { + it("reports every sweep on time when all have heartbeats inside budget", () => { + return bySweep(ALL_FRESH).then((r) => { + expect(Object.values(r).map((x) => x.status)).toEqual(["fresh", "fresh", "fresh", "fresh"]); + }); + }); + + it("goes OVERDUE for the six-day outage — the case it exists for", async () => { + // 2026-08-24 → 2026-08-30. Every sweep had `considered: 0` throughout, which is + // exactly why a readout derived from the sweeps' own send-markers would have shown + // nothing wrong. These are heartbeat rows, so the silence is visible. + const r = await bySweep({ + "trial-warnings": hoursAgo(6 * 24), + "go-live": hoursAgo(6 * 24), + "backup-alerts": hoursAgo(6 * 24), + retention: hoursAgo(6 * 24), + }); + + expect(Object.values(r).map((x) => x.status)).toEqual(["overdue", "overdue", "overdue", "overdue"]); + }); + + it("distinguishes NEVER RUN from merely stale", async () => { + // Two different facts that need two different words: a sweep nobody has ever + // triggered, and one that stopped. Collapsing them tells the reader to go looking + // in the wrong place. + const r = await bySweep({ "go-live": hoursAgo(0.1) }); + + expect(r["go-live"].status).toBe("fresh"); + expect(r["trial-warnings"].status).toBe("never"); + expect(r["trial-warnings"].lastRunAt).toBeNull(); + expect(r["trial-warnings"].ageMs).toBeNull(); + }); + + it("holds each sweep to ITS OWN budget, not one shared threshold", async () => { + // 3h stale: nothing for a daily sweep, badly overdue for one that runs every 15 min. + // A single global threshold would have to be wrong for one of them. + const r = await bySweep({ + "trial-warnings": hoursAgo(3), + "go-live": hoursAgo(3), + "backup-alerts": hoursAgo(3), + retention: hoursAgo(3), + }); + + expect(r["go-live"].status).toBe("overdue"); + expect(r["trial-warnings"].status).toBe("fresh"); + expect(r.retention.status).toBe("fresh"); + }); + + it("does not cry on ordinary GitHub schedule drift", async () => { + // The control for the test above. Scheduled runs routinely drift by tens of minutes; + // an alarm that fires most days is one nobody reads, and the failure being caught + // here lasted six DAYS. Each budget is ~2+ missed runs, so a single slip is silent. + const r = await bySweep({ + "trial-warnings": hoursAgo(25), + "go-live": hoursAgo(0.6), + "backup-alerts": hoursAgo(13), + retention: hoursAgo(25), + }); + + expect(Object.values(r).map((x) => x.status)).toEqual(["fresh", "fresh", "fresh", "fresh"]); + }); + + it("every declared sweep gets a row even with no rows at all", async () => { + const r = await bySweep({}); + + expect(Object.keys(r).sort()).toEqual(Object.keys(CRON_SWEEPS).sort()); + expect(Object.values(r).every((x) => x.status === "never")).toBe(true); + }); + + it("records a heartbeat with the sweep's own counts and no PII", async () => { + await recordCronRun("trial-warnings", { considered: 0, founderNotices: 0 }); + + expect(audit).toHaveBeenCalledWith(null, CRON_RAN_ACTION, "Cron", "trial-warnings", { + result: { considered: 0, founderNotices: 0 }, + }); + }); +});