<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pukar Khanal</title>
    <description>The latest articles on DEV Community by Pukar Khanal (@chessdream).</description>
    <link>https://dev.to/chessdream</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4089450%2F6e2b737e-ca01-4ad3-af96-ebbb14c028f1.png</url>
      <title>DEV Community: Pukar Khanal</title>
      <link>https://dev.to/chessdream</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chessdream"/>
    <language>en</language>
    <item>
      <title>I spent three months building Stripe for Nepal, alone</title>
      <dc:creator>Pukar Khanal</dc:creator>
      <pubDate>Sat, 22 Aug 2026 10:18:07 +0000</pubDate>
      <link>https://dev.to/chessdream/i-spent-three-months-building-stripe-for-nepal-alone-12km</link>
      <guid>https://dev.to/chessdream/i-spent-three-months-building-stripe-for-nepal-alone-12km</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Stripe doesn't work here, specifically
&lt;/h2&gt;

&lt;p&gt;The obvious answer is "Nepal isn't on the supported countries list." The real answer is more annoying.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I did not choose to build a regulated fintech. I chose to build recurring billing, and the market handed me a regulated fintech.&lt;/p&gt;

&lt;h2&gt;
  
  
  The law is a scanned PDF
&lt;/h2&gt;

&lt;p&gt;Here is the sequence I actually went through.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;grep&lt;/code&gt;, no copy-paste, no search. A picture of a legal document.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What came out, from Directive 5/079 section 3:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flow&lt;/th&gt;
&lt;th&gt;Per day (NPR)&lt;/th&gt;
&lt;th&gt;Per month (NPR)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bank account to wallet&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wallet to bank account&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wallet to wallet&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So caps are rows, not constants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;walletCapConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wallet_cap_config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;basic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enhanced&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wallet_to_wallet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bank_to_wallet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wallet_to_bank&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;overnight_balance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;per_load_count&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approval_threshold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;capPaisa&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;moneyPaisa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cap_paisa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="c1"&gt;// provenance travels with the number&lt;/span&gt;
  &lt;span class="na"&gt;directiveCitation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;directive_citation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;confidence&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;needsOfficialConfirmation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;needs_official_confirmation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;effectiveFrom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;effective_from&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;effectiveTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;effective_to&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money is a bigint or it's wrong
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;Two things made it stick. First, &lt;code&gt;pg&lt;/code&gt; returns &lt;code&gt;BIGINT&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nf"&gt;paisa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bigint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Money.paisa requires integer input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^-&lt;/span&gt;&lt;span class="se"&gt;?\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Money.paisa: invalid integer string "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the class has no escape hatch. No &lt;code&gt;.toNumber()&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  eSewa's duplicate transaction UUID
&lt;/h2&gt;

&lt;p&gt;Now the integration war story, because every payments post needs one.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total_amount=100.00,transaction_uuid=&amp;lt;uuid&amp;gt;,product_code=&amp;lt;code&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two more things worth writing down. eSewa wants amounts as decimal rupee strings, so &lt;code&gt;"100.00"&lt;/code&gt;, not paisa, which means a conversion at exactly one boundary and nowhere else. And their status API returns &lt;code&gt;NOT_FOUND&lt;/code&gt; for a transaction that exists but has not been paid yet. Treating &lt;code&gt;NOT_FOUND&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Khalti, by contrast, is a clean JSON REST API. Its one sharp edge is the auth header, which must be exactly &lt;code&gt;Key &amp;lt;secret&amp;gt;&lt;/code&gt;, capital K, lowercase rest, one space. Send anything else and the request fails without telling you why.&lt;/p&gt;

&lt;p&gt;Both providers sit behind an interface whose only required method is &lt;code&gt;initiate&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The month that isn't a month
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is a UI curiosity right up until it becomes an AML requirement.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The month-length data is bit-packed, two bits per month, base 29:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;daysInBsMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ENCODED_MONTH_LENGTHS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;BS_YEAR_ZERO&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No BS calendar data for year &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; BS`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger you cannot UPDATE
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;psql&lt;/code&gt; session goes straight around it.&lt;/p&gt;

&lt;p&gt;So the database role enforces it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wallet_ledger&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;trile_app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;trile_app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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 &lt;code&gt;CHECK &amp;gt; 0&lt;/code&gt; while direction carries the sign, so a signed amount can never disagree with its own direction field.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the AI part
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>typescript</category>
      <category>postgres</category>
      <category>nepal</category>
    </item>
    <item>
      <title>Why free chess analysis is always capped at one game a day</title>
      <dc:creator>Pukar Khanal</dc:creator>
      <pubDate>Sat, 22 Aug 2026 09:27:12 +0000</pubDate>
      <link>https://dev.to/chessdream/why-free-chess-analysis-is-always-capped-at-one-game-a-day-50n</link>
      <guid>https://dev.to/chessdream/why-free-chess-analysis-is-always-capped-at-one-game-a-day-50n</guid>
      <description>&lt;p&gt;Most free chess game review gives you one game per day. Chess.com works that way, and so does almost every smaller site offering the feature. I assumed for a long time that this was just a paywall placed where it hurts.&lt;/p&gt;

&lt;p&gt;It is partly that. But there is a real cost sitting behind the cap, and once I worked out what the cost was, I built my own analysis site differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of one game review
&lt;/h2&gt;

&lt;p&gt;Reviewing a 40 move game means evaluating about 80 positions. Give the engine two seconds on each one and you have spent close to three minutes of CPU. None of it is cacheable, because your game is not anyone else's game.&lt;/p&gt;

&lt;p&gt;Run that on your own hardware and you pay for every minute. A thousand people reviewing one game a day is roughly 50 CPU hours daily, for a feature you are giving away. The quota is not greed. It is the number that stops the free tier from eating the company.&lt;/p&gt;

&lt;p&gt;Which raises a more interesting question than "how do I price this". What happens if you delete the cost instead of rationing it?&lt;/p&gt;

&lt;h2&gt;
  
  
  Move the engine to the client
&lt;/h2&gt;

&lt;p&gt;Stockfish compiles to WebAssembly. Put it in a Web Worker and the visitor's own processor spends those three minutes. Your server ships static files and never sees a chess position.&lt;/p&gt;

&lt;p&gt;The whole free tier problem disappears, because there is no per-user cost left to control. Nothing to meter, so nothing to cap.&lt;/p&gt;

&lt;p&gt;Getting started is unremarkable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stockfish.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;uci&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;isready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that you speak UCI over &lt;code&gt;postMessage&lt;/code&gt;. Set a position, ask the engine to think, and read results off the message stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`position fen &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fen&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`go depth 15 movetime 2000`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the pitch. Now the parts nobody mentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol is strings, and it is asynchronous
&lt;/h2&gt;

&lt;p&gt;UCI was designed for a pipe between two processes. You get that pipe, faithfully, with all of its ergonomics intact. The engine answers with lines like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;info depth 15 seldepth 22 score cp -34 pv e2e4 e7e5 g1f3
bestmove e2e4 ponder e7e5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you write regexes against engine output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cpMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/score cp &lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;?\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mateMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/score mate &lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;?\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about this bite you. The first is that &lt;code&gt;info&lt;/code&gt; lines stream continuously as the search deepens, and only the last one before &lt;code&gt;bestmove&lt;/code&gt; reflects the depth you asked for. Read the wrong line and your evaluation comes from a depth 4 search. You have to hold the most recent value and commit it when &lt;code&gt;bestmove&lt;/code&gt; arrives.&lt;/p&gt;

&lt;p&gt;The second is that a mate score is not a centipawn score. &lt;code&gt;score mate 3&lt;/code&gt; means mate in three, and if you feed it through the same &lt;code&gt;/100&lt;/code&gt; you use for centipawns you get an evaluation of 0.03 for a forced win. Handle it as a separate branch or your graph will show a winning position as dead level.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, eighty positions
&lt;/h2&gt;

&lt;p&gt;The obvious way to review a game is a loop over the moves. The obvious way is also wrong on the first attempt, because a Worker is a single engine and &lt;code&gt;postMessage&lt;/code&gt; does not queue by request. Fire off 80 positions in a &lt;code&gt;Promise.all&lt;/code&gt; and you get 80 sets of interleaved output from one engine with no way to tell which line belongs to which position.&lt;/p&gt;

&lt;p&gt;So the loop has to be serial. Await each position, resolve on its &lt;code&gt;bestmove&lt;/code&gt;, then send the next. In my case that means attaching a listener per position and removing it on resolve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bestmove&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;eng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;evaluation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bestEval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestMove&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;engineLine&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forget the &lt;code&gt;removeEventListener&lt;/code&gt; and every position after the first has listeners from all previous positions still attached, each one resolving a promise that has already resolved. It does not throw. It just quietly produces wrong results, which is worse.&lt;/p&gt;

&lt;p&gt;Create the Worker once and terminate it on unmount. In React that is a &lt;code&gt;useEffect&lt;/code&gt; with an empty dependency array and a cleanup that calls &lt;code&gt;terminate()&lt;/code&gt;. Creating one per analysis leaks a Worker every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I got wrong: threads
&lt;/h2&gt;

&lt;p&gt;Stockfish is much stronger with multiple threads. Multi-threaded WASM needs &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, and since Spectre, browsers only hand you &lt;code&gt;SharedArrayBuffer&lt;/code&gt; on a cross origin isolated page. That means two response headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My site does not send them. I checked while writing this, which is a slightly embarrassing way to find out. So every analysis on chessdream.app today runs single threaded, and my depth 15 default is doing more work than it needs to for the strength it delivers.&lt;/p&gt;

&lt;p&gt;The reason it is not a one line fix is &lt;code&gt;require-corp&lt;/code&gt;. Turning it on breaks every cross origin resource that does not opt in with CORP headers, which on my site means the ad script and the analytics tags. That is a real tradeoff and not obviously worth it, given single threaded Stockfish at depth 15 already plays far above any human who is using a free analysis site to review their blitz games.&lt;/p&gt;

&lt;p&gt;Worth knowing before you promise yourself threads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning evaluations into words
&lt;/h2&gt;

&lt;p&gt;Users do not want centipawns. They want to be told that move 23 was the blunder. So you take the evaluation delta between consecutive positions and bucket it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;moveUci&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;bestMove&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;bestMoveSan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;moveSan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evalDiff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;brilliant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evalDiff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;great&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;best&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evalDiff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;excellent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evalDiff&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blunder&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to be honest that these thresholds are judgment, not mathematics. There is no objective centipawn value at which a move becomes a blunder. Every site that labels moves picked numbers that felt right, mine included, and the labels are a user interface decision wearing the costume of an engine output.&lt;/p&gt;

&lt;p&gt;One asymmetry to remember: the delta is from the mover's perspective. Black losing half a pawn is &lt;code&gt;evalDiff&lt;/code&gt; positive in raw terms and negative in meaning, so you flip the sign for black before bucketing. Skip that and half your board gets praised for its mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually pay
&lt;/h2&gt;

&lt;p&gt;Nothing here is free. Moving the engine to the client trades your costs for the user's.&lt;/p&gt;

&lt;p&gt;The download is real. On my deployment &lt;code&gt;stockfish.wasm&lt;/code&gt; is 546KB, plus 146KB of loader. There is also a 2.6MB asm.js fallback for browsers without WASM, which in 2026 is close to nobody, and I should probably drop it.&lt;/p&gt;

&lt;p&gt;Results vary by device, which is the part I find genuinely annoying. The same position on the same site gives a different evaluation on a four year old phone than on a desktop, because the phone reaches a lower depth in the same two seconds. Server side analysis is consistent for everyone. Client side analysis is as good as whatever the visitor is holding.&lt;/p&gt;

&lt;p&gt;And you get no telemetry on analysis quality, because the analysis never touches your servers. That is the same property that lets you promise nothing is uploaded. You cannot have both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;For this use case, yes, and not mainly for the cost saving.&lt;/p&gt;

&lt;p&gt;The privacy claim turns out to be the part people react to. "Your games never leave your browser" is not a policy or a promise to be trusted. It is a description of where the code runs, and anyone can open devtools and confirm there is no request. I did not expect that to matter as much as it does.&lt;/p&gt;

&lt;p&gt;The unlimited analysis follows from the architecture rather than from generosity. I am not choosing to be nice. There is simply no meter to read, so there is nothing to cap, and a business decision I would otherwise have had to make repeatedly is just gone.&lt;/p&gt;

&lt;p&gt;If you are building something where the expensive per-user work could run on the user's machine, it is worth pricing out the client side version before you design a quota. The quota is often just the shadow of a server bill.&lt;/p&gt;

&lt;p&gt;Site is at &lt;a href="https://chessdream.app" rel="noopener noreferrer"&gt;chessdream.app&lt;/a&gt; if you want to see it running. Happy to answer questions about the Worker plumbing, it is the part I spent the most time getting wrong.&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>chess</category>
    </item>
  </channel>
</rss>
