Skip to content

Repository files navigation

AKM Clearing

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

Three tiers: the gateway calling in, the processor service, and its own database.

This service, inside the wider platform

Where the processor sits in the four-service platform.

Runs on port 4200. Called by the gateway; calls the acquirer.

The state machine

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.

Chart of accounts

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.

The second rail

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.

The saga

Authorisation crosses a service boundary that cannot join our transaction, so:

  1. Record PENDING before calling out. A crash after this leaves a row a sweeper can reconcile; calling first would leave an authorisation nothing knows about.
  2. Call the acquirer.
  3. 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.

Reconciliation

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.

Endpoints

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

Run it

cp .env.example .env
npm install && npm run db:push && npm run db:seed && npm run dev

About

Authorization and settlement engine behind the gateway. Auth, capture, void, refund, and partial-capture state machine on a double-entry ledger, with ISO 8583 style messaging to the acquirer and end-of-day reconciliation. TypeScript, Node, PostgreSQL.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages