Case study · Marketplace payments
Al-Kout — payments that settle exactly once
A shops-and-services marketplace where a webhook, a customer retry and a scheduled check can all report the same payment.
The problem
Al-Kout is a Saudi marketplace with two sides. Shops sell products: cart, order, the shop confirms, the customer pays, delivery. Technicians sell home services: the customer describes a job, providers send quotations, the customer accepts one and pays. The platform earns a commission on both.
Money moves between customers, providers and the platform through wallets, cash and online payment. Every one of those paths can be retried, or can arrive twice.
My role
I was the only backend developer. I built the customer API, the provider API and the Arabic admin panel: 123 specified features, delivered in eight planned cycles.
Architecture
The code is organised by domain: one folder for the customer API, one for the provider API and one for the admin panel. Models sit beside their repository interface and its implementation.
- Controller
- FormRequest
- DTO
- Service
- Repository
- Resource
Customers, providers and admins sign in through three separate guards. Every response uses one envelope, and every exception is normalised in one place so that internal class names never reach the client.
Key decisions
One door for payment results. A payment’s outcome can be reported by the gateway’s webhook, by the app asking for a sync, or by a scheduled check. All three call the same function.
Save the payment attempt before calling the gateway
It gets its own identifier first, so a timeout never leaves a charge nobody is tracking.
Call the gateway outside any database transaction
A slow gateway never holds a lock.
A result arrives: webhook, app sync or scheduled check
All three go to the same function.
Lock the attempt row and read its state
The decision is made from the locked state, never from the incoming payload alone.
Compare the amount and the currency with the attempt
A mismatch is rejected and never credited.
Apply once: mark as paid, move the order, write the ledger
Unique constraints in the database back this up. Every request that moves money also carries an idempotency key, so a repeated tap or a retried request returns the first result.
The wallet is a ledger. Every credit and debit goes through one service that locks the wallet row first. Amounts are stored as whole halalas, never as floats.
One map of order transitions. A single table lists which status can follow which, and who may trigger it. Stock follows the same map: reserved when the order is placed, committed on payment, restored on cancellation.
Two harder cases
A payment deadline against a card payment in flight. Unpaid orders are cancelled at a deadline. The customer might be on the payment page at that moment. Before cancelling, the system asks the gateway. If a success still arrives after the cancellation, it is refunded once.
Shared hosting without background workers. The target server does not allow long-running processes. A scheduled command drains the queue, a heartbeat endpoint shows whether the scheduler is alive, and order timers repair themselves when an order is next read.
Outcome
- All 123 features across the customer app, the provider app and the admin panel.
- About 1,800 test methods, with cases for a late success, a double payment and the order in which locks are taken.
- 14 scheduled commands for deadlines, reminders and reconciliation.
What comes next
The payment gateway sits behind an interface and was developed against a fake implementation. Connecting the production gateway (Moyasar) and the SMS provider are the remaining integration steps. After that, the locking design should be verified against MySQL under real concurrency, since the tests run on SQLite.