Where the money lifecycle lives. Owns the payment state machine, a double-entry ledger kept independently of the acquirer's, the saga that compensates for unknown outcomes, a transactional outbox, and reconciliation.
This service, in three tiers
This service, inside the wider platform
Runs on port 4200. Called by the gateway; calls the acquirer.
Written as an explicit transition table rather than scattered status checks, so an illegal transition is caught in one place and the legal ones read as a specification.
PENDING ──────▶ AUTHORIZED ──────▶ PARTIALLY_CAPTURED ──▶ CAPTURED ──▶ SETTLED
│ │ │ │ │
├──▶ DECLINED ├──▶ REVERSED └──────────────────┴──────────┴──▶ REFUNDED
└──▶ FAILED └──▶ FAILED
PENDING → CAPTURED is rejected: capturing before authorising would move money
against a hold that does not exist. AUTHORIZED → SETTLED is rejected too, settling
without capturing settles money that never moved.
| Account | Type |
|---|---|
ASSETS:ACQUIRER_RECEIVABLE |
Asset, owed to us by our own acquirer (rail A) |
ASSETS:PSP_RECEIVABLE |
Asset, owed to us by Stripe (rail B) |
ASSETS:SETTLEMENT_CASH |
Asset, received |
LIABILITIES:MERCHANT_PAYABLE |
Liability, owed to the merchant |
REVENUE:PROCESSING_FEES |
Revenue, our markup |
On a capture of 125,000 with 938 interchange and 1,468 markup:
Dr ACQUIRER_RECEIVABLE 124,062 (gross less the interchange the acquirer keeps)
Cr MERCHANT_PAYABLE 122,594
Cr PROCESSING_REVENUE 1,468
Interchange has no account here on purpose. This acquirer nets it before paying
us, so those funds never pass through our books and cannot be an expense we incur.
Booking it anyway is what broke the first version of this ledger, entries summed to
938 instead of zero and the balance assertion refused the transaction. A processor on
gross settlement terms would model it differently: debit the full gross and carry an
INTERCHANGE_PAYABLE cleared at settlement. Equally correct, and wrong here only
because it would not describe what this acquirer does.
POST /api/v1/external-capture records a payment Stripe already authorised and
captured. We never touched the card and never called our acquirer, but the merchant
is still our merchant, we still owe them money, and we still take our markup. So the
processor keeps the books either way.
The postings are structurally identical, with Stripe's fee playing the part interchange plays on rail A:
Dr PSP_RECEIVABLE gross - stripeFee
Cr MERCHANT_PAYABLE gross - stripeFee - markup
Cr PROCESSING_REVENUE markup
Two disciplines that matter:
pspFee is what Stripe actually charged, read back from its balance transaction -
not our estimate of Stripe's pricing. Booking an estimate is how a reported margin
quietly drifts from the real one.
PSP_RECEIVABLE is a separate account, not netted into ACQUIRER_RECEIVABLE.
Different counterparties, different settlement schedules. Netting them makes "how
much is Stripe holding for us" unanswerable and makes both rails unreconcilable.
Authorisation crosses a service boundary that cannot join our transaction, so:
- Record
PENDINGbefore calling out. A crash after this leaves a row a sweeper can reconcile; calling first would leave an authorisation nothing knows about. - Call the acquirer.
- Record the outcome, and enqueue the event, in one transaction.
Step 2 returning nothing is the case that matters. A timeout is not a failure. It is an unknown outcome. Retrying could place a second hold on the cardholder's card; assuming failure could strand a real one. So the processor compensates: it sends a reversal, which is safe whether or not a hold exists.
The compensation is itself best-effort. If the reversal cannot be delivered, the
payment stays flagged outcomeUnknown rather than throwing away the record of it.
Fetches the acquirer's settlement file and compares it against our own captures per transaction, matching on a reference both sides agree on. Comparing totals alone would let two offsetting errors hide inside a matching sum.
Scoped to rail A. AKM Bank's file describes what AKM Bank acquired; a Stripe-rail capture is money Stripe owes us. Comparing the two reports every Stripe payment as a break. This project shipped exactly that bug when the second rail landed. Reconciliation is always per-counterparty, and rail B reconciles against Stripe's payout report instead.
Breaks are classified, because "the numbers don't match" is not actionable:
| Type | Meaning |
|---|---|
MISSING_AT_ACQUIRER |
We captured it; their file does not mention it. We expect funds they have not agreed to send. |
MISSING_AT_PROCESSOR |
They settled something we have no capture for. The more alarming direction. |
AMOUNT_MISMATCH |
Both have it, at different amounts. |
FEE_MISMATCH |
Interchange disagrees; our margin is wrong by the difference. |
Only matched captures are marked settled. A break stays open, closing it silently is how a discrepancy becomes permanent and unexplainable.
| Method | Path | Auth |
|---|---|---|
POST |
/api/v1/authorize |
HMAC, idempotent on gatewayRef |
POST |
/api/v1/capture |
HMAC, idempotent on gatewayRef |
POST |
/api/v1/external-capture |
HMAC, records a Stripe-rail capture |
POST |
/api/v1/reconcile |
HMAC |
POST |
/api/v1/outbox/drain |
HMAC |
GET |
/api/v1/ledger · /api/v1/trace/:id · /api/v1/health |
open |
cp .env.example .env
npm install && npm run db:push && npm run db:seed && npm run dev