BLOG

🌱 digital garden

Basé à Nice, France - CET (UTC+1)

BLOG

🌱 digital garden

Basé à Nice, France - CET (UTC+1)

BLOG

🌱 digital garden

Basé à Nice, France - CET (UTC+1)

Engineering payments for a country Stripe does not serve

A marketplace payment flow in Tunisia with no ledger, no queue and no state machine, and what that simplicity costs.

Stripe does not onboard Tunisian businesses, and the dinar is not freely convertible. If you want to charge someone 5 TND for a file, you integrate a local gateway, and the gateway's API is the only documentation of how money moves.

7el.li is a link-in-bio where a creator prices a link and a buyer pays to unlock it. It is a marketplace: the buyer's money goes to the creator, minus a 10% platform fee. The usual architecture for that is a ledger, a payout job, a webhook consumer with a queue behind it, and a state machine holding it together.

I built none of those. I am not sure that was right in every case, so this post is about why, and about what the decision costs.

The gateway is the system of record

The design follows from one constraint I set at the start: my database never holds a balance.

Each creator gets their own organisation and wallet at the gateway. A commission rule is attached to that wallet once:

{ receiverWallet: platformWallet, rate: 10, type: 'explicit', token: 'TND' }
{ receiverWallet: platformWallet, rate: 10, type: 'explicit', token: 'TND' }

From then on the gateway splits every payment itself. The creator's balance is read from the gateway when the wallet page loads. A cash-out is one call that moves money from their wallet to their bank account.

What this removes is larger than what it adds. There is no ledger to reconcile against a bank, no payout batch to fail halfway, no rounding policy for fees (amounts are integer millimes end to end, and the gateway does the division). And there is no period during which the platform is holding other people's money, which is an engineering property and a regulatory one.

The wallet is created on the creator's first sale, not at signup. Most accounts never sell anything, and a wallet is a real object at a real financial institution.

A callback is a hint

The gateway's webhook is a GET with a single query parameter, the payment reference. No body, no signature. Anyone who learns the URL can call it.

You could wrap that in shared secrets and IP allowlists. The simpler answer is to stop treating the callback as information. The handler takes the reference and reads the payment's status from the gateway, server to server:

const payment = await konnect.getPaymentDetails(paymentId)
if (payment.status === 'completed') updateData.paid = true
const payment = await konnect.getPaymentDetails(paymentId)
if (payment.status === 'completed') updateData.paid = true

A forged callback triggers one read and changes nothing. This is also the correct design when callbacks are signed. A signature tells you who rang the bell, not what is true now. Callbacks arrive late, twice and out of order. A read does not.

So there is no state machine to speak of. The one transition that matters is from unpaid to paid, and it only ever happens after a read.

Write the record first

The purchase row is created before the gateway is called, and its id travels as the order id. If the gateway call fails, there is a harmless pending row. In the other order, a database failure after a successful charge leaves a customer who paid for something the system has never heard of. Orphan rows are cheap. Orphan money is not.

What the simplicity costs

Every one of these decisions trades a component for a dependency, and it is worth being exact about which.

No queue means no retry. If the status read fails inside the callback, the purchase is marked verification_failed and nothing looks at it again. The fix is not a queue. It is a job that re-reads every pending purchase older than ten minutes, which is the same read the callback does, on a timer. With that in place the callback becomes an optimisation and could be deleted without losing a payment. I should have started there.

No ledger means no history of my own. Reporting depends on the gateway's transaction list and its pagination. For a product this size that is fine. The day finance asks a question the gateway cannot answer, a ledger gets built, and it will be easier to build as a read-only projection of the gateway than it would have been to keep in sync as a second source of truth.

The handler is not idempotent where it matters. Setting paid twice is harmless. Sending the confirmation email twice is not. Side effects belong on the transition from unpaid to paid: an update that only succeeds when paid was still false, and an email that goes out only when that update changed a row.

The gateway's availability is my availability. The wallet page is a live read. A cache would hide outages and show stale money. I chose to show an error.

The shape of it

The whole payment path is a handful of routes and one client file. That is not because the problem is small. It is because every piece of state has exactly one owner, and wherever I could make that owner someone else, I did.

The pattern generalises beyond payments: when an external system is authoritative, hold a pointer, not a copy. Read through. Treat notifications as a reason to read. Reconcile on a timer. It is less infrastructure than the textbook version and it fails in fewer ways. At this size, at least. I have not run it at a scale where I would have to defend that sentence.