The £1,259 donation that never happened
Subhankar Denria
CTO & product engineer

What this part does
Make every money figure on a donations platform honest, prove nothing was ever missed, and see abandoned payments the next morning instead of by luck, with donor privacy designed in from the start.
- Started with
- One number that looked too good
- Found
- No large donation ever missed
- Now
- A lost donation is noticed by 08:00 the next day
Amounts in this post are illustrative; the engineering is exactly as built.
The internal admin dashboard of a donations platform showed a card: "Large donation processed: £1,259.34."
A lovely number. Then we looked for it in Stripe, and it wasn't there.
That leaves two possibilities, and a platform whose whole product is trust can't live with either:
Large donation processed
£1,259.34
not in Stripe
It never arrived
The dashboard shows money that isn’t there, and plans get made on it.
It arrived, unrecorded
Someone gave, and the platform never noticed. No receipt, no thank-you.
Either way, trust is what breaks. On a donations platform, trust is the product.
If your product takes money, this is the bug to fear most: the one that makes you look better. Nobody complains about a number that's too good.
So we stopped, and looked properly.
What had actually happened
A donor started a £1,200 gift and chose to cover the fees, which made it £1,259.34. They picked Pay by Bank and were sent to their banking app to approve it. They never approved it, so no money moved.
The dashboard counted it anyway, because it counted every donation that had been started, not every donation that had been paid. Flip the switch and see the difference:
Admin dashboard · large donations
Large donation processed
£1,259.34
Status in the database: pending
Counting what was started: money that never arrived looks real.
What actually happened
- Started a £1,200 gift, fees covered
- Chose Pay by Bank, sent to their banking app
- Approved the payment in the app
- Money moved
Underneath, the platform does something right. When a checkout begins, it creates the payment with Stripe and saves a pending donation straight away. Stripe's webhook later marks it completed or failed. The record exists before any money moves, so a webhook can never arrive for a donation nobody knows about.
Checkout begins
The payment is created with Stripe
A pending donation is saved
Before any money moves
The donor pays, or doesn’t
Card, bank or app
Stripe’s webhook arrives
Marks it completed or failed
The bug was only in the reading. Some dashboard queries never asked for the status, so pending donations looked like real money:
// Before: every donation, including ones that were abandoned
donations where amount >= 1000, newest first
// After: only money that actually arrived
donations where status = completed and amount >= 1000, newest firstThere was a wrong turn on the way, too. At first we read Stripe's requires_action as "the donor got stuck at their card's security check". There was no card. It was a bank redirect. A status on its own doesn't explain what happened; you also need the payment method.
Step 1: make the numbers honest
Every figure on the admin dashboard that counted pending donations now counts completed ones only:
One consistent definition of "raised" is worth more than a bigger number. Decisions get made from these figures, and a figure that's inflated even once undermines every other figure on the page.
Step 2: prove nothing was missed
Fixing the dashboard answered "are we showing too much?" The scarier question was the opposite one: did we ever miss money?
So we built a read-only reconciliation check that compares the platform's records with Stripe's, in both directions, and looks for four kinds of mismatch:
- 1Pending or failed here, but Stripe says it succeeded
- 2Succeeded at Stripe, with no completed donation here
- 3Completed here, with no Stripe payment behind it
- 4Pending here, with no Stripe payment at all
No large donation had ever been missed
No large donation had ever been missed. And because the check only reads, anything it ever flags goes to a person, never to an automatic fix.
Two traps on the way:
- API versions change shapes. On the Stripe API version in use, a payment no longer carries its invoice, so subscription payments looked like orphans. They're now looked up through Stripe's invoice payments instead.
- Stripe keeps events for 30 days. A check that looks further back can't lean on resending webhooks, so its advice says so plainly.
Step 3: see abandoned payments on purpose
The bigger lesson: this was found by accident. A £1,200 donor walked away, and nobody would have known if the dashboard hadn't been wrong.
Every platform that takes money has this gap. Between "started" and "paid" sit the people who meant to give and didn't, and by default nobody sees them:
Every donation that was started, counted as money. It looks wonderful.
So the platform now sees them on purpose, in three parts.
1. Add to checkout without touching it
Following up on a failed payment needs a little more saved with each unpaid donation. But the payment path is the riskiest code in any product, so this change stays off it. The extra details are saved in their own step, after the pending donation exists, and if that step fails for any reason, checkout carries on and a warning is logged:
pending = save pending donation // unchanged
try:
pending.update(follow_up) // the new step
catch any error:
log warning // and carry onIt was tested the hard way: with the database column removed entirely. Checkout still worked.
2. A page that asks Stripe what happened
Super admins have an Incomplete payments page listing every unpaid donation older than 30 minutes. For each, it asks Stripe, and translates the answer into plain English. Pick a row:
The admin page shows
Waiting at bank
Sent to their banking app, never approved. The £1,259.34.
Stripe is asked only about the rows on screen, and each answer is kept for 15 minutes, so the page is fast and gentle on Stripe's API. Donations from the Android app go through Stripe's payment sheet, create the same pending donations, and appear on the same page with no change to the app's payment code.
3. A morning email that only comes when it matters
Incomplete payments over £100
Amounts, charities and a link to the admin page. No personal details.
Every day at 08:00 UK time, one email lists incomplete payments over £100 from the last 24 hours. If Stripe took a payment in the past week that was never recorded, the subject starts with ACTION NEEDED. If there's nothing to report, nothing is sent, so nobody learns to ignore it.
Privacy, designed in
Keeping anything about people who didn't donate means handling personal data, and that needs a reason. This is how we approached each UK GDPR principle while building it:
Lawful basis
We treat telling someone their own payment didn’t go through as a service message, never as marketing.
Specific consent
The updates box is unticked by default and names the charity. Only those who tick it hear more.
Data minimisation
The daily email carries no personal details. Everything else stays behind the admin login.
Storage limitation
Details on payments that never completed are cleared automatically after 30 days.
Purpose limitation
The page says it plainly: contact them about this payment only, never add them to marketing.
Access control
Only super admins can open the page.
One distinction is easy to get wrong. A donor who ticks "give anonymously" has asked not to be named publicly. They haven't asked not to be told their payment failed. The platform respects the first, and can still send the second.
And the people who walked away?
Only people who ticked the box to hear from the charity get anything, and then just one friendly, no-pressure note: your donation didn't go through; here's the link if you'd still like to give, and no worries if not.
It can also ask what went wrong. If people give up at the bank step, the flow may be confusing others too.
What one afternoon bought
None of it touched the payment path, and none of it needed a new service.
What I'd tell anyone handling payments
For founders
- A dashboard that's wrong in your favour is still wrong. Define "raised" once, and use it everywhere.
- Lost revenue is invisible by default. If you don't build a way to see abandoned payments, you won't see them.
- Privacy designed in from the start didn't slow anything down.
Try it on your own platform. Five questions, about a minute:
- 1Does every revenue figure count only money that actually arrived?
- 2Could you list payments that were started but never finished, with the reason?
- 3Do you compare your records with your payment provider, in both directions?
- 4Would you hear about an abandoned large payment by tomorrow morning?
- 5Is personal data from unfinished payments cleared on a schedule?
0 of 5 answered. Nothing is saved or sent.
For developers
- 1Create the record before the money moves, then filter on status everywhere you report.
- 2Reconcile with your payment provider in both directions, and keep the check read-only.
- 3Keep new code off the payment path, or wrap it so a failure can't block a payment. Then test that failure on purpose.
- 4Read the payment method, not just the status, before explaining what went wrong.
- 5Alerts that only fire when something is wrong are the ones people keep reading.
One wrong number, one afternoon, and the platform now notices a lost donation the next morning instead of by luck.
Booking new builds now
