Payments
CHIP
Six colleagues buy a leaving gift. One person organises it, then spends two weeks chasing the other five in a group chat.
Click to preview · 2× speed
Stack/Tools
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
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