Overdue detection and alerts
The merchant's actual request was not 'charge my customers'. It was 'I shouldn't have to think about whether the payment happened.' Overdue detection and alerting are the parts that answer it.
Nobody noticing a missing payment was the pain, not charging. That distinction shapes this whole layer: the interesting event is not a successful charge, it is the absence of one past a date.apps/worker/src/sweep.js:7
One overdue path, not two
The sweep advances obligations through two transitions, and neither special-cases the fulfilment method. A pull whose charge failed terminally and a watch payment that never arrived are the same fact by the time they reach here: an expected payment past its due date with an outstanding balance.apps/worker/src/sweep.js:19
| Transition | Condition | Emits |
|---|---|---|
upcoming → due | Inside the lead time: now() ≥ due_date − lead_time_seconds | nothing — being due is not news |
due | partially_paid → overdue | Past the due date plus grace: now() > due_date + grace_seconds | payment.overdue apps/worker/src/sweep.js:39 |
The overdue event carries what a merchant would act on rather than a bare identifier: whose obligation it is, how much is still outstanding, when it was due, and how it was meant to be satisfied.apps/worker/src/sweep.js:40
upcoming, and no overdue event may be emitted for it.scripts/phase2-alerts-drills.mjs:69The six event types
| Event | When |
|---|---|
payment.overdue | The sweep moved an obligation past its due date. |
payment.received | An incoming transfer was indexed and is not internal. |
payment.matched | A transfer was attributed to an obligation, automatically or by a human. |
payment.paid | An obligation is fully settled — by either path, with via saying which. |
payment.needs_review | The matcher declined to guess. |
charge.failed | A pull failed, carrying the classifier's own verdict rather than a restatement. |
Emission and delivery are separate on purpose
An event is written inside the same database transaction as the state change that caused it, so an event exists if and only if the thing happened. There is no window in which an obligation is overdue but no event records it, and none in which an event describes something that was rolled back. apps/worker/src/events.js:7
Delivery is the opposite kind of problem — a network call to someone else's server — so it is a separate, retried concern. Fan-out writes one delivery row per (event, destination), and the unique constraint makes it idempotent even if emission were somehow retried. apps/worker/src/events.js:33 The constraint is in the schema, not only in the code. packages/db/migrations/003_expected_payments.sql:237
Webhook signing
Each webhook carries Retainer-Signature: t=<unix>,v1=<hmac>, an HMAC-SHA256 over`${t}.${body}`. The timestamp is inside the signed material rather than beside it, which is what stops a captured delivery being replayed later. apps/worker/src/alerts.js:23
- Verification rejects a signature older than five minutes, before comparing anything. apps/worker/src/alerts.js:37
- The comparison is constant-time. apps/worker/src/alerts.js:40
- Every delivery carries
retainer-event-idso a receiver can dedupe: the same event must never become a second payment on their side. apps/worker/src/alerts.js:113 - The verifier is exported, so a receiver can use exactly the code that produced the signature. apps/worker/src/alerts.js:32
All four properties are asserted in both directions: a correct signature verifies, and the wrong secret, a tampered body and a replayed timestamp each fail. scripts/phase2-alerts-drills.mjs:85
Retry schedule
A failed delivery backs off over roughly a day before being abandoned, and the attempt count lives on the delivery row rather than in memory. apps/worker/src/alerts.js:17
BACKOFF_SECONDS = [60, 300, 1500, 7200, 21600, 86400]
1m 5m 25m 2h 6h 24h -> then deadThe drill proves retry actually happens by making the receiver reject the first two attempts and requiring the delivery to succeed anyway. scripts/phase2-alerts-drills.mjs:106
Email is behind a transport boundary
Email is one function with one shape, chosen by ALERT_EMAIL_TRANSPORT. Adding Resend or SMTP is one entry in a map and no caller changes. apps/worker/src/alerts.js:46
Today only file and console exist. That is stated rather than dressed up: no real email has ever been sent by this system. What is proven is the boundary — the delivery row records which transport handled it, and the file transport is asserted to have actually written the message.scripts/phase2-alerts-drills.mjs:114