Retainer
Payments & matching

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

TransitionConditionEmits
upcoming → dueInside the lead time: now() ≥ due_date − lead_time_secondsnothing — being due is not news
due | partially_paid → overduePast the due date plus grace: now() > due_date + grace_secondspayment.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

Selective, not blanket
A sweep that moved everything would be indistinguishable from a broken one. The drill therefore asserts the negative case as well: an obligation beyond its lead time must stay upcoming, and no overdue event may be emitted for it.scripts/phase2-alerts-drills.mjs:69

The six event types

apps/worker/src/events.js:11

EventWhen
payment.overdueThe sweep moved an obligation past its due date.
payment.receivedAn incoming transfer was indexed and is not internal.
payment.matchedA transfer was attributed to an obligation, automatically or by a human.
payment.paidAn obligation is fully settled — by either path, with via saying which.
payment.needs_reviewThe matcher declined to guess.
charge.failedA 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

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

apps/worker/src/alerts.js
BACKOFF_SECONDS = [60, 300, 1500, 7200, 21600, 86400]
                    1m   5m   25m   2h    6h     24h   -> then dead

The 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

What is not built
There is no alert-destination management interface; destinations are added programmatically.apps/worker/src/alerts.js:173 There is no digest or quiet-hours logic, and no per-merchant routing beyond an optional event-type filter on a destination. See current limitations.