If you're writing your own code to push Stripe payments into QuickBooks Online or Xero, the first version usually works for a plain $100 sale. The trouble shows up weeks later, when the bank feed stops matching. Here are four cases worth handling before that happens, with the journal entries that keep the books right.
1. Stripe pays you net, in batches
Stripe takes its fee before it pays you, and one payout can bundle charges, refunds and fees from several days. If each sale goes straight to the bank account at its full amount, nothing will ever match the deposit.
Post each charge into a Stripe clearing account instead, at its full amount, with the fee split out:
Dr Stripe Clearing $96.80
Dr Stripe Fees $3.20
Cr Sales $100.00
Each payout then moves money from the clearing account to the bank:
Dr Bank $5,000.00
Cr Stripe Clearing $5,000.00
The payout entry now matches the bank deposit, and at month end the clearing account should equal your Stripe balance. If you need to know what went into a payout, Stripe's API can list the balance transactions for an automatic payout by its ID.
2. A refund on a yearly plan that's partly used
A customer pays $1,200 for a year. You hold it as deferred revenue and release $100 a month. Three months in, they cancel and you refund the remaining $900.
The easy mistake is posting the $900 as a refund against revenue. Only $300 was ever counted as revenue, so net revenue for this customer drops to minus $600 even though they received $300 of service, and the $900 sits in deferred revenue forever.
The refund should come out of deferred revenue first:
Dr Deferred Revenue $900.00
Cr Stripe Clearing $900.00
Only the part beyond what's still deferred is a refund of earned revenue. To get this right, your code has to know how much is still deferred. That's a fact about your books, not something in the Stripe event, so you need to keep the schedule somewhere.
3. A dispute where the fee is bigger than the charge
When a customer disputes a charge, Stripe pulls the disputed amount plus a dispute fee from your balance. You might still win, so the disputed amount belongs in a receivable until the outcome. The fee is gone either way, so it's an expense now:
Dr Disputes Receivable $100.00
Dr Dispute Fees $15.00
Cr Stripe Clearing $115.00
If you win, the $100 moves back from the receivable to the clearing account. If you lose, it becomes an expense.
The quiet bug is telling the two amounts apart by size. On a $9.99 charge, the $15 fee is the bigger number. Use the fee details Stripe puts on the dispute's balance transaction instead of guessing from which amount is larger.
4. Partial refunds on a taxed sale
If the sale collected sales tax, each refund has to reduce the tax you owe as well, in proportion. Round each partial refund on its own and a sale refunded in three pieces can leave a cent or two of tax owed on a sale that no longer exists. Work out the tax on the total refunded so far, then subtract what earlier refunds already took back. The pieces then add up to exactly the tax you collected.
The library that came out of this
These all came up while building Ledgerly, a free, open-source TypeScript library that turns Stripe events into balanced QuickBooks Online and Xero journal entries. You call mapEvent(event) in your own webhook handler and post the result. The cases above, plus payouts and currency changes, are checked by tests. The ones that need saved history, like the yearly plan refund, are handled by an optional receiver that keeps that history.
To see what it would post for your own account without connecting anything, there's a read-only preview. Create a restricted Stripe key with Read access, then run:
STRIPE_SECRET_KEY=rk_live_... npx --yes ledgerly@0.18.1 --stripe --days 30
The README shows how to enter the key without it landing in your shell history. The code is at https://github.com/jakethehoffer/ledgerly. AI helped build it and draft this post. It creates journal entries, not invoices, and it isn't accounting advice.
If you've written this kind of code yourself, which case caught you out?
Top comments (3)
the yearly-plan refund hitting deferred revenue first is the one that's easy to get wrong in code, because stripe's refund event doesn't tell you what's still deferred. do you store the remaining deferred amount on your side per subscription, or recompute it from the schedule when the refund lands?
Ledgerly recomputes it from the schedule. When an annual invoice is paid, it saves that invoice's monthly recognition rows. When a refund lands, the receiver adds up the rows that haven't posted yet for the invoice that charge paid, and that total is what's still deferred. The refund comes out of that first, anything beyond it is booked as a refund of earned revenue, and the leftover is spread back over the remaining months while the old rows are cancelled.
That way the only record of what's deferred is the set of rows that actually get posted. A separate running balance per subscription would be one more number that can drift away from the books. How are you tracking it on your side?
deriving it from the rows instead of keeping a running deferred number makes sense, one less thing to drift from the books. on a small setup i'd go the same way, compute it, don't store it. when a refund goes past what's still deferred and gets booked against earned revenue, does that case get surfaced anywhere for a person to check, or is it fully automatic?