DEV Community

Jayant Chaudhary for eProxim

Posted on AI-assisted

Posting Stripe Payouts to a Ledger in TypeScript, to the Cent

One of our clients, a direct-to-consumer apparel brand, dreaded the end of every month. Somewhere between 1% and 2% of its bank deposits matched nothing in the ledger. So, at the close of every month, there was a high-pressure stretch in which finance went through transactions and deposits by hand until the books agreed. This had been going on for over a year, and it was getting worse – for the best possible reason. The brand had found real success online through its BigCommerce store, and every month pushed more orders through the same broken process.

The store used Stripe as its payment gateway. When we looked at the integration, the cause turned out to be simple. Every time an order was paid for, the integration posted the successful charge to the ledger as cash received. To be honest, I understand why people build it that way. A charge succeeds, the order is marked paid, revenue is booked, and the accounting system is told the money has arrived. All of that is true about the customer. None of it is true about the cash, and the gap shows up weeks later, as deposits in the bank that nobody can explain.

Stripe actually keeps track of two different things. PaymentIntents, Charges and the webhooks that go with them tell you about the customer – who paid, who got a refund, who disputed a charge. Balance transactions and payouts tell you about the money – what moved through your Stripe balance, and what eventually landed in your bank account. A ledger needs both, tied together. In this article, I'll cover the money side, in TypeScript, with the official stripe Node package and PostgreSQL. I have written separately about webhooks, disputes and the business decisions that go into a production Stripe integration beyond checkout, so I will stay close to the code here.

Why a charge is not cash

Here is what actually happens to a $100.00 charge. Stripe takes its fee first. What's left sits in your Stripe balance as pending, until it becomes available. Then, at some later date, Stripe sweeps it into a payout, together with everything else that became available in the same window – minus any refunds, dispute debits and standalone fees that landed in that window too. (If the customer paid in euros and your account settles in dollars, there's a currency conversion in there as well.) So the deposit your bank sees is a net figure for dozens or hundreds of transactions, and it will almost never equal any single charge.

Every one of those steps creates a BalanceTransaction. Each one has a gross amount (in the smallest unit of your settlement currency – cents, if you settle in dollars), a fee, a net that equals amount - fee, a fee_details breakdown, a source that points to the charge, refund or dispute behind it, and a reporting_category. Use reporting_category to classify transactions. Stripe recommends it for accounting over the raw type, and once you've looked at the raw types, you'll see why. Card and non-card charges both become charge. The catch-all type=adjustment gets broken out into dispute, dispute_reversal and so on. And partial-capture reversals get a category of their own, so they don't get mistaken for refunds.

The payout is what your bank sees, so that's what the ledger should reconcile against. When the sale happens, you debit a Stripe clearing account and credit receivables. When the payout arrives, you clear that account and debit the bank. If the integration does both, the balance in the clearing account is, at any moment, exactly what Stripe owes you – a number finance can actually check.

Your own outbound calls need idempotency keys

Before any of this can work, the customer side has to be clean, and the most common way it gets messed up is a refund that goes out twice, because a request timed out and was retried automatically. Stripe accepts an Idempotency-Key header on every v1 POST, and the Node SDK exposes it as the idempotencyKey request option. The key has to identify the business operation – this refund, for this order – and not the individual HTTP attempt. A key generated fresh for each attempt protects you from nothing. So, write the refund request to your own database first, and build the key from that row.

import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

interface RefundRequest {
  id: string;              // primary key of our refund_requests row, created first
  orderId: string;
  paymentIntentId: string;
  amountMinor: number;
}

export async function issueRefund(req: RefundRequest): Promise<Stripe.Refund> {
  return stripe.refunds.create(
    {
      payment_intent: req.paymentIntentId,
      amount: req.amountMinor,
      metadata: { order_id: req.orderId, refund_request_id: req.id },
    },
    { idempotencyKey: `refund:${req.id}` },
  );
}
Enter fullscreen mode Exit fullscreen mode

Stripe checks the parameters that come in with a reused key, so if a retry somehow carries a different amount, it fails with an error, which is exactly what you want. The catch is that Stripe can prune keys once they're 24 hours old, after which the same key is treated as a brand-new request. A retry a few minutes later is safe. A "retry failed refunds" job that someone runs on Monday for Friday's failures is not – unless it first checks your own row to see whether a Stripe refund ID is already there. I think of the key as protection against a flaky network, and of my own table as the real record of what was done.

The metadata pays off later. When the refund turns up in a payout, the expanded source carries the order_id, and matching it to an order becomes a simple lookup.

Listing the balance transactions in a payout

For automatic payouts, Stripe will tell you exactly which balance transactions went into a payout. You pass the payout ID as the payout filter when you list balance transactions. Manual payouts are another story. Stripe has no way of knowing which transactions you meant to include, so the filter doesn't work for them, and the payout's reconciliation_status reads not_applicable. For automatic payouts, the status moves from in_progress to completed, and Stripe sends a payout.reconciliation_completed event when it does. That event is the natural trigger for the code below.

export class NotReadyError extends Error {}
export class ReconciliationError extends Error {}

export interface PayoutBundle {
  payout: Stripe.Payout;
  txns: Stripe.BalanceTransaction[];
}

export async function loadPayout(payoutId: string): Promise<PayoutBundle> {
  const payout = await stripe.payouts.retrieve(payoutId);
  if (payout.reconciliation_status !== "completed") {
    throw new NotReadyError(`${payoutId}: reconciliation_status=${payout.reconciliation_status}`);
  }
  const ownTxnId =
    typeof payout.balance_transaction === "string"
      ? payout.balance_transaction
      : payout.balance_transaction?.id;

  const txns: Stripe.BalanceTransaction[] = [];
  const pages = stripe.balanceTransactions.list({
    payout: payoutId,
    limit: 100,
    expand: ["data.source"],
  });
  for await (const txn of pages) {
    if (txn.id === ownTxnId) continue; // the payout's own debit from the balance
    if (txn.currency !== payout.currency) {
      throw new ReconciliationError(`${payoutId}: ${txn.id} is in ${txn.currency}`);
    }
    txns.push(txn);
  }

  const net = txns.reduce((sum, t) => sum + t.net, 0);
  if (net !== payout.amount) {
    throw new ReconciliationError(`${payoutId}: transactions net ${net}, payout ${payout.amount}`);
  }
  return { payout, txns };
}
Enter fullscreen mode Exit fullscreen mode

The payout itself appears in that list as a balance transaction of type payout, so the loop skips it by ID before adding anything up. The for await loop uses the SDK's auto-pagination, which matters on a busy account, where a single daily payout can hold thousands of transactions. Expanding data.source gives you each charge, refund or dispute with its metadata attached, so you don't have to make another call for every line.

The check at the bottom is the part I would defend hardest in a code review. If the nets don't add up to the payout amount, the function throws an error and stops. Sooner or later, someone will ask you to post the difference to a plug account so the job stops failing. Please don't. A plug account is where an unexplained difference goes to be forgotten.

Turning a payout into a journal entry

Once the list has been checked, building the journal entry should be boring – and I like it that way. Each balance transaction posts its gross amount to an account chosen by reporting_category. Each fee posts to an account chosen by its fee_details type. The bank line is the payout amount. Since net = amount - fee for every transaction, and the nets already add up to the payout, the entry has to balance. The code checks anyway. When the numbers come from someone else's API, I check even the arithmetic that can't possibly be wrong.

export interface JournalLine {
  account: string;
  amountMinor: number; // positive = debit, negative = credit
  sourceRef: string;   // txn_ or po_ id, for drill-down
}

const CATEGORY_ACCOUNT: Record<string, string> = {
  charge: "1210 Stripe clearing",
  refund: "1210 Stripe clearing",
  partial_capture_reversal: "1210 Stripe clearing",
  refund_failure: "1210 Stripe clearing",
  dispute: "1215 Funds held in dispute",
  dispute_reversal: "1215 Funds held in dispute",
  fee: "6150 Payment processing fees",
};
const FEE_ACCOUNT: Record<string, string> = {
  stripe_fee: "6150 Payment processing fees",
  tax: "1410 Recoverable tax on fees",
};
const SUSPENSE = "1299 Stripe suspense";
const BANK = "1010 Operating account";

export function buildJournal({ payout, txns }: PayoutBundle): JournalLine[] {
  const lines: JournalLine[] = [];
  for (const t of txns) {
    lines.push({ account: CATEGORY_ACCOUNT[t.reporting_category] ?? SUSPENSE, amountMinor: -t.amount, sourceRef: t.id });
    let feeBooked = 0;
    for (const fd of t.fee_details) {
      lines.push({ account: FEE_ACCOUNT[fd.type] ?? SUSPENSE, amountMinor: fd.amount, sourceRef: t.id });
      feeBooked += fd.amount;
    }
    if (feeBooked !== t.fee) {
      lines.push({ account: SUSPENSE, amountMinor: t.fee - feeBooked, sourceRef: t.id });
    }
  }
  lines.push({ account: BANK, amountMinor: payout.amount, sourceRef: payout.id });

  const balance = lines.reduce((s, l) => s + l.amountMinor, 0);
  if (balance !== 0) throw new ReconciliationError(`${payout.id}: entry out of balance by ${balance}`);
  return lines.filter((l) => l.amountMinor !== 0);
}
Enter fullscreen mode Exit fullscreen mode

The account numbers are placeholders, so use your own chart of accounts. More importantly, the account map is an accounting decision, and it belongs to your controller. I write the code; finance decides where things land.

A few of the mappings need explaining. Charges credit the clearing account because the entry at the time of sale debited it, and refunds debit it for the same reason in reverse. Dispute debits go to a holding account, and not straight to expense, because a dispute can be won and reversed months later, and finance should record the outcome when it's known. Standalone fee transactions come in with a negative amount and a reporting_category of fee, so they reach fee expense through the first line and never touch the fee loop. The dispute fee comes through one of those two paths, and the code doesn't need to know which. Anything with a category the map doesn't recognize goes to suspense, and someone should be clearing suspense to zero every week. Stripe adds new categories from time to time. If your integration quietly defaults unknown categories to revenue, your ledger will drift, and it's quite possible nobody will notice until the auditors do.

Currency conversion

When a customer pays in a currency you don't settle in, the charge's balance transaction has already been converted into your settlement currency, with an exchange_rate that turns the presentment amount into the settled amount. So the payout entry never sees a single euro. That's correct, because the bank received dollars.

The currency question belongs to the entry made at the time of sale. If you book the receivable in euros at your own daily rate, and then clear it with dollars from Stripe, the difference is a realized FX gain or loss – and your accounting system should calculate it, not your integration. For many businesses, the simpler route is to book the clearing debit from the charge's own balance transaction once the charge succeeds. The clearing account then stays in your settlement currency, and the FX difference sits between receivables and clearing, which is where finance will look for it. One more thing about disputes: the disputed amount can differ from the original charge, because the exchange rate on the day of the dispute isn't the rate on the day of the purchase.

Posting it exactly once

The webhook that kicks all this off can arrive twice, and two workers can pick up the same payout at the same time. I want the database itself to make a second posting impossible. This version assumes PostgreSQL and the pg package.

CREATE TABLE stripe_payout_journal (
  payout_id     text PRIMARY KEY,
  currency      text   NOT NULL,
  amount_minor  bigint NOT NULL,
  arrival_date  date   NOT NULL,
  posted_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE stripe_payout_journal_line (
  payout_id     text   NOT NULL REFERENCES stripe_payout_journal (payout_id),
  line_no       int    NOT NULL,
  account       text   NOT NULL,
  amount_minor  bigint NOT NULL,  -- positive = debit
  source_ref    text   NOT NULL,
  PRIMARY KEY (payout_id, line_no)
);
Enter fullscreen mode Exit fullscreen mode
import { Pool } from "pg";

export async function postPayout(pool: Pool, b: PayoutBundle, lines: JournalLine[]) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const header = await client.query(
      `INSERT INTO stripe_payout_journal (payout_id, currency, amount_minor, arrival_date)
       VALUES ($1, $2, $3, (to_timestamp($4) AT TIME ZONE 'UTC')::date)
       ON CONFLICT (payout_id) DO NOTHING`,
      [b.payout.id, b.payout.currency, b.payout.amount, b.payout.arrival_date],
    );
    if (header.rowCount === 0) {
      await client.query("ROLLBACK");
      return "already_posted" as const;
    }
    for (const [i, l] of lines.entries()) {
      await client.query(
        `INSERT INTO stripe_payout_journal_line (payout_id, line_no, account, amount_minor, source_ref)
         VALUES ($1, $2, $3, $4, $5)`,
        [b.payout.id, i + 1, l.account, l.amountMinor, l.sourceRef],
      );
    }
    await client.query("COMMIT");
    return "posted" as const;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

The primary key on payout_id provides the whole guarantee. If two transactions race, the second insert waits for the first one to commit, and then does nothing. The header and its lines are written together, or not at all. Anything that exports to the ERP should read from these tables, never directly from Stripe, so the ERP gets exactly one entry per payout, no matter how many times the pipeline ran.

Orders that ship in pieces

Everything so far assumes that a charge is captured once. For our apparel client, that assumption fell apart on orders with several items that shipped at different times. Charging the full amount at checkout meant taking money for goods that might not ship for weeks, and charging at the first shipment left nothing clean to charge for the rest. The workflow I implemented uses Stripe's multicapture feature. The whole order is authorized at checkout, and each shipment's share is captured as it leaves the warehouse.

interface Order {
  id: string;
  totalMinor: number;
  currency: string;
}

export async function authorizeOrder(order: Order): Promise<Stripe.PaymentIntent> {
  return stripe.paymentIntents.create(
    {
      amount: order.totalMinor,
      currency: order.currency,
      capture_method: "manual",
      payment_method_options: { card: { request_multicapture: "if_available" } },
      metadata: { order_id: order.id },
    },
    { idempotencyKey: `authorize:${order.id}` },
  );
}

interface Shipment {
  id: string;              // primary key of our shipments row
  amountMinor: number;     // items in this box plus allocated tax, shipping and discounts
  isLast: boolean;
}

export async function captureShipment(paymentIntentId: string, s: Shipment): Promise<Stripe.PaymentIntent> {
  return stripe.paymentIntents.capture(
    paymentIntentId,
    { amount_to_capture: s.amountMinor, final_capture: s.isLast },
    { idempotencyKey: `capture:${s.id}` },
  );
}
Enter fullscreen mode Exit fullscreen mode

The order is authorized at checkout with capture_method: "manual", and with multicapture requested. When a shipment is ready, the integration calculates what that box is worth – the items in it, plus their share of tax, shipping and discounts – and captures exactly that amount with final_capture: false, so the rest of the authorization stays in place. The last shipment captures with final_capture: true, and Stripe releases anything left over. The idempotency key comes from the shipment row, for the same reason the refund key came from the refund row.

Multicapture comes with conditions. First, it isn't available to everyone. Stripe offers it on IC+ pricing, only for online card payments, and only on certain card networks. The charge tells you whether a particular card supports it, so your integration needs a fallback. Second, authorizations expire. A card authorization is good only for a limited time, and if the remaining items won't ship before it runs out, you need a fresh payment authorized by the customer. Assuming the original hold is still there will fail you on exactly the day it matters. Third, the card networks have their own rules. Most limit multicapture to goods that ship separately, which is precisely this case, but check what applies to your business.

Every capture then needs to be reconciled three ways: against the order in BigCommerce, against Stripe's balance transactions, and, eventually, against the bank payout. Captures made on different days settle on different days, so one charge can end up spread across several payouts. The payout code above handles that without any change, because it works one balance transaction at a time and never assumes that one charge means one payout.

After go-live

Some payouts that fail show up as paid first, and change to failed later. When that happens, the payout's failure_balance_transaction returns the funds to your Stripe balance, and you need a reversing entry against the bank line, triggered by the payout.failed event. Manual payouts are the other trap. They can't be listed this way at all, so if someone on the team switches off automatic payouts, the pipeline should stop and say why. It should never guess.

Sooner or later, finance will do the obvious thing, and compare each posted payout's amount and arrival date with the bank statement. With the pipeline built this way, that comparison is a simple join between two tables, and the clearing account will tell them, at any moment, how much Stripe still owes.

And the apparel brand? Unmatched deposits did eventually go to zero – though not on the day the new pipeline went live. Real orders kept turning up situations the first version hadn't anticipated, and each one, the split shipments included, took its own piece of engineering before the books stayed clean. That, to be honest, is what this kind of work looks like. A sound design gets you most of the way on day one. The edge cases get you the rest of the way, one at a time, and the reconciliation check is what tells you which one to fix next.

I'm Jayant Chaudhary, founder of eProxim, where we build payment and ledger integrations for implementation partners and the firms they serve.

Top comments (1)

Collapse
 
shuttlewright profile image
Shuttlewright •

The clearing account is doing more work than it looks like: it turns the month-end hunt into a list. Once every charge, refund, dispute and fee posts as its own balance-transaction line, the unmatched residual stops being "deposits nobody can explain" and becomes exactly the set of clearing entries not yet swept into a payout - a query, not a search. Two edges worth naming while the model is still clean: if you settle in more than one currency, run one clearing account per currency, because Stripe pays out per balance and a merged account will never clear to zero. And a payout that lands after month-end is not a break in the model but the point of it - the receivable simply stays open across the close, and the books agree anyway. That was the real fix underneath the 1-2%: the gap was never in the money, it was in booking revenue timing and cash timing as the same event.