청구가 제대로 이루어지지 않았고 만료일이 만료되지 않았습니다. 그 이유는 다음과 같습니다.

작성자

카테고리:

← 피드로
DEV Community · shaojie gong · 2026-08-03 개발(SW)

shaojie gong

I paid $2.99 to my own product this week. That’s how I found out my subscriptions had no expiry date.

Quick context: my extension has a Pro tier. No login servers, no license keys — you pay through Stripe, and a tiny Cloudflare Worker listens for Stripe’s webhook and writes a little record: this email is Pro, and here’s when the subscription renews. That renewal date is the whole point — it’s how I know when to stop giving someone Pro if they cancel or their card fails.

So I did the thing every indie dev should do more often: I became my own customer. Test card, real checkout flow, $2.99. Payment went through. Then I opened the database to admire my handiwork.

The record was there. Email, active: true, the Stripe customer id. But the renewal date — the one field the whole system exists to track — was just… missing. Not wrong. Missing. The field wasn’t even there.

Here’s the part that made it confusing: nothing had failed. Stripe’s dashboard showed every webhook delivered, every one a green 200 OK. My worker said “yep, got it, all good” to everything. And still, no date.

I went to Stripe’s event log and lined the events up by timestamp. When you pay, Stripe doesn’t send one event — it sends a burst. checkout.session.completed, invoice.paid, subscription.created, a dozen others, all within the same second or two. And that’s when I saw it: invoice.paid was delivered at 2:55:57. checkout.session.completed came at 2:55:58. One second later.

That one second was the whole bug.

My code had an unspoken assumption baked into it: that “checkout completed” always arrives first. That event is where I build the lookup table connecting a Stripe customer to their email. Every other event — including the one carrying the renewal date — uses that table to figure out whose record to update. So when invoice.paid showed up first, it went looking for an email that didn’t exist yet, found nothing, and quietly moved on. No error. Just a shrug. The date it was carrying got dropped on the floor, and checkout.session.completed arrived a second later to build a record that would now never learn its own expiry date.

Webhooks don’t promise order. I knew that in the abstract, the way you know a tornado is theoretically possible. I just never built for it, because in every test I’d ever run, the events happened to arrive in the tidy order I expected. It took a real payment, with real network timing, to shuffle the deck the other way.

The fix wasn’t dramatic. Now, when an event arrives and can’t find its email yet, instead of dropping the data it stashes it in a “pending” slot keyed by customer id. When checkout.session.completed finally lands, it merges whatever was waiting. Order stops mattering — whoever gets there first leaves a note for the others. I also found a second landmine while I was in there: Stripe had quietly moved the renewal-date field to a new location in a recent API version, so even the events I was handling were reading an empty spot. Fixed that too.

Bought Pro again after deploying. Opened the database. There it was — renewal date, plan, everything. A month out, exactly right.

Two things I’m taking from this. One: buy your own product. Not a mock, not a test harness — the actual flow with actual money moving. Half the bugs that matter only show up when the timing is real. Two: any time your code assumes A happens before B, and you don’t own the thing deciding the order, you don’t have a guarantee — you have a coin flip that’s been landing heads in testing.

Anyone else have a bug that only appeared the first time real money went through? I’d bet those are a special category.

— building NotebookBloom in public, #13

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다