Expected payments
The obligation is the top-level object, not the charge. A charge is one way to satisfy it; a matched incoming transfer is the other. Both are first-class, which is what gives overdue detection a single path instead of two.
Phase 1 made the charge the top-level object. That was the wrong shape, and the reason is visible the moment a payer cannot use a spend permission at all: there is nothing left to represent the money the merchant is still owed. Anexpected_payment is that thing — an amount, a token, a due date, a state, and a fulfilment method saying how it is meant to be satisfied. packages/db/migrations/003_expected_payments.sql:70
Why one abstraction rather than two systems
The tempting design is two parallel systems: a charge pipeline with its own failure handling, and a transfer watcher with its own. It looks simpler until you ask the only question the merchant actually has — am I owed money that has not arrived?— and discover the answer has to be assembled from two places that disagree about what “late” means.
Making the obligation primary collapses that. A pull whose charge failed terminally and a watch payment that never arrived are the same fact: an expected payment past its due date with an outstanding balance. One sweep finds both, and one alert describes both. apps/worker/src/sweep.js:11
UNIQUE (permission_id, period_start) guarantee; they gained a parent. packages/db/migrations/003_expected_payments.sql:244The six states
Terminal states are terminal: paid and void are never left. Everything else can still move.packages/db/migrations/003_expected_payments.sql:59
| State | Meaning | How it is entered |
|---|---|---|
upcoming | The due date is further away than the lead time. | Created this way. |
due | Inside the lead time, not yet satisfied. | The sweep moves it once now() ≥ due_date − lead_time_seconds. apps/worker/src/sweep.js:25 |
overdue | Past the due date plus grace, still not satisfied. | The sweep moves it once now() > due_date + grace_seconds, and emits payment.overdue. apps/worker/src/sweep.js:32 |
partially_paid | 0 < settled < expected, every unit of it confirmed on-chain. | A match applied less than the full amount, or a charge confirmed short. apps/worker/src/matcher.js:144 |
paid | Settled at or above the expected amount. Terminal. | Either the reconciler confirmed the charge behind it, or a matched transfer covered it. apps/worker/src/sweep.js:73 |
void | Cancelled by the merchant. Terminal. | Not reachable from the dashboard; the review actions cannot void an obligation. |
lead_time_seconds defaults to three days and grace_secondsto zero, both per-obligation rather than global, because “when should I be told” is a merchant policy and not a property of the engine.packages/db/migrations/003_expected_payments.sql:83
Fulfilment is declared, not inferred
An obligation says up front how it is meant to be satisfied — pull or watch — and the schema refuses the incoherent case: a pull without a permission cannot be stored at all.packages/db/migrations/003_expected_payments.sql:94
CONSTRAINT expected_payments_pull_needs_permission
CHECK (fulfilment <> 'pull' OR permission_id IS NOT NULL)The declaration is not a filter on what may satisfy it. A watch obligation is still settled by a transfer that had to be reviewed by hand, and a pull obligation whose charge failed is still visible to the matcher as something a transfer could pay — just never automatically. What the declaration buys is the ability to say, on the dashboard and in an alert, how this payment was supposed to arrive, which is most of what a merchant needs to know when it has not.
The double-settlement guard
Two systems settling one obligation is where double-counting comes from, so the matcher's candidate query excludes any obligation that already has a charge in_flight or confirmed against it. Such a payment is stillreviewable — a human can look at it — it simply can never be matched automatically.apps/worker/src/matcher.js:38
The database backs the same rule from the other side: a charge is unique per expected payment, so the engine cannot enqueue two pulls for one obligation even if asked twice. packages/db/migrations/003_expected_payments.sql:290
charges.state = 'confirmed', and this layer reads that verdict rather than forming its own.apps/worker/src/sweep.js:54 On the watch side the equivalent is three confirmations before a transfer is eligible to be matched at all. See watch mode.Seeing it
- The dashboard's expected payments page lists every obligation with its outstanding balance and how it is meant to be satisfied. apps/web/app/dashboard/expected/page.tsx:15
- Evidence is a link to the confirming transaction for a pull, or the count of matched transfers for a watch. apps/web/app/dashboard/expected/page.tsx:74
- The loader is database-only: nothing on that page is fetched from the chain at render time. apps/web/lib/dashboard-data.ts:250