13 min read

Model Every Payment Rail in Your Ledger, Not in Your Provider's Balance

Three payment rails flowing into one ledger that records fees, updates and money in flight, with each provider's own balance left unconnected
On this page

When your app uses more than one payment provider (say one for bank transfers coming in, one for card payments and one for sending money out), some of those providers will show you a balance for each customer. It's tempting to use that number as the customer's balance.

But each provider only sees its own part. Say one of your customers, Adam, uses all three. The bank-transfer provider doesn't know about Adam's card payments. The card provider doesn't know what Adam sent out. So no single provider's number is Adam's balance.

Your ledger can give you Adam's real balance, because it sees every provider in one place. For that to work, it has to record three things from each provider:

  1. the fees the provider takes
  2. every update the provider sends you, like "a payment arrived", "a transfer finished" or "a payment was refunded"
  3. money that's on its way but hasn't arrived or left yet

This article shows how to record each of those.

The code uses Blnk Core, our open-source ledger, but the same approach works if you build the ledger yourself. It also assumes you already keep your ledger separate from your payment providers.

Every rail gets the same two accounts

Each provider moves money over a network: bank transfers, card payments, instant payouts. We'll call each of those a rail. A rail tells you what happened on it: a payment arrived, a transfer finished, a fee was charged. Your ledger decides what that means for the customer.

Here's what that looks like in your ledger.

Each customer has one balance: what you owe them. Money from every rail ends up there, but only your ledger changes it. No provider's number is ever written into it.

Then each rail gets two accounts of its own, whichever provider runs it:

  • A clearing account. What that provider is holding for you, or owes you. Think of it as the provider's tab in your books.
  • A fee account. What that rail costs you, kept apart from customer money.

Some providers also show you their own balance for each customer. Don't copy that number into your ledger. Use it to check your ledger instead. We'll come back to how at the end.

Take three kinds of rail. Each one handles fees, timing and balances differently.

Virtual accounts. The provider gives each customer an account number, and money arrives in it by bank transfer: ACH, SEPA, Faster Payments, NIP or Pix. The money is usually final once it arrives. The provider may charge you a fee for each transfer, taken out of the deposit before it pays you. Some of these providers also keep a balance for each customer.

Card payments. A card acquirer or processor takes the payment. It's captured first and paid out to you days later, minus a fee: a percentage plus a fixed amount. The customer can still dispute it for months. There's no balance per customer.

Instant payouts. You send money out over RTP, FedNow, Faster Payments or NIP. Each transfer stays pending until the provider confirms it, and sometimes the status is unknown after a timeout. The fee is charged per transfer, as a flat amount or a percentage, taken from money you've deposited with the provider in advance. There's no balance per customer here either.

So each rail reports its own way. Your ledger's job is to turn all of it into one balance for the customer.

Fees are their own transaction

Payment providers charge you for moving money. Often the fee doesn't arrive as a bill. The provider takes it out of money it's already holding for you, so your bank only ever sees what's left. Cards are a good example.

Say Adam pays you $100 by card. Two days later, the card company pays you $96.80. The missing $3.20 is its fee: 2.9% of $100 is $2.90, plus $0.30.

So there are three numbers:

  • $100.00, what Adam paid. This is what Adam should see.
  • $3.20, the fee. This is what the payment cost you.
  • $96.80, what actually reached your bank.

If you only record the $96.80 when the money lands, Adam sees $96.80 instead of $100. The $3.20 isn't anywhere in your books. It only exists in the card company's report.

So record them as separate transactions. A transaction moves an amount from one account to another, and it's the only way a balance in your ledger changes:

  1. Credit Adam the full $100

    When the card company tells you the payment went through, credit Adam the full $100 from the card clearing account. The card company now owes you $100.

    Request
    const payment = await blnk.Transactions.create({
      amount: 100,
      precision: 100,
      currency: "USD",
      reference: `card_${chargeId}`,
      source: "@CardClearing",
      destination: customerBalanceId,
      allow_overdraft: true,
      meta_data: { rail: "card", provider_ref: chargeId, event: "charge.captured" },
    });
    201 Created
    {
      "amount": 100,
      "precision": 100,
      "transaction_id": "txn_3a7f2c19-8d4e-4b6a-9c21-5e0f7b1d2a64",
      "reference": "card_ch_3PqX7b2eZvKYlo2C",
      "source": "@CardClearing",
      "destination": "bln_4c2e9a7d-1f3b-4e8a-b6d5-0a9c8e7f6d21",
      "currency": "USD",
      "status": "QUEUED",
      "allow_overdraft": true
    }
  2. Settle it when the payout lands

    When the payout lands, settle that $100 from two places: $3.20 from the fee account, and $96.80 from the money that reached your bank. Blnk does this in one split transaction, with each source paying its share.

    Request
    const settlement = await blnk.Transactions.create({
      amount: 100,
      precision: 100,
      currency: "USD",
      reference: `card_payout_${payoutId}`,
      sources: [
        { identifier: "@CardFees", distribution: "3.20" },
        { identifier: "@CardSettlement", distribution: "left" },
      ],
      destination: "@CardClearing",
      allow_overdraft: true,
      meta_data: { rail: "card", provider_ref: payoutId, fee: "3.20" },
    });
    201 Created
    {
      "amount": 100,
      "precision": 100,
      "transaction_id": "txn_8e1b4d72-3c9a-4f05-a7e6-2d8c1f9b0e53",
      "reference": "card_payout_po_1PqY2c2eZvKYlo2C",
      "destination": "@CardClearing",
      "currency": "USD",
      "status": "QUEUED",
      "allow_overdraft": true,
      "sources": [
        {
          "identifier": "@CardFees",
          "distribution": "3.20",
          "transaction_id": "txn_5d2a9c8e-7b14-4f63-8a0d-e1c6b3f7a925"
        },
        {
          "identifier": "@CardSettlement",
          "distribution": "left",
          "transaction_id": "txn_c7f0e3a1-2b9d-4e58-b146-9a5d8c2e1f07"
        }
      ]
    }

    Blnk records one transaction for each source, so the fee and the cash each have their own ID.

    In this example the payout covers just this one $100 payment. In practice, one payout often covers many payments, so you record the whole payout as one transaction when it lands, with its total fees and total cash. That's why the reference is the payout's ID, not the payment's.

After step 1, Adam has $100 and @CardClearing shows −$100.00: the card company owes you $100.

Here's where every balance ends up after step 2:

BalanceAfter the payout
Adam$100.00
@CardClearing$0.00 (the card company has paid in full)
@CardFees−$3.20 (this payment cost you $3.20)
@CardSettlement−$96.80 (the cash that reached your bank)

Balances that start with @ are your own internal accounts. Because the code sets allow_overdraft: true, they can go below zero. Here a minus sign doesn't mean you lost money. It means that account has sent out more than it received. @CardSettlement shows −$96.80 because $96.80 of real cash reached your bank and paid off most of what the card company owed. @CardFees paid the other $3.20. Adam's $100 doesn't change when the fee shows up.

The other two rails work the same way: the provider's fee gets its own transaction, from that rail's fee account. A virtual-account provider may charge you for each transfer that comes in, taking it out of the deposit before it pays you. Record that from @VAFees. An instant-payout provider charges for each transfer you send, taken from the balance you keep with it. Some charge a flat amount and some a percentage (Stripe's Instant Payouts, for example, cost 1% to 1.5%). Record that from @PayoutFees.

Passing a fee on to the customer is a separate choice. If you make it, record it as its own transaction from the customer's balance, so the customer can see the charge.

On every rail, the amount, the fee and what you actually received are three numbers you can find in your ledger, without opening the provider's report.

Every rail maps to the same operations

Two providers can tell you about the same event under different names. Take a card payment being captured: Stripe calls it charge.captured, and Adyen calls it CAPTURE.

Now say your Stripe code and your Adyen code each decide for themselves what a capture does to Adam's balance. One might make the $100 spendable straight away, and the other might wait until the card company pays you. The same card payment would then mean something different depending on which provider it came through. Add more rails, each with its own updates, and it gets harder to say what any balance means.

So decide it once, in one place. For each rail, write a short lookup, a map, that pairs every update name with one of five things the ledger can do. For cards, Stripe's charge.captured and Adyen's CAPTURE would both point to the same one. Nothing else in your code decides what an update does to a balance:

Ledger operationWhat it means
CreditMoney arrived and is final
HoldMoney is on its way: visible, but the customer can't spend it yet
CommitThe rail confirmed a hold
VoidThe rail cancelled a hold
ReverseMoney that was final came back

The map lives in one place. Adding a rail means writing a new map, not changing how balances work:

rails.ts
// rails.ts: the only place that knows what each provider's events mean
const RAILS = {
  virtual_account: {
    clearing: "@VAClearing",
    events: { "transfer.received": "credit", "transfer.recalled": "reverse" },
  },
  card: {
    clearing: "@CardClearing",
    events: {
      "payment.authorized": "hold",
      "charge.captured": "commit", // Stripe
      CAPTURE: "commit", // Adyen
      "authorization.expired": "void",
      "refund.created": "reverse",
      "dispute.lost": "reverse",
    },
  },
  instant_payout: {
    clearing: "@PayoutClearing",
    events: {
      "payout.initiated": "hold",
      "payout.succeeded": "commit",
      "payout.failed": "void",
      "payout.returned": "reverse",
    },
  },
} as const;

The update names in the map are examples. Every provider names them differently, which is exactly why the map exists.

Anything specific to a rail goes in metadata on the transaction, not in new columns: which rail it was, the provider's reference, and the provider's own name for the update. The transactions above already carry rail and provider_ref, so you can pull up every transaction from one rail, or one provider reference, with metadata search.

When a provider renames an update, you change one line in its map. Nothing about your balances changes. The provider still sends the updates, but your ledger decides what each one does to the customer's balance.

Money in flight is a hold

Not everything a provider tells you means the money has finished moving. Sometimes it only means the money has started: a transfer has been sent but hasn't arrived, or a card payment has been approved but not yet captured. Your ledger needs a way to record that in-between state too.

Say Adam has $100 and sends $40 out. The transfer takes a little while to finish. Until it does, that $40 isn't Adam's to spend any more, but it hasn't left yet either. That's money in flight.

In your ledger, money in flight is a hold. In Blnk it's an inflight transaction. The $40 is recorded and visible, but Adam can't spend it, so the ledger shows $60 available. When the rail confirms the transfer, the hold is committed and the $40 is gone. If the rail says the transfer failed, the hold is voided and Adam can spend the $40 again.

Create the hold before you call the rail:

Request
// The customer sends $40 out. Hold it before you call the rail.
const hold = await blnk.Transactions.create({
  amount: 40,
  precision: 100,
  currency: "USD",
  reference: `payout_${payoutId}`,
  source: customerBalanceId,
  destination: "@PayoutClearing",
  inflight: true,
  skip_queue: true,
  meta_data: { rail: "instant_payout", provider_ref: payoutId },
});
201 Created
{
  "amount": 40,
  "transaction_id": "txn_6164573b-6cc8-45a4-ad2e-7b4ba6a60f7d",
  "status": "INFLIGHT",
  "inflight": true,
  "created_at": "2026-11-11T09:30:00Z"
}

Save the transaction_id. It's the ID you commit or void later. skip_queue: true applies the hold in the same request, so the response already says INFLIGHT.

Sometimes the rail doesn't answer. The request times out, and when you ask the provider, it says pending or unknown. Two shortcuts are tempting here, and both go wrong. You could copy that status into your records, but then the provider's answer is deciding Adam's balance, not your ledger. Or you could send the transfer again to be safe, but if the first one did go through, the money goes out twice.

Instead, keep the hold and check the transfer's status with the provider again, using the same reference. Treat the answer the way you'd treat the matching update: if the transfer succeeded, commit the hold; if it failed, void it. If it's still pending, keep the hold and check again later.

In Blnk, that looks like this. provider.getTransfer stands for whatever status call your provider offers, and its status names will differ too:

checkPayout.ts
// The payout timed out. Keep the hold and ask the provider what happened.
const transfer = await provider.getTransfer(reference); // the same reference you sent

if (transfer.status === "succeeded") {
  await blnk.Transactions.updateStatus(holdId, { status: "commit" });
} else if (transfer.status === "failed") {
  await blnk.Transactions.updateStatus(holdId, { status: "void" });
}
// Still pending or unknown: leave the hold as it is and check again later.

holdId is the transaction_id you saved when you created the hold. Store it next to the provider's reference so you can find the hold again.

Holds behave a little differently on each rail:

  • Card authorization. If a card payment is authorized but never captured, the authorization eventually expires. Give the hold an inflight_expiry_date to match, and Blnk voids it automatically on that date.
  • Instant payout. Don't let this hold expire on its own. If the rail later says the payout went through, an automatic void would already have given the money back to the customer. Keep the hold until the rail answers, and get alerted about holds that last longer than that rail normally takes.
  • Virtual account. Money arriving in the account is usually final, so it's a plain credit. If your provider holds incoming money for checks first, use a hold there too.

On every rail, what the customer can spend comes from the holds in your ledger, not from whatever status the provider shows that day.

Reconcile provider balances instead of copying them

So which provider's number is Adam's balance? None of them. Your ledger now records every provider's fees, updates and money on its way, so Adam's real balance is the one in your ledger.

The providers' numbers still matter. They're how you check the ledger is right.

A provider's balance for Adam only covers what happened at that provider. A virtual-account provider knows about transfers into that account. It doesn't know about Adam's card payments or payouts on other rails. So compare it with the ledger's transactions for Adam tagged rail: "virtual_account". Those should match. If they don't, you have a specific difference to look into, not a number to copy.

Then check each rail's statement against your ledger, using the references you stored. Cards need two steps, because one payout covers many payments. First, match the bank line to the payout. Then match each payment in that payout to your ledger, using the payout report the card provider gives you.

Then put a balance monitor on each clearing account. If a provider owes you more than it normally would, you get a webhook straight away, instead of finding out at the end of the month.

When a provider and your ledger disagree now, you get a short list of real differences: a fee you didn't expect, a transfer still on hold, an update that never arrived. You fix each one by recording what actually happened. The provider's number is back to being a check on your ledger, not the customer's balance.

The next rail

With all of this in place, adding another provider is a small job. You give its rail a clearing account and a fee account, write a map from its updates to the same five ledger operations, and decide how long its money can stay on hold. Nothing about how customer balances work has to change.

That's the point of keeping it all in your ledger. A customer's balance means the same thing whichever rail the money came through, because no rail gets to decide it.

Your providers will keep showing you their own balances for each customer, and that's fine. Just don't treat any of them as the customer's balance. Your ledger records what actually happened, and the providers' numbers are how you check it.

To try this against a running ledger, start with the Cloud Sandbox.

Related posts