DEV Community

Pukar Khanal
Pukar Khanal

Posted on

I spent three months building Stripe for Nepal, alone

Stripe does not operate in Nepal. Neither does anything with its shape. If you run a business here and want a customer to pay you the same amount every month without thinking about it, your options are to ask them nicely, or to build the thing yourself.

So I built the thing myself. Recurring billing, a prepaid wallet, a double-entry ledger, KYC tiers, an AML reporting pipeline, a merchant dashboard, a hosted checkout, and a back-office console for staff. Roughly 2,500 commits across seven repos between mid-May and early August. The backend is about 46,000 lines of TypeScript across 22 NestJS modules, 31 migrations, 259 test files.

None of that is the interesting part. The interesting part is that almost every hard problem I hit came from the country, not from the code.

Why Stripe doesn't work here, specifically

The obvious answer is "Nepal isn't on the supported countries list." The real answer is more annoying.

Card penetration is low. Most people pay with a mobile wallet, and in practice that means eSewa or Khalti. Neither of them can charge a customer again next month. They do exactly one thing. You send the user to a hosted page, they approve one payment, you get a callback. That is a checkout, not a subscription.

Which means recurring billing in Nepal cannot be built on top of the payment rails. It has to be built beside them. The customer tops up a prepaid wallet through eSewa or Khalti, and the subscription bills against the wallet balance. The wallet is the recurring instrument. The wallet is also the thing that turns you into a regulated entity, which is where the fun starts.

Prepaid balance held on behalf of the public is Nepal Rastra Bank's business. NRB is the central bank and it regulates payment service providers directly. The moment you hold customer funds you inherit balance caps, load-frequency caps, KYC tiers, transaction reporting obligations, and audit retention rules.

I did not choose to build a regulated fintech. I chose to build recurring billing, and the market handed me a regulated fintech.

The law is a scanned PDF

Here is the sequence I actually went through.

I needed the wallet balance cap. Not a rough number, the real one, because the number goes into a database and it decides whether a top-up succeeds. I went looking for it.

NRB publishes a document called the Unified Directive on Payment Systems. It is in Nepali. The version I needed was a scan, meaning no text layer, meaning no grep, no copy-paste, no search. A picture of a legal document.

I ended up running the pages through an image-understanding model to pull the tables out, then corroborating each number against what eSewa and Khalti publish as their own customer-facing limits. A licensed PSP will not advertise a limit above what the law allows, so their published numbers are a useful check on mine.

What came out, from Directive 5/079 section 3:

Flow Per day (NPR) Per month (NPR)
Bank account to wallet 200,000 1,000,000
Wallet to bank account 200,000 1,000,000
Wallet to wallet 50,000 500,000

Plus a limit of ten loads per day per person, and an end-of-day balance ceiling of NPR 50,000 that I could corroborate from two licensed PSPs but never read in the amended primary text.

That last gap is the whole lesson. I had a number I was fairly sure about and could not fully verify. The instinct is to hardcode it and move on. That instinct is wrong, and not because of some abstract principle about magic numbers. It is wrong because NRB revises these figures roughly annually, with each new Bikram Sambat year's directive, and whoever updates that number next may not be me.

So caps are rows, not constants:

export const walletCapConfig = pgTable('wallet_cap_config', {
  id: text('id').primaryKey(),
  tier: text('tier', { enum: ['basic', 'standard', 'enhanced', 'all'] }).notNull(),
  flow: text('flow', {
    enum: ['wallet_to_wallet', 'bank_to_wallet', 'wallet_to_bank',
           'overnight_balance', 'per_load_count', 'approval_threshold'],
  }).notNull(),
  capPaisa: moneyPaisa('cap_paisa').notNull(),
  // provenance travels with the number
  directiveCitation: text('directive_citation'),
  confidence: text('confidence').notNull(),
  needsOfficialConfirmation: boolean('needs_official_confirmation').notNull(),
  effectiveFrom: timestamp('effective_from', { withTimezone: true }).notNull(),
  effectiveTo: timestamp('effective_to', { withTimezone: true }),
});
Enter fullscreen mode Exit fullscreen mode

Every cap row is effective-dated and carries where it came from and how sure I am. A row that sits above the NRB overnight ceiling is only valid if it carries a directive citation, and a service-level check enforces that. Raising a cap is itself a privileged action that needs a second person to approve it.

If you build for a regulated market, put the provenance next to the number. A cap with no citation is a cap nobody can safely change.

Money is a bigint or it's wrong

This one is not Nepal-specific but the currency made it sharper. Nepal uses NPR, and NPR subdivides into 100 paisa. Everything internal is integer paisa. No floats, anywhere, ever.

The part people get wrong is not the decision, it's the enforcement. It is very easy to write Money as a class, feel good, and then have someone six weeks later do Number(row.amount_paisa) because the types allowed it.

Two things made it stick. First, pg returns BIGINT columns as JavaScript strings by default, to avoid silent precision loss past 2^53. Correct behavior, and also a trap, because a string flows through arithmetic without complaining and produces garbage. So the constructor accepts the string shape explicitly and validates it:

static paisa(amount: bigint | number | string): Money {
  if (typeof amount === 'bigint') return new Money(amount);
  if (typeof amount === 'number') {
    if (!Number.isInteger(amount)) throw new Error('Money.paisa requires integer input');
    return new Money(BigInt(amount));
  }
  if (!/^-?\d+$/.test(amount)) {
    throw new Error(`Money.paisa: invalid integer string "${amount}"`);
  }
  return new Money(BigInt(amount));
}
Enter fullscreen mode Exit fullscreen mode

Second, the class has no escape hatch. No .toNumber(). No scalar multiply. No "split this into three equal parts" helper, because that helper is where the rounding bug lives. There are unit tests that assert those methods do not exist. A CI script rejects any money column declared outside the one custom Drizzle type that handles the bigint conversion.

Negative-existence tests feel silly to write. They are the only thing that stops a well-meaning future contributor from adding the convenience method that loses a paisa on every invoice.

eSewa's duplicate transaction UUID

Now the integration war story, because every payments post needs one.

eSewa's ePay v2 flow is a signed form POST. You build the fields, sign them, and the user's browser posts them to eSewa's form URL. The signature is HMAC-SHA256 over a specific comma-joined string, base64 encoded:

total_amount=100.00,transaction_uuid=<uuid>,product_code=<code>
Enter fullscreen mode Exit fullscreen mode

The obvious server-side implementation is to POST that form from your backend, follow the redirect, and hand the user the resulting URL. It does not work, and the failure is fantastic: eSewa replies "Duplicate transaction UUID."

The reason is that the first POST registers the transaction. When the browser then arrives with the same UUID, eSewa sees a UUID it already knows and rejects it. Your server posting the form is the duplicate. You have to build and sign the fields, return them to the client, and let the browser do the one and only POST.

Two more things worth writing down. eSewa wants amounts as decimal rupee strings, so "100.00", not paisa, which means a conversion at exactly one boundary and nowhere else. And their status API returns NOT_FOUND for a transaction that exists but has not been paid yet. Treating NOT_FOUND as a terminal failure will cancel live payments during the window when the user is still typing their PIN. It is a transient state. Map it to pending.

Khalti, by contrast, is a clean JSON REST API. Its one sharp edge is the auth header, which must be exactly Key <secret>, capital K, lowercase rest, one space. Send anything else and the request fails without telling you why.

Both providers sit behind an interface whose only required method is initiate. Verification stays provider-specific, because the two APIs disagree about almost everything, but settlement converges on one path shared by the polling worker and the webhook controller. Same shape, same code, same tests. When a provider changes something, one adapter changes.

The month that isn't a month

Nepal's official calendar is Bikram Sambat, and it is not a Gregorian calendar with an offset. Months run 29 to 32 days. The lengths do not follow a formula. There is no closed-form conversion, only lookup tables that get extended when the government publishes them. It is currently in the 2080s.

This is a UI curiosity right up until it becomes an AML requirement.

Nepal's Financial Information Unit requires threshold transaction reports for aggregate customer activity of NPR 1,000,000 or more within one month, and it means the Nepali calendar month. Not a 30-day window, not the Gregorian month. The report is due within 15 days of the transactions. Suspicious transaction reports are due within three working days, and Nepal's weekly holiday is Saturday only, not the two-day weekend most date libraries assume.

So the AML aggregation job has to compute a Bikram Sambat month boundary, anchored to Asia/Kathmandu, which is UTC+05:45 and observes no daylight saving. Get any of those three wrong and you produce a report that crosses the threshold on the wrong day.

The month-length data is bit-packed, two bits per month, base 29:

export function daysInBsMonth(year: number, month: number): number {
  const delta = ENCODED_MONTH_LENGTHS[year - BS_YEAR_ZERO];
  if (delta === undefined) throw new Error(`No BS calendar data for year ${year} BS`);
  return 29 + ((delta >>> ((month - 1) << 1)) & 3);
}
Enter fullscreen mode Exit fullscreen mode

The epoch is BS 1970-01-01 = AD 1913-04-13, and everything is whole-day arithmetic against that, with the Kathmandu offset applied so the result reflects Nepali wall-clock, not UTC. I copied the dataset verbatim from the same npm package the frontend uses, on purpose. Two independent implementations of a calendar with no formula will eventually disagree, and the disagreement will show up as a regulatory filing.

That the function throws for an unknown year is deliberate too. A calendar that silently extrapolates past its data is worse than one that stops.

The ledger you cannot UPDATE

The wallet ledger is append-only. Every credit and debit is a new row, and a correction is a reversing entry, never an edit. This is standard practice for anything that touches money, and every team says they do it.

Most teams enforce it in the service layer. Which is not enforcement. It is a convention with good intentions. Any script, any migration, any late-night psql session goes straight around it.

So the database role enforces it:

REVOKE UPDATE, DELETE ON wallet_ledger FROM trile_app;
REVOKE UPDATE, DELETE ON audit_log FROM trile_app;
Enter fullscreen mode Exit fullscreen mode

The application role physically cannot rewrite history. The service layer also exposes no update path, so it is guarded twice, but the REVOKE is the one that survives someone being clever.

Two design details I would keep in any ledger I build again. Balance before and balance after are stored on every row, not derived. Deriving them means replaying the whole wallet across a timestamp range, and an auditor asking "what was the balance at 14:32" should get an answer from one row. And the amount column has a CHECK > 0 while direction carries the sign, so a signed amount can never disagree with its own direction field.

The audit log goes one step further and hash-chains each entry to the previous one, so tampering is detectable rather than merely difficult. Privileged staff actions run through a maker-checker engine where whoever initiates cannot approve, enforced server-side, not in the UI.

About the AI part

I should be straight about this. A large amount of this code was written with an AI agent driving, under a planning workflow that produced a research document and a plan per phase before any code got written.

That is the honest reason a backend, five frontend apps and a compliance pipeline came out of one person in three months. It is not the reason the design decisions are what they are. The agent did not know that eSewa rejects a server-side form POST, or that FIU means the Nepali calendar month. Every one of those came from reading a primary source and then writing the constraint down where it could be enforced.

It is also worth saying what it did not save me from. The two worst bugs in the project were found in a milestone audit, after every phase had passed its own verification. Both were integration bugs. The step-up re-authentication dialog in the frontend verified against the wrong endpoint, so it never wrote the freshness marker the backend guard reads, which meant every privileged write in the admin console returned 403. The other was a compliance action the backend implemented and the frontend never gave anyone a button to trigger. Each side was tested. Neither contract was.

That failure mode is not new and it is not AI-specific. But an agent working phase by phase will happily produce two internally consistent halves that do not meet in the middle, and it will report both as done. If you work this way, budget for an integration pass that exercises the real seam. Phase-level verification will not find it.

What I'd do differently

Verify the regulation before designing around it. I built a graduated KYC tier ladder before confirming that NRB's model is closer to binary, verified or not. The ladder still works, it just maps onto a floor and a ceiling that were not designed for it.

Deploy earlier. The public directory site is feature-complete, verified, and not live, because the API it reads from was never deployed to production. Everything buildable got built. The last mile turned out to be the part I could not do from inside the repo, and I found that out at the end rather than the beginning.

And write down where every number came from. The wallet cap table has a confidence column and a source pointer on every row. That felt like overhead when I added it. Then I found an amendment that moved some of the figures, and the confidence column was the only thing that told me which rows to re-check and which were still solid.

If you're building payments in a market the big processors skip, most of your difficulty will not be in your code. It will be in a scanned PDF, in a calendar with no formula, and in an API that fails with a message describing a problem you did not cause. Budget for that. It is most of the work.

Top comments (0)