Payments

CHIP

Six colleagues buy a leaving gift. One person organises it, then spends two weeks chasing the other five in a group chat.

Test card4084 0840 8408 4081CVV408PIN0000OTP123456ExpiryAny future date

Click to preview · 2× speed

A payment lands and the page confirms it on its own. Nobody touched anything.

Stack/Tools

Next.jsTypeScriptTailwindPrismaPostgreSQLPaystack

What it does

Set a total, add the people, send one link. Everyone pays their own share by card, and the money only counts once everyone has paid.

There are no accounts anywhere. A pool is a URL: a public id in the share link, and a separate secret token in the organiser link. Holding that token is the permission to extend the deadline or cancel and refund everyone. That is the entire authorisation model.

The organiser can nudge whoever hasn't paid with a copyable message, push the deadline back, or call it off and refund everyone. If the deadline passes with people still outstanding, the pool expires and refunds itself.

The hard part

The seconds where a payment has happened and your server has no idea.

Someone pays on Paystack's page, not yours. Their card clears, the money is real, and they get sent back to your site. For the next few seconds your database still says pending and your page still shows a Pay button next to their name.

Most payment tutorials treat that redirect as proof. It isn't. The browser can close the tab, lose signal, or simply lie. So the only thing that marks a share paid is a signed webhook arriving from Paystack's servers, out of band, retried until it's acknowledged.

That handler verifies the signature before touching anything, records the event id inside the same transaction as the state change so a retry can't double-credit, and checks the charge against its own record of both the price and the currency rather than trusting the payload.

A decision I'd defend

One line of raw SQL, in an app otherwise entirely on the ORM.

Two people pay the last two shares within the same second. Both webhooks arrive. Each reads a pool that still shows shares outstanding, because the other hasn't committed yet. Each marks its own share paid. Neither funds the pool.

Everyone has paid and the pool never closes.

`SELECT id FROM "Pool" WHERE id = $1 FOR UPDATE`, taken before the share is updated, so the second transaction blocks until the first commits and then sees a complete picture. Prisma has no first-class row lock, so this drops below the ORM — the correct primitive for the problem rather than a workaround for a missing feature.

Selected screens

A pool mid-collection. One dot per person, so where it stands reads before any number does.
Paystack's page, not mine — which is the whole problem. The payer has been told it worked before my server has heard anything about it.
Back from the checkout, before the confirmation lands. The one state that has to say clearly there's no need to pay again.
Every share in. The pool funds itself and says so plainly, because this is the state the whole thing exists to reach.
The organiser called it off. Everyone who had paid was refunded, and the summary names the real figure and headcount rather than putting it abstractly.
The deadline passed with people outstanding, so everyone who paid was refunded. The list stays visible so a payer can find their own name.

Where it stands

Live in Paystack test mode with the card details on the page, so you can pay a share yourself and watch it confirm.

Four demo pools stopped at different stages, including one that exists to be cancelled and restores itself a few minutes later, because the refund flow is the hardest thing here to see.

Fifty-five tests over the logic that decides things: the state machine, the money handling, and the helpers. None of them need a mock, because none of those functions touch anything.

What's still missing

A share is marked refunded when Paystack accepts the refund, not when the money settles. Those are days apart, and the interface says the rest in words instead.

A stuck transaction is re-verified on every poll, up to about thirty calls over three and a half minutes. A checkout timestamp would let it back off.

A pool cannot exceed ₦20,000,000, because the amount is a Postgres integer. Beyond it you get a readable message rather than a driver error.

Full reasoning for all three is in the README.

I wrote up the whole build: the races, the refund that had to happen outside a transaction, and the bug I wrote twice in two different places.

Test card4084 0840 8408 4081CVV408PIN0000OTP123456ExpiryAny future date

Or step sideways