What I Learned Building a Payment System Under Sanctions
Most payment tutorials assume Stripe. Mine started with a constraint: nothing built outside the country would work. Here's what building Yarplus taught me about engineering money flows when the easy options are simply gone.
The constraint that defined everything
When you build for creators in Iran, the first architectural decision is made for you. Stripe, Patreon, PayPal, Whop — every global monetization rail is blocked by sanctions and payment restrictions. There is no "just add Stripe Checkout" step. The entire money layer has to run on domestic payment gateways, settle in Rial, and survive an environment where the tooling you'd reach for in a tutorial doesn't exist.
That constraint turned out to be the most useful teacher I've had. When you can't outsource trust to a brand-name processor, you have to understand what that processor was actually doing for you — and rebuild it yourself.
Gateways are not interchangeable
Domestic gateways follow a redirect-and-callback model: you create a payment request, send the user to the gateway's hosted page, and the gateway calls you back (and the user returns) with a result you then have to verify in a second server-to-server request. That verify step is non-negotiable — the return redirect alone is never proof of payment.
The practical lessons:
- Never trust the client redirect. A user landing back on your success URL means nothing until your backend independently confirms the transaction with the gateway.
- Treat the callback as hostile input. Validate the amount, the order reference, and the status against what you stored when you created the request — not against what the callback claims.
- Abstract the gateway behind an interface. I wrapped each provider in a common
PaymentProvidercontract so the rest of the system never knows which gateway handled a charge. When one provider has an outage, you want to reroute, not rewrite.
Idempotency is the whole game
The single most important thing I built was idempotency. Networks fail mid-callback. Users double-click. Gateways retry. Without protection, a single payment can get recorded twice — or a subscription can get granted twice for one charge.
Every payment intent got a unique key generated at creation time. The verify-and-fulfill step was wrapped so that processing the same key twice is a no-op that returns the original result. In SQL terms, the fulfillment write is guarded by a unique constraint on the transaction reference, and the handler is written to expect — and swallow — the duplicate-key collision gracefully.
If your payment handler isn't safe to call twice with the same input, it's not finished. It just hasn't failed yet.
Reconciliation, because callbacks lie by omission
Callbacks don't always arrive. The user closes the tab, the network drops, the gateway has a hiccup. So the money in the gateway's ledger and the state in your database drift apart. The fix is a reconciliation job: a scheduled process that walks every pending transaction older than a few minutes, asks the gateway "what actually happened here?", and reconciles your records to the source of truth.
This job caught real money. Subscriptions that should have activated but didn't, payments that succeeded at the gateway but never fulfilled on my side — reconciliation closed that gap. If I were starting again, I'd build it on day one instead of day thirty.
Trust without chargebacks
Global processors give you a fraud and dispute apparatus for free. Domestic rails mostly don't. There's no Stripe Radar, and the chargeback mechanics are different. So fraud prevention becomes your problem: rate-limiting payment attempts, watching for suspicious subscription churn, keeping an auditable trail of every state transition on a transaction.
I leaned on a simple principle — every change to a payment's state is an append-only event, never an in-place update. When something looks wrong, you can replay exactly what happened and when. That audit log was worth more than any clever heuristic.
What I'd tell my earlier self
- Model failure states first. "Pending," "verifying," "failed," "refunded," "reconciled" — the happy path is the easy 20%.
- Store money as integers. Smallest currency unit, always. Floating point and money don't mix.
- Log the boring stuff. The audit trail is your debugger, your fraud tool, and your customer-support tool all at once.
- Constraints are a gift. Being cut off from Stripe forced me to actually learn payments instead of gluing an SDK together.
Building money infrastructure under sanctions is harder than it should be. But it stripped the problem down to fundamentals, and those fundamentals — idempotency, verification, reconciliation, auditability — are exactly the things that matter on any payment system, Stripe or not.