Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions app/(control)/admin/cron/page.tsx
Original file line number Diff line number Diff line change
@@ -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 | number>) => 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 (
<div className="grid gap-10">
<div>
<h1 className="font-display font-bold text-5xl">{t("title")}</h1>
<p className="mt-2 font-label text-muted-foreground">{t("intro")}</p>
</div>

{/* 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 ? (
<p role="alert" className="hand-drawn-border bg-card p-4 font-label text-craft-error-text">
{t("attention", { count: stale.length, total: rows.length })}
</p>
) : (
<p className="hand-drawn-border bg-card p-4 font-label text-craft-success-text">
{t("allFresh", { count: rows.length })}
</p>
)}

<ul className="grid gap-4">
{rows.map((r) => (
<li key={r.sweep} className="hand-drawn-border bg-card p-6 grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="font-hand text-3xl font-bold">{r.label}</h2>
<span
className={
r.status === "fresh"
? "font-label text-craft-success-text"
: "font-label text-craft-error-text"
}
>
{t(`status.${r.status}`)}
</span>
</div>
<p className="font-mono text-xs text-muted-foreground">
{r.lastRunAt
? t("lastRan", {
when: r.lastRunAt.toISOString().replace("T", " ").slice(0, 16),
ago: humanAge(r.ageMs ?? 0, t),
})
: t("neverRan")}
</p>
<p className="font-label text-sm text-muted-foreground">
{t("budget", { hours: Math.round(r.budgetMs / 3600000) })}
</p>
</li>
))}
</ul>

<p className="font-label text-sm text-muted-foreground">{t("footnote")}</p>
</div>
);
}
1 change: 1 addition & 0 deletions app/(control)/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
],
},
{
Expand Down
7 changes: 7 additions & 0 deletions app/api/cron/backup-alerts/route.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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);
}
7 changes: 7 additions & 0 deletions app/api/cron/go-live/route.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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);
}
7 changes: 7 additions & 0 deletions app/api/cron/retention/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 });
}
7 changes: 7 additions & 0 deletions app/api/cron/trial-warnings/route.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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);
}
64 changes: 63 additions & 1 deletion docs/adr/ADR-011-payments-split.md
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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
Expand Down
97 changes: 97 additions & 0 deletions lib/cron-freshness.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<CronFreshnessRow[]> {
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),
};
});
}
7 changes: 7 additions & 0 deletions lib/module-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,8 @@
"icp": "ICP",
"billingDetails": "بيانات الفوترة",
"domains": "النطاقات",
"brand": "العلامة"
"brand": "العلامة",
"cron": "المهام المجدولة"
},
"groups": {
"pipeline": "المسار",
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading