Retainer
Payments & matching

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

Retainer system architectureAn expected payment is satisfied by one of two paths. In the pull path, a customer's Base Account, or a smart account their wallet owns, signs a spend permission once. Retainer's worker calls Retainer's SpendRouter, the router calls the SpendPermissionManager, and the manager moves USDC from the customer's wallet to the router, which forwards the full value to the merchant treasury in the same transaction. A confirmed charge settles the obligation. In the watch path, any payer, including a Safe multisig, sends USDC directly to the treasury; the worker indexes the transfer and a matched transfer settles the obligation. Retainer never holds the money on either path.PULL — SPEND PERMISSIONSpendPermissionManagerexecutes on the customer's walletCustomerBase Account, or wallet-ownedSpendRouterRetainer's own instancesigns a spend permission, oncespend()USDC, one atomic transactionforwards the full valueMerchant treasuryreceives on both pathsExpected paymentamount, due date, stateoverdue if neither path settles itsettled by a confirmed chargeor by a matched transferWATCH — INCOMING TRANSFERPayerSafe multisig, DAO, any walletplain USDC transferno permission exists, and none canRetainer workerholds no funds on either path — it signs transactions and reads the chain, never custodiescharger → classifies before spending gas, then calls spendAndRoute() · reconciler → sole writer of confirmedwatcher → indexes transfers at 3 confirmations · matcher → attributes, or defers to review · sweep → overdue + alertsspendAndRoute()watches
One obligation, two ways to satisfy it. The merchant's question is 'did I get paid', not 'did the charge succeed'.

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

The migration was a re-shaping, not a rewrite
Every one of the twelve existing charges became the fulfilment of a backfilled expected payment, keeping its transaction hashes and its idempotency key untouched. Charges did not lose their UNIQUE (permission_id, period_start) guarantee; they gained a parent. packages/db/migrations/003_expected_payments.sql:244

The 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

StateMeaningHow it is entered
upcomingThe due date is further away than the lead time.Created this way.
dueInside the lead time, not yet satisfied.The sweep moves it once now() ≥ due_date − lead_time_seconds. apps/worker/src/sweep.js:25
overduePast 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_paid0 < 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
paidSettled 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
voidCancelled 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

packages/db/migrations/003_expected_payments.sql
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

Settlement still comes from the chain, never from intent
Nothing here marks an obligation paid because a charge was sent. The reconciler remains the sole writer ofcharges.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