Skip to main content

Payment mistakes

Every item here really happened while we built payments, mostly in the first six weeks of UPG and its first product integration. Each has what happened, why, and what to do instead. Read it in your first week on any billing work.

The pattern behind most of them​

A first purchase works because the server asks the rail directly. Everything after it arrives later, from the rail, and is easy to drop. Test the whole life of a subscription: buy, renew, fail, recover, cancel, refund, dispute. Run it against a real database, over HTTP, as the real database role.

Money and state​

  • A member bought the same plan three times in half an hour. They bought, cancelled and re-bought; each was a full charge for the same month. The duplicate check only counted active and in_grace, and the pricing page auto-opened checkout before it knew the member's plan. Instead: a cancelled-but-paid subscription to the same plan blocks checkout with subscription_already_active and a "resume" path. Two of the three were refunded.
  • Two plans, one lapses, both switch off. Entitlements were upserted from the one subscription being processed. Instead: recompute from all grants. See Entitlements.
  • Refunds did not stick. The next event on a refunded subscription re-granted access. Instead: revoked holds against later events, except one that opens a new paid period.
  • Revoking on cancel. Instead: cancelled keeps access to period end.
  • Double charges on upgrade retries. Instead: lock the subscription before the rail call, use an idempotency key derived from the request, and apply state if the rail already moved.
  • Every plan billed monthly. Instead: build the billing period from the plan.

Webhooks and callbacks​

  • Renewals, refunds and cancellations stored but never applied. Only tests called the apply step. Instead: a worker drains stored events, with an alarm on the backlog age.
  • Every webhook dropped in the first real environment. Row-level security hid the connection lookup, and form posts got a 415. Instead: test over HTTP as the app role.
  • No purchase ever unlocked, and all tests passed. A date-binding error in the raw SQL upsert threw on every callback. Tests used an in-memory fake. Instead: run the callback path against a real database.
  • Signatures could not be re-verified. We stored JSON.stringify(event) instead of the raw bytes. Instead: store the raw body.
  • Callbacks were rate limited. The webhook route sat under the global per-IP limit. Instead: exempt it.
  • Secret rotation broke every callback until the backend redeployed. Instead: sign with both secrets during an overlap.

Stores​

  • Google auto-refunded one-time purchases. We acknowledged by orderId; the API wants the purchase token. See Google Play Billing.
  • The production image had no Apple root certificates. See Apple in-app purchase.
  • Apple prices are in milliunits. A wrong factor is a 10x ledger error.
  • iOS rejected while nothing was purchasable. Prices and subscription terms were live on the website. Launch them with the purchase.
  • StoreKit returned no products for weeks. The Paid Applications Agreement was waiting for tax forms.

Prices and country​

  • Buyers could pick their own country's price through a forwarded header. See Prices, currencies and country.
  • KWD prices shown ten times too high. Every amount was divided by 100.
  • Hard-coded rupee prices came back when pricing was down. Show no price instead.

Security​

  • Any signed-in user could cancel or verify anyone's subscription. No ownership check. Instead: read the subscription and compare user_ref before any action.
  • One app's key could read another app's subscriptions, and test keys could read live data. Instead: scope every read and write by app and environment.
  • Idempotency keys collided across apps. Instead: scope keys to app and environment.

Configuration​

  • ^0.2.0 never installed 0.4.0. For 0.x, caret ranges stop at the next minor. Instead: pin exact versions of our own SDKs.
  • Each deploy reverted panel edits. Instead: seed create-only.
  • Catalog drift on a live app with no errors. Mis-mapped base plans, missing offers, invisible products. Instead: run the drift check after every store catalog change.
  • A Stripe API version Stripe rejects. Instead: pin a version and test it with a real sandbox purchase before you merge.