Skip to content
All posts

Reading Stripe's ledger like an accountant

Balance transactions are the source of truth: their types map to journal entries, and refunds must reconcile to expectations, not merely exist.

FeeGuard13 min read

Reading Stripe's ledger like an accountant

Every cent that moves through a Stripe account leaves exactly one trace: a BalanceTransaction row. This guide teaches finance teams to treat those rows as the general ledger — the object anatomy, the type vocabulary translated into journal-entry terms, and reconciling refunds against what should have happened rather than merely confirming that something did.

Start from the BalanceTransaction object

If you run the close for a Connect platform, this object is yours before anyone else's — controllers and finance operations own its interpretation (the controller's view). The anatomy is small enough to memorize; the table gives each field its accounting read.

FieldHoldsAccounting read
amountSigned integer, smallest currency unitGross movement in or out
feePositive integerCosts attached to this line
netSigned integerCash impact; Stripe computes it as amount - fee
fee_detailsItemized arrayThe split behind fee
currencyISO codeNever aggregate across these
typeEnumThe chart-of-accounts hint
sourceOriginating object IDThe join key back to reality
created / available_onTimestampsEvent date versus recognition date
statuspending or availableWhich clock the money is on

All of it lives on the BalanceTransaction object, which also carries a reporting_category field grouping types for accounting use. The discipline that follows from the anatomy: book revenue from amount, book cash from net, and never book a payout lump as revenue — a payout is a movement of already-earned money, not income.

The arithmetic is self-evident but worth pinning. A $100.00 payment with a $3.20 processing fee posts as one transaction where amount = 10000¢ and fee = 320¢:

net = amount - fee = 10000 - 320 = 9680   →   $96.80 reaches the balance

One object, three numbers, and each answers a different question: what was billed, what it cost, what settled.

Two clocks on the same money

Every transaction exists on two timestamps. created records when the event happened; available_on records when its net becomes usable, because card payments land in pending first and roll onto the available balance on a rolling schedule — typically about two business days, varying by country and risk profile. Payouts draw only from available funds (payouts), and both balances exist per account on Connect (account balances). The balance.available event fires at the moment of the transition if you want it pushed rather than polled.

An illustrative timeline, dates assumed for the example:

DayLedger eventMoney state
MonCharge created, available_on = WedPending
WedBecomes availableUsable
FriPayout created against availableLeaving
Next MonBank credit appearsReconciled

This clockwork explains most bank-recognition mismatches. The payout that hits the bank next Monday contains sales from the previous week; matching it against Monday's invoices fails by construction. Match payouts to bank lines by payout amount and arrival date, recognize revenue per your policy on the sales themselves, and treat available_on as the recognition boundary between the two ledgers. When bank reconciliation disagrees with Stripe, timing is the default hypothesis and error the exception worth proving.

The type vocabulary, translated

The type enum is the closest thing Stripe has to a chart of accounts, and the table below translates the commonly seen values into journal-entry terms. Grouped by role:

GroupTypesJournal-entry meaning
Revenue sidecharge, paymentGross sales in
Revenue sideapplication_feePlatform commission income on Connect charges
Contrarefund, payment_refundSales returns
Contraapplication_fee_refundCommission given back to the seller
ContraadjustmentPost-settlement corrections; investigate each one
Movement outtransfer, payoutFunds to connected accounts; funds to bank
Movement failed or returnedpayout_cancel, payout_failure, transfer_refundMovements that bounced; a reversal returning transferred funds
Movement intopupManually added funds
Stripe-side feesstripe_fee, stripe_fx_feeProcessing and conversion costs
Connect machineryreserve_transaction, reserved_funds, connect_collection_transferRisk holds; aged-negative collection

Treat the table as a working subset. The authoritative enumeration, with a sentence on what each value represents, sits on Stripe's balance transaction types reference, and rare values appear only on some accounts — an unfamiliar type is a prompt to look it up, not to guess.

Two entries deserve special respect in month-end review. adjustment means money moved after the fact for reasons ranging from dispute outcomes to corrections, and each one should trace to a cause you can name. connect_collection_transfer means Stripe reached into platform reserves to zero out an aged negative connected-account balance — rare, material, and never a surprise if the negative balances were being watched.

One net, many GL lines: fee_details

A single net rarely maps to a single general-ledger line, and fee_details is where the decomposition happens. This is a seller-side direct charge as the connected account sees it — $100.00 sale, standard US pricing assumption of 2.9% + $0.30, $10.00 application fee:

{
  "object": "balance_transaction",
  "amount": 10000,
  "fee": 1320,
  "net": 8680,
  "currency": "usd",
  "type": "charge",
  "fee_details": [
    { "description": "Stripe processing fee", "amount": 320, "type": "stripe_fee" },
    { "description": "Application fee", "amount": 1000, "type": "application_fee" }
  ]
}

Check the arithmetic: fee = 320 + 1000 = 1320, and net = 10000 − 1320 = 8680. One ledger row, three GL destinations; the table shows how a controller might map it:

GL lineAmount
Revenue (gross)$100.00
Payment processing expense$3.20
Platform commission expense$10.00
Net settled$86.80

With the check that 100.00 − 3.20 − 10.00 = 86.80. The platform side of the same economy splits across two transactions instead: a destination-charge sale nets $96.80 after only the Stripe fee, and the transfer to the seller posts separately at −$90.00, leaving margin of 96.80 − 90.00 = $6.80. The commission itself is an ApplicationFee object with its own amount, amount_refunded, and balance_transaction fields (application fee object) — the fields every fee-reconciliation check eventually reads.

Reconstructing a refund from three ledger lines

Refund flags state intent; the ledger states execution. Reading the cluster of transactions around a refund tells you which one you actually got.

Take the canonical destination charge: $100.00 sale, $90.00 transfer, $10.00 application fee, platform margin $6.80. Now refund it in full with both reverse_transfer=true and refund_application_fee=true. Three ledger rows appear:

LineAmountTypeSource points at
1−10000¢refundThe refund object
2+9000¢transfer_refundThe original transfer
3−1000¢application_fee_refundThe fee refund

Sum the cluster: −10000 + 9000 − 1000 = −2000, so the refund-week cash effect is −$20.00. Stack it on the sale-time margin of $6.80 and the platform's lifetime position is 6.80 − 20.00 = −$13.20 while the seller ends up +$10.00 — the cluster reveals that flagging everything true over-compensates the seller on destination charges, because fee refunds compensate sellers, never buyers.

Now the default signature: same refund, no flags. The ledger shows a single bare row — −10000¢ typed refund — and nothing else. Lifetime position: 6.80 − 100.00 = −$93.20, seller untouched at +$90.00. One line versus three lines is the entire difference between a deliberate unwind and money left on the table.

That contrast is automatable without any judgment. For each refund entry, expect siblings proportional to the refund ratio: a reversal near round(refund_ratio × transfer_amount) and a fee refund near round(refund_ratio × fee_amount). Missing siblings are findings. Present siblings are policy, working as designed. The ledger answers not just "did we refund" but "did the refund do everything our policy promised."

Tying the ledger to the bank

Three bridges connect the Stripe ledger to external reality, and each has a failure mode worth instrumenting.

Payouts bridge to bank statements. Every payout groups the transactions inside it, and Stripe's reporting offers itemized balance-change and payout-reconciliation report types built for exactly this match (Stripe reports). Match by amount and arrival date; investigate residuals rather than forcing them.

Failed legs leave their own entries. A payout that bounces produces payout_failure activity and a payout.failed event; a refund that cannot reach the card reverses course and returns funds to your balance within up to about 30 days, exposed through failure_balance_transaction on the refund (refunds). The ledger type refund_failure marks the round trip. Neither failure closes anything until the return leg lands.

Disputes reinstate as well as debit. A won case returns the held funds, announced by the charge.dispute.funds_reinstated event, with the corresponding positive entries landing on the ledger (Connect disputes). Book them against the original reserve, not as new income.

Keeping all of this queryable year-round is a pipeline decision, not a spreadsheet one; syncing Stripe data into a warehouse makes every assertion in the next section a scheduled query (BigQuery sync), while export-driven teams can still systematize the CSV path (recovery from Excel exports).

Assertions worth automating

Three classes of assertion turn the ledger from a record into a control. Run all three per currency, per day.

The identity test proves completeness: opening available balance plus all non-payout activity minus payout totals must equal the closing available balance. In code:

function closesBalanced(
  txs: Stripe.BalanceTransaction[],
  openingAvailable: number,
  payoutTotal: number,
  closingAvailable: number,
): boolean {
  const activity = txs.reduce(
    (sum, t) => (t.type === "payout" ? sum : sum + t.amount),
    0,
  );
  return openingAvailable + activity - payoutTotal === closingAvailable;
}

A false result means a missing or duplicated transaction — reconcile before anything else that day.

The expectation test proves correctness, not just completeness: every refund cluster must satisfy the proportional formulas — reversal near ratio times transfer, fee refund near ratio times fee — and violations are findings regardless of how cleanly the identity test passes. An internally consistent ledger happily records a policy executed badly.

The anomaly test proves nothing is hiding: any adjustment, reserve_transaction, or connect_collection_transfer without a filed reason gets a ticket before the close finishes. These types are legitimate; unexplained, they are how surprises enter the books quietly.

Teams running this manually today can compare their process against the month-end close pattern or the spreadsheet-based workflow as further reading — the assertions above are the part worth keeping no matter which tool executes them.

Corrections you will meet: failures, reinstatements, reserves

A clean month still contains entries that exist purely to correct other entries. Reading them fast is most of the accountant's edge.

Failed refunds come back to you. When a refund cannot reach the customer's instrument, the funds return to the balance that funded it — within up to roughly 30 days of the attempt (refunds) — and the Refund object carries status: failed with a failure_balance_transaction pointing at the return leg. In the ledger this appears as a fresh credit where you expected nothing; matching it against its failed refund prevents double-counting both the original refund and the return as activity.

Won disputes reinstate funds. A dispute resolved in your favor triggers charge.dispute.funds_reinstated, and the associated balance transaction restores what the freeze or debit had taken, including the partially-refunded-payment nuances Stripe documents. The reconciliation habit: every dispute.created debit should eventually pair with either a funds_reinstated credit or a permanent-loss entry, never neither.

Reserve machinery lives on the platform side. reserve_transaction entries appear when Stripe holds platform balance against a negative connected account and again when it releases that hold; connect_collection_transfer appears when a 180-day-old connected negative is zeroed out of your reserves (account balances). Neither is an error; both are movements of YOUR money triggered by someone else's account, which makes them exactly the lines worth ticketing on sight.

And then there is adjustment — the catch-all. Genuine uses exist, but an unexplained adjustment without a filed reason is how surprises enter books quietly. Treat any adjustment you cannot narrate in one sentence as an open item until closed.

Balances per currency

Multi-currency platforms read more than one column. The retrieve-balance call returns pending and available figures for each currency the account holds, so a USD-settled platform accumulating EUR application fees from cross-border destination charges sees separate EUR lines waiting there (retrieve balance). Reconcile per currency before converting anything; mixing them at the totals level hides exactly the conversion deltas that FX-focused reviews go hunting for.

curl https://api.stripe.com/v1/balance \
  -u "sk_live_...:"

Each currency block answers independently: payouts draw from their own currency's available figure, refunds debit their own charge currency, and no automatic netting crosses the columns.

Frequently asked questions

Is net what lands in the bank?

Not by itself. Each transaction's net aggregates into the available balance, and the bank receives payouts — lumps of many nets. The bank line matches the payout amount, and the individual nets reconcile inside it. Treating any single transaction's net as a bank posting is the fastest way to break reconciliation.

Which date should I book a sale on?

Pick a policy and apply it uniformly. Booking on created aligns revenue with customer activity; available_on marks when the cash became usable and drives the bank-side picture. Problems come from mixing the two conventions mid-year, not from either choice itself.

Where do currency conversions show up?

Inside the same balance transactions: when money converts, the transaction carries an exchange_rate field explaining exactly how much landed (BalanceTransaction object). Refunds convert at the live rate on refund day regardless of any quote locked at purchase time, and the original conversion fee is not returned (FX quotes) — both facts show up as spread between paired entries.

Do connected accounts see the same vocabulary?

Yes — every account on Connect keeps its own ledger using the identical type enum, seen from its own side. The platform watches transfer rows leave; the seller watches them arrive. Reconciling the two perspectives against each other is precisely how transfer-reversal gaps get found.

Where do currency conversions show up?

Inside the balance transactions themselves: when a charge's presentment currency differs from settlement, the transaction records the amounts and fees that produced the settled figure, so the conversion cost is embedded in fee/net rather than appearing as its own type. Comparing linked transactions across currencies is how the spread becomes visible — and why reconciling per currency first, then converting totals, keeps arithmetic honest.

Check your own last 90 days

Reading a ledger well tells you what happened; pairing every refund against what should have happened tells you what is missing. FeeGuard exists because that pairing runs silently on every refund your platform issues. The free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreturned application fee, and uncovered dispute loss with the amounts attached. You get the answer first; ongoing monitoring stays optional afterward.

Run the free 90-day audit.

FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.