All notes
PaymentsOctober 24, 20269 min read

The £1,259 donation that never happened

Subhankar Denria

CTO & product engineer

The £1,259 donation that never happened
~9 min

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:

One number, two possibilities

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:

Count donations that were…

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

  1. Started a £1,200 gift, fees covered
  2. Chose Pay by Bank, sent to their banking app
  3. Approved the payment in the app
  4. 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.

A donation's life
1

Checkout begins

The payment is created with Stripe

2

A pending donation is saved

Before any money moves

3

The donor pays, or doesn’t

Card, bank or app

4

Stripe’s webhook arrives

Marks it completed or failed

Completed · money arrived, so it counts
Pending or failed · never counted as raised
Because the record exists first, a webhook can never arrive for a donation nobody knows about.

The bug was only in the reading. Some dashboard queries never asked for the status, so pending donations looked like real money:

text
// 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 first

There 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:

The admin dashboard: one definition of raised
The large-donations feedeverything started
Today’s volumeeverything started
Totals by charityeverything started
Progress towards targetseverything started
Unique donor countseverything started

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:

Reconcile, both ways, read-only
The platform’s records
Stripe
  1. 1Pending or failed here, but Stripe says it succeeded
  2. 2Succeeded at Stripe, with no completed donation here
  3. 3Completed here, with no Stripe payment behind it
  4. 4Pending here, with no Stripe payment at all

No large donation had ever been missed

Read-only on purpose: it reports, and people decide. A check that can change money is a second payment system.

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:

Your biggest number, opened up
Raised
lost here: Left before paying
Raised
lost here: Waiting at bank · declined · stuck at a security check
Raised

Every donation that was started, counted as money. It looks wonderful.

Bar lengths are illustrative, not real data.

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:

Try to break it
Save the pending donationExactly as before: untouched
Save the follow-up detailsIts own step, after the donation exists
Carry on with checkoutAs normal
The donor paysThe payment path never knew
pending = save pending donation    // unchanged

try:
    pending.update(follow_up)       // the new step
catch any error:
    log warning                    // and carry on

It 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:

Stripe says… we show

The admin page shows

Waiting at bank

Sent to their banking app, never approved. The £1,259.34.

The same status means different things for a card and a bank, so the label reads the payment method too.

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

08:00 UK time, every day

Incomplete payments over £100

A charity · Waiting at bank£1,259.34

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.

A gift is abandoned on day 0. When do you find out?
Before
Now
day 0day 7

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:

UK GDPR, principle by principle

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

Before → after
What “raised” meansEverything startedMoney that arrived
Money ever missed?Nobody knewChecked both ways: no large gift
Abandoned giftsInvisibleListed, with the reason
How you find outBy luck08:00 the next day
Who to follow up withUnknownKept 30 days, then cleared

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:

Five questions for your own platform
  1. 1Does every revenue figure count only money that actually arrived?
  2. 2Could you list payments that were started but never finished, with the reason?
  3. 3Do you compare your records with your payment provider, in both directions?
  4. 4Would you hear about an abandoned large payment by tomorrow morning?
  5. 5Is personal data from unfinished payments cleared on a schedule?

0 of 5 answered. Nothing is saved or sent.

For developers

  1. 1Create the record before the money moves, then filter on status everywhere you report.
  2. 2Reconcile with your payment provider in both directions, and keep the check read-only.
  3. 3Keep new code off the payment path, or wrap it so a failure can't block a payment. Then test that failure on purpose.
  4. 4Read the payment method, not just the status, before explaining what went wrong.
  5. 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.