Chapter 8 · The 90-day self-audit
The Platform Refund Ledger · 6 min read
This chapter answers one question: how do you audit your own platform's last ninety days of refunds — the queries, the spreadsheet, the leak signatures — using nothing but Stripe's read API?
This is the honest, manual version of what an audit tool does continuously. It takes an afternoon the first time and an hour each quarter after. Everything it needs is readable with restricted read-only access to five resources: charges, transfers, application fees, balance, and events.
Ground rules before you start
- You are reading a snapshot. Balances move between your query and your action; always re-fetch live state before acting on any single finding.
- Amounts are integers. Every figure below is minor units (cents); format as currency only at display time.
- Tolerance is two minor units per charge, absorbing the independent-rounding drift demonstrated in chapter 6. Below tolerance is not a finding.
- Ninety days is a practical window, not an API limit — older reversals are legal but more likely to land on empty balances (chapter 3's decay).
Step 1 — Collect the refund events
Pull every charge.refunded event for the window (GET /v1/events?type=charge.refunded&created[gte]=<90 days ago>, paginated), or equivalently list charges and filter on refunded. Record per event: charge id, refund amount, created, and reason if set.
Step 2 — Classify each charge
For each affected charge, retrieve it expanded with its transfer:
GET /v1/charges/{id}?expand[]=transfer
charge.transfer present -> destination charge
charge lives under Stripe-Account header -> direct charge
neither -> separate pattern; find transfers yourself
Record pattern, amount, and for destination/separate the linked or associated transfer.id and its amount.
Step 3 — Compute expected reversals
Per charge, using cumulative figures so per-refund rounding cannot accumulate:
Expected reversal = round( min(total_refunded, amount) ÷ amount × transfer.amount ) Missing = Expected reversal − amount_reversed
The min() clamps the ratio at 1 — fully refunded charges must not generate expectations above the transfer itself. Compare against amount_reversed read live from the transfer.
Step 4 — Compute expected fee returns
For every application_fee on these charges (direct and destination patterns only):
Expected fee refund = round( total_refunded ÷ amount × application_fee.amount ) Missing = Expected fee refund − application_fee.amount_refunded
Step 5 — Sweep the disputes
List disputes in the window and keep those with status: lost. For each, fetch the linked transfer and record:
Dispute recovery due = transfer.amount − amount_reversed
Never substitute the disputed amount (chapter 5 worked through why it fails).
The spreadsheet
One row per charge; formulas assume amounts in column C onward:
| Col | Field | Formula / source |
|---|---|---|
| A | charge_id | from step 1 |
| B | pattern | step 2 |
| C | charge_amount | charge.amount |
| D | transfer_amount | transfer.amount, else blank |
| E | total_refunded | sum of refund amounts |
| F | expected_reversal | =IF(D="","",ROUND(MIN(E,C)/C*D,0)) |
| G | actual_reversed | transfer.amount_reversed |
| H | missing_reversal | =IF(D="","",MAX(0,F-G)) |
| I | fee_amount | application_fee.amount |
| J | expected_fee_refund | =IF(I="","",ROUND(E/C*I,0)) |
| K | actual_fee_refund | application_fee.amount_refunded |
| L | missing_fee_refund | =IF(I="","",MAX(0,J-K)) |
| M | dispute_recovery_due | =IF(dispute_lost,D-G,0) |
| N | notes | reason, dispute id, seller status |
A worked mini-audit
Four findings from a fabricated quarter, each from a different signature:
| Row | Pattern | C | D | E | F | G | H | I | J | K | L | M |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ch_101 | destination | 120.00 | 108.00 | 120.00 | 108.00 | 0 | 108.00 | 12.00 | 12.00 | 0 | 12.00 | — |
| ch_102 | separate | 80.00 | 72.00 | 20.00 | 18.00 | 13.50 | 4.50 | — | — | — | — | — |
| ch_103 | direct | 60.00 | — | 30.00 | — | — | — | 6.00 | 3.00 | 0 | 3.00 | — |
| ch_104 | destination, lost dispute | 860.00 | 731.00 | 0 | — | 0 | — | 129.00 | — | 0 | — | 731.00 |
Note column D on the two destination rows: the transfer is the charge less the application fee, which is what the fee being withheld from the transfer means in data (chapter 1) — 120.00 − 12.00 = 108.00, and 860.00 − 129.00 = 731.00. Reading 120.00 or 860.00 there is the single most common way this sheet produces expectations no transfer could ever satisfy.
Verify each computed cell by hand once: 120/120 × 108 = 108.00 minus 0 reversed; 20/80 × 72 = 18.00 minus 13.50 reversed (someone reversed, but by feel rather than proportion — the most human finding there is); 30/60 × 6 = 3.00 against nothing returned; 731 − 0 on the lost dispute. Totals: $112.50 of missing reversals, $15.00 of unreturned fees, $731.00 of unclawed dispute losses — $858.50 this fabricated platform could still reach, each dollar traceable to an id a support ticket can cite.
The leak signatures
Every row above instantiates one of five signatures. When totals look wrong, diagnose by signature:
- Unreversed full refund — flags absent on the path that issued it (ch_101).
- Partial shortfall — reversal attempted but mis-sized (ch_102).
- Fee never returned —
refund_application_feeabsent everywhere (ch_103). - Unclawed lost dispute — recovery never run against the payout window (ch_104).
- Rounding drift beyond tolerance — rare, and almost always a sign someone recomputed proportions per-refund instead of cumulatively.
Acting on findings, safely
Three disciplines separate a cleanup from an incident. Re-fetch live state immediately before each correction; a snapshot age of weeks means balances have changed. Derive one idempotency key per finding (Stripe documents keys as surviving retries for 24 hours, docs.stripe.com/api/idempotent_requests) so a rerun collapses instead of double-reversing. And tell the seller first when the correction touches money they can see — the communication patterns are chapter 10's closing topic.
What to check on your own platform
- Run steps 1–5 for your last 90 days and fill the spreadsheet completely before judging anything.
- Total column H by signature, not just overall — the fixing code path differs per signature.
- Cross-check three random findings against chapter 7's ledger view; a finding without matching balance-transaction evidence is a query bug.
- Record your tolerance policy and clamp rule in the sheet itself so the next person inherits the method, not just the numbers.
- Date-stamp the export; findings decay as payouts run.
Sources: docs.stripe.com/api/events · docs.stripe.com/api/refunds/object · docs.stripe.com/api/transfers/object · docs.stripe.com/api/application_fees/object · docs.stripe.com/api/disputes/list · docs.stripe.com/api/idempotent_requests