<?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: Rasikh</title>
    <description>The latest articles on DEV Community by Rasikh (@mashhadi).</description>
    <link>https://dev.to/mashhadi</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%2F1319642%2Ff8419919-99ca-487b-b67b-5813ecbd74cc.jpg</url>
      <title>DEV Community: Rasikh</title>
      <link>https://dev.to/mashhadi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mashhadi"/>
    <language>en</language>
    <item>
      <title>The provider timed out after committing</title>
      <dc:creator>Rasikh</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:05:24 +0000</pubDate>
      <link>https://dev.to/mashhadi/the-provider-timed-out-after-committing-101g</link>
      <guid>https://dev.to/mashhadi/the-provider-timed-out-after-committing-101g</guid>
      <description>&lt;p&gt;Your HTTP client raises &lt;code&gt;ETIMEDOUT&lt;/code&gt; on &lt;code&gt;POST /charges&lt;/code&gt;. Something has to be written to the database, the status column has two useful values, and so the catch block writes &lt;code&gt;failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The timeout isn't the bug. Timeouts are normal and you'll get another one this week. The bug is that &lt;code&gt;failed&lt;/code&gt; is a claim about the outside world, and at the moment you write it you are not in a position to make any claim at all. The provider may have taken the money 200 ms ago. You have asserted, in durable storage, that it didn't.&lt;/p&gt;

&lt;p&gt;Everything downstream now believes you. The customer gets an error and pays again. Support issues a refund for a charge your system says never happened. Reconciliation, six hours later, finds a movement on their side with nothing on yours and files it as a break, which is the only part of the system that behaved correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three outcomes, one symptom
&lt;/h2&gt;

&lt;p&gt;A request that doesn't come back has three possible histories, and they are indistinguishable from where you're standing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;What you saw&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The request never reached them&lt;/td&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It reached them, they committed, the response was lost&lt;/td&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It reached them, they committed, your process died before writing&lt;/td&gt;
&lt;td&gt;Timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's a fourth that's worth separating out, because it breaks the mental model people fall back on. The provider accepted and committed, and the underlying rail rejects it forty minutes later. That one isn't ambiguous, it's just slow, and it means "confirmed" is itself a state with a subsequent transition rather than an end.&lt;/p&gt;

&lt;p&gt;The standard error handler collapses all of this into a boolean. &lt;code&gt;try&lt;/code&gt; succeeded, &lt;code&gt;catch&lt;/code&gt; failed. That shape is fine when the failure costs you a retry and nothing else. It is wrong the moment the operation moves money, because in this domain the third outcome is not an exception case. During the incidents where processes are dying and connections are being reset, which is exactly when you most need to be right, it's the &lt;em&gt;common&lt;/em&gt; case.&lt;/p&gt;

&lt;p&gt;The correction is small to state and awkward to retrofit: &lt;strong&gt;unknown is a state your system holds, not an error it handles.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;provider_ref IS NULL&lt;/code&gt; is not a state machine
&lt;/h2&gt;

&lt;p&gt;Most systems already encode the unknown accidentally. There's a &lt;code&gt;payments&lt;/code&gt; row with &lt;code&gt;status = 'failed'&lt;/code&gt; and a null &lt;code&gt;provider_ref&lt;/code&gt;, and someone on the team knows that a null there sometimes means "we never got a response" and sometimes means "we never made the call". That knowledge lives in one person and expires when they change teams.&lt;/p&gt;

&lt;p&gt;The distinction has to be in the enum, because the enum is what queries filter on and dashboards group by.&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;create&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;payment_state&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;enum&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'intended'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;-- we have decided to pay, nothing has been sent&lt;/span&gt;
  &lt;span class="s1"&gt;'in_flight'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;-- a request is on the wire right now&lt;/span&gt;
  &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;-- the request ended without an answer&lt;/span&gt;
  &lt;span class="s1"&gt;'confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;-- the provider has told us it happened&lt;/span&gt;
  &lt;span class="s1"&gt;'settled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;-- it appeared in a statement we ingested&lt;/span&gt;
  &lt;span class="s1"&gt;'rejected'&lt;/span&gt;       &lt;span class="c1"&gt;-- the provider has told us it did not happen&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 that list are load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unknown&lt;/code&gt; and &lt;code&gt;rejected&lt;/code&gt; are different states, and only one of them is a fact. &lt;code&gt;rejected&lt;/code&gt; means the provider gave you an answer. &lt;code&gt;unknown&lt;/code&gt; means nobody has. Merging them is precisely the mistake that started this post.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;confirmed&lt;/code&gt; is not the last state. The provider saying yes over an API is their intent, not the money's arrival. Only &lt;code&gt;settled&lt;/code&gt; is backed by a statement, which is the part &lt;a href="https://mashhadi.me/blog/reconciliation-is-a-design-input" rel="noopener noreferrer"&gt;reconciliation&lt;/a&gt; exists to establish. A system that stops at &lt;code&gt;confirmed&lt;/code&gt; has substituted a promise for the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the intent down before you make the call
&lt;/h2&gt;

&lt;p&gt;The dangerous window isn't the call. It's the gap between deciding to pay and recording that you decided.&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="c1"&gt;// Broken. A crash on line 1 leaves no trace that money may be moving.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&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;card&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;providerRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirmed&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;If the process dies inside &lt;code&gt;provider.charge&lt;/code&gt;, there is no row. Nothing to reconcile, nothing to poll, nothing to alert on. The money left and your database has never heard of it. This is worse than a wrong state, because a wrong state at least shows up in a query.&lt;/p&gt;

&lt;p&gt;Commit the intent first, then call:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;intended&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// generated here, stored here, reused forever&lt;/span&gt;
  &lt;span class="na"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ourReference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// the join key reconciliation will need&lt;/span&gt;
  &lt;span class="na"&gt;expectedResolutionBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;addBusinessHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in_flight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;try&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;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&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;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;This is write-ahead logging with the business as the log. The row exists before the side effect, so every crash leaves something behind that knows to go and ask.&lt;/p&gt;

&lt;p&gt;Note what &lt;code&gt;classify&lt;/code&gt; is allowed to return. A response that says no is &lt;code&gt;rejected&lt;/code&gt;. A connection that was refused before any bytes left the machine is &lt;code&gt;rejected&lt;/code&gt; too, because the request provably never arrived. Everything else, including every read timeout, is &lt;code&gt;unknown&lt;/code&gt;. If you can't tell which kind of timeout you got, it's &lt;code&gt;unknown&lt;/code&gt;. Guessing in the safe direction here costs you a poll; guessing in the other direction costs you a duplicate payment.&lt;/p&gt;

&lt;p&gt;The idempotency key belongs on that row for the same reason. It's a property of the intent, not of the attempt, which is the &lt;a href="https://mashhadi.me/blog/idempotency-is-a-contract" rel="noopener noreferrer"&gt;contract&lt;/a&gt; the retry depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown needs a deadline and an owner
&lt;/h2&gt;

&lt;p&gt;A state that nothing is responsible for leaving is just a slower way of losing data. &lt;code&gt;unknown&lt;/code&gt; is only useful if something is obliged to resolve it, and the obligation has to be written into the row rather than into a runbook.&lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;expected_resolution_by&lt;/code&gt; is for. It gives you two queries that a human can act on:&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="c1"&gt;-- Working as designed: young unknowns, being polled.&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;expected_resolution_by&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;-- The alert. Something is stuck and a person needs to look.&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;expected_resolution_by&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Alert on the age of the oldest unknown, not on the count.&lt;/strong&gt; Volume tracks traffic and provider health, and it's noisy in a way that trains people to ignore it. Age tracks whether resolution is working at all. Fifty unknowns that all clear within four minutes is a healthy system on a bad network. One unknown from Tuesday is an outstanding question about real money, and it will still be there in March unless someone is told.&lt;/p&gt;

&lt;p&gt;The pressure you have to resist is organisational, not technical. A queue of unknowns is uncomfortable to look at, and somebody will propose a nightly job that ages them out to &lt;code&gt;failed&lt;/code&gt; so the dashboard goes green. That job converts an open question into a false answer, which is the only genuinely irreversible operation in this whole design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask, but know which answer is authoritative
&lt;/h2&gt;

&lt;p&gt;There are three ways an unknown resolves, and they differ in latency and in how much you should believe them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query the provider by your own key.&lt;/strong&gt; Fastest, and available on demand. Most payment APIs let you look up by idempotency key or by your reference; the ones that only let you look up by &lt;em&gt;their&lt;/em&gt; ID are useless here, because their ID is exactly what you didn't receive. This is the check you run on a backoff schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait for a webhook.&lt;/strong&gt; Free and quick when it works, and it is not a plan on its own. Webhooks are at-least-once at best, they arrive out of order, and they get dropped by your own load balancer during the incident that caused the unknowns in the first place. Treat an inbound webhook as a hint that makes you poll, not as the source of truth.&lt;/p&gt;

&lt;p&gt;The webhook race is worth naming, because it produces a bug that looks impossible. Their event can arrive before your own transaction commits. Your handler looks up the payment, finds nothing, logs "unknown webhook" and drops it. The confirmation you needed most is the one you threw away. Persist unmatched webhooks in their own table, keyed by the reference they carry, and re-match them when the payment appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the settlement file.&lt;/strong&gt; Slowest, usually a day, and the only one of the three that constitutes evidence. The API says what the provider believes. The statement says what the bank did. When those two disagree, and they do, the statement wins.&lt;/p&gt;

&lt;p&gt;So the polling loop is a convenience that closes most unknowns in minutes, and the file is the backstop that closes the rest. Build both. A system with only the poll has no answer when the provider's API is the thing that's broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money in flight is a balance
&lt;/h2&gt;

&lt;p&gt;Here's the part that turns this from an engineering pattern into something the finance team can use: an unknown isn't only a row state, it's an amount, and that amount has to be somewhere in the ledger.&lt;/p&gt;

&lt;p&gt;The usual instinct is to write no entries until the payment confirms. That keeps the ledger clean and makes it lie: the customer's money has left their account and, according to your books, has not gone anywhere. Double entry has an answer for this that predates all of us, which is a suspense account.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;On intent:      DR Customer               100.00
                CR Payments in transit    100.00

On confirmation: DR Payments in transit   100.00
                 CR Provider clearing     100.00

On rejection:    DR Payments in transit   100.00
                 CR Customer              100.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every leg is a new entry rather than an edit, so the history of an unknown that eventually resolved is still readable a year later. That's the same append-only discipline reconciliation depends on.&lt;/p&gt;

&lt;p&gt;What you get for it is a number: the balance of "payments in transit" is your total exposure to unresolved outcomes, in currency, at any instant. Graph it. It should be small and it should spike and drain. A balance that only grows means resolution has quietly stopped working, and you'll see that on a chart weeks before anyone opens the payments table.&lt;/p&gt;

&lt;p&gt;It also gives the unknown a home in the accounts. Nobody has to invent a policy for it during a close, because it was already a line item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancelling an unknown is another unknown
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut, when you have a payment you're unsure about, is to void it and start clean.&lt;/p&gt;

&lt;p&gt;You can't. &lt;code&gt;POST /charges/void&lt;/code&gt; needs the charge ID you never received, and the variants keyed on your reference have the identical failure mode: their response can time out too. Now you have an unknown cancellation of an unknown payment, and the state space has squared rather than shrunk.&lt;/p&gt;

&lt;p&gt;Unknowns resolve by asking, not by acting. The only safe operations while you're in that state are reads. Once you know a charge exists, refunding it is a normal, well-defined thing with its own record, and a refund is a new fact rather than an erasure of the old one.&lt;/p&gt;

&lt;p&gt;This is also why the answer to "should we retry?" is no, not until you've asked. Retrying a read timeout is safe if and only if the idempotency key travels with it and the provider still recognises it. Both halves have to hold. Their key window is often shorter than the age of your oldest unknown, and once it lapses, your retry is a brand new charge as far as they're concerned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your timeout is a policy, not a network fact
&lt;/h2&gt;

&lt;p&gt;The number of unknowns you have to handle is partly your own choice, made in a config file, usually by accident.&lt;/p&gt;

&lt;p&gt;If your client's read timeout is 10 seconds and the provider's p99 is 14, you are manufacturing unknowns at roughly one percent of traffic, deterministically, forever. Every one of those requests is going to succeed on their side. You just decided not to wait for the news. Look up the number, don't assume it: the ones the provider publishes are for the happy path, and card authorisations that route through 3-D Secure or a slow issuer are not the happy path.&lt;/p&gt;

&lt;p&gt;Two adjustments follow. Set the read timeout above the provider's real tail latency, and give the write path its own timeout budget rather than inheriting the API gateway's. And separate the two timeouts in your client configuration, because connect and read mean genuinely different things here: a connection you never established is a request that never happened, and a response you never read is a question.&lt;/p&gt;

&lt;p&gt;Then check what your infrastructure does underneath you. A 30 second idle timeout on a load balancer, a proxy that retries idempotent methods on its own, a client library that quietly retries once by default: any of those turns a clean request into an ambiguous one without appearing in your code. The library default is the one that catches people, because it's a retry you didn't write and therefore didn't attach a key to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the customer sees
&lt;/h2&gt;

&lt;p&gt;None of this reaches the user as "unknown". They get three states, and the middle one is the whole point of the exercise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Processing.&lt;/strong&gt; Honest, and the correct answer for as long as you're unsure. Give it an expected duration so it doesn't read as a hang.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirmed&lt;/strong&gt;, once you have an answer you'd defend in a dispute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed&lt;/strong&gt;, only when the provider said no.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing you have to stop is the customer resolving the ambiguity for you by pressing Pay again. Their second attempt is a genuinely different request with a different key, and idempotency will process it exactly as instructed. What stops it is a business uniqueness constraint: one non-terminal payment per invoice, enforced in the database, not in the button's disabled attribute.&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;create&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;one_open_payment_per_invoice&lt;/span&gt;
    &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'intended'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'in_flight'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That partial index is about fifteen minutes of work and it removes the most common way an unknown becomes a duplicate charge: an anxious human with a working mouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't buy you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not fewer unknowns.&lt;/strong&gt; The count is set by network reality and your timeout policy. This makes them visible, bounded and resolvable, which is the whole available win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not idempotency.&lt;/strong&gt; Modelling the state doesn't make the retry safe. The key does, and it has to have been on the row before the first attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not reconciliation.&lt;/strong&gt; Polling closes an unknown against the provider's opinion. Only the statement closes it against the bank's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not correctness.&lt;/strong&gt; A payment can be fully confirmed, fully settled, perfectly reconciled and still the wrong amount to the wrong person. Nothing here has an opinion about intent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a resolution guarantee.&lt;/strong&gt; Some unknowns need a human and a phone call. The design's job is to make sure there are three of those a quarter and that each one is found in hours, not that the number reaches zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A timeout is not a failure. Writing &lt;code&gt;failed&lt;/code&gt; on one is a claim you can't support.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unknown&lt;/code&gt; belongs in the enum, next to &lt;code&gt;rejected&lt;/code&gt; and distinct from it.&lt;/li&gt;
&lt;li&gt;Commit the intent, with its key and its reference, before the call that can vanish.&lt;/li&gt;
&lt;li&gt;Only a connection that was never established counts as "never happened". Every read timeout is unknown.&lt;/li&gt;
&lt;li&gt;Give every unknown a resolution deadline, and alert on the age of the oldest, never the count.&lt;/li&gt;
&lt;li&gt;Poll by your own key. Treat webhooks as hints, and store the ones that arrive before the payment exists.&lt;/li&gt;
&lt;li&gt;The settlement file is the only authority. The API is an opinion, however confident.&lt;/li&gt;
&lt;li&gt;Put money in flight in a suspense account, so the exposure is a balance somebody can graph.&lt;/li&gt;
&lt;li&gt;Never cancel an unknown. Reads only, until you know.&lt;/li&gt;
&lt;li&gt;One open payment per invoice, as a partial unique index, or the customer will resolve the ambiguity for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things to go and look at today. Grep your codebase for catch blocks around a provider call and read what each one writes to the database; every &lt;code&gt;status = 'failed'&lt;/code&gt; on a timeout is a future refund request for a charge you'll swear never happened. Then find your client's read timeout and put it next to the provider's p99 latency. If the first number is smaller, you already know how many unknowns you're creating a day, and you've never seen a single one of them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mashhadi.me/blog/money-in-flight" rel="noopener noreferrer"&gt;mashhadi.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>api</category>
      <category>programming</category>
    </item>
    <item>
      <title>Idempotency is not a key, it's a contract</title>
      <dc:creator>Rasikh</dc:creator>
      <pubDate>Sun, 23 Aug 2026 05:35:45 +0000</pubDate>
      <link>https://dev.to/mashhadi/idempotency-is-not-a-key-its-a-contract-534h</link>
      <guid>https://dev.to/mashhadi/idempotency-is-not-a-key-its-a-contract-534h</guid>
      <description>&lt;p&gt;Every payments API has an &lt;code&gt;Idempotency-Key&lt;/code&gt; header. Far fewer have written down what it means, and the ones that haven't are usually running a &lt;code&gt;SELECT … WHERE key = ?&lt;/code&gt; before the insert and calling the job done.&lt;/p&gt;

&lt;p&gt;A key is a token. A contract is a set of answers: what counts as the same request, how long the answer stays valid, what a caller gets when their retry lands while the first attempt is still running, and which failures are worth remembering. Skip those and you've made double charges rarer without making them impossible, which is the worst outcome available: now nobody is watching for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The promise is "same outcome", not "skip the duplicate"
&lt;/h2&gt;

&lt;p&gt;HTTP already has idempotent methods. RFC 9110 defines &lt;code&gt;PUT&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; that way: sending the request twice leaves the server in the same state as sending it once. &lt;code&gt;POST&lt;/code&gt; is deliberately excluded, which is why the header exists at all. It's a way to bolt the property onto the one method that doesn't have it.&lt;/p&gt;

&lt;p&gt;Notice what the definition covers and what it doesn't. It constrains &lt;em&gt;server state&lt;/em&gt;. It says nothing about what the caller sees, and the caller is the entire reason you're doing this. Here's the handler people write first:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&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;State is protected. The client is not. They retried because they never saw the first response: the socket died, the load balancer timed out, the phone lost signal in a lift. What they need back is the charge id from attempt one. What they got is &lt;code&gt;{"status":"ok"}&lt;/code&gt;, which tells them a charge exists somewhere with an id they will never learn.&lt;/p&gt;

&lt;p&gt;So the promise has two halves, and both matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The work happens at most once.&lt;/li&gt;
&lt;li&gt;Every retry gets back the &lt;em&gt;original&lt;/em&gt; response, with the same status and the same body, byte for byte.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second half is what turns your endpoint into something a client can safely retry in a loop. Without it, retries are safe for you and useless for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts as "the same request"
&lt;/h2&gt;

&lt;p&gt;The key is not the identity of the request. It's a name the client picked for one. The identity is the key &lt;em&gt;plus&lt;/em&gt; what was in the body.&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;POST /payments   Idempotency-Key: 7f3a…   { "amount": 5000, "to": "acct_1" }
POST /payments   Idempotency-Key: 7f3a…   { "amount": 9000, "to": "acct_1" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replay the first response and you've silently refused to send $90 while telling the caller you sent it. Execute the second and the key bought you nothing. Neither is acceptable, which means there's only one correct answer: reject it. Store a fingerprint of the request alongside the key and compare on every hit.&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/** Stable hash of the parts of a request that change what it *does*. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canonicalJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&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;Stripe returns a &lt;code&gt;400&lt;/code&gt; with an &lt;code&gt;idempotency_error&lt;/code&gt; here. &lt;code&gt;422&lt;/code&gt; is defensible too. What isn't defensible is picking one of the two bodies and proceeding.&lt;/p&gt;

&lt;p&gt;The trap in this section is &lt;code&gt;canonicalJson&lt;/code&gt;. Fingerprint the raw bytes and you'll ship false rejections, because a retry is very often a &lt;em&gt;re-serialisation&lt;/em&gt;, not a replay of the same buffer. Two things reliably bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key order.&lt;/strong&gt; A client that builds the payload from a hash map may emit fields in a different order the second time. Same request, different bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamps.&lt;/strong&gt; A &lt;code&gt;client_ts&lt;/code&gt; or &lt;code&gt;requested_at&lt;/code&gt; field filled in with &lt;code&gt;Date.now()&lt;/code&gt; at send time changes on every attempt. Now every retry is a fingerprint mismatch and your safest clients get the most errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Canonicalise (sort keys, normalise numbers), or fingerprint an explicit allowlist of the fields that carry meaning. Both work. Hashing the whole body verbatim does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race is the actual problem
&lt;/h2&gt;

&lt;p&gt;Check-then-insert is a time-of-check-to-time-of-use bug wearing a business shirt. Two retries arrive 4 ms apart, both &lt;code&gt;SELECT&lt;/code&gt; and miss, both charge. This is not an exotic interleaving; it's the &lt;em&gt;normal&lt;/em&gt; shape of a retry storm, because whatever made the client retry (a timeout) also tends to make it retry more than once.&lt;/p&gt;

&lt;p&gt;Let the database arbitrate. Insert first, on the way in.&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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;account_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;endpoint&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt;             &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;fingerprint&lt;/span&gt;     &lt;span class="n"&gt;bytea&lt;/span&gt;       &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;state&lt;/span&gt;           &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
                  &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'in_flight'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'succeeded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;response_status&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;response_body&lt;/span&gt;   &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;resource_id&lt;/span&gt;     &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;locked_until&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;key&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`insert into idempotency_keys (account_id, endpoint, key, fingerprint, state, locked_until)
   values ($1, $2, $3, $4, 'in_flight', now() + interval '60 seconds')
   on conflict (account_id, endpoint, key) do nothing
   returning key`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fp&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;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// We own this key. Do the work, then write the response back.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero rows means someone else owns it, and the existing row tells you what to do:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stored state&lt;/th&gt;
&lt;th&gt;What the caller gets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fingerprint differs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;400&lt;/code&gt;: same key, different request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;succeeded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The stored status and body, replayed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;failed&lt;/code&gt;, deterministic&lt;/td&gt;
&lt;td&gt;The stored status and body, replayed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;in_flight&lt;/code&gt;, lock live&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;409&lt;/code&gt; + &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;in_flight&lt;/code&gt;, lock expired&lt;/td&gt;
&lt;td&gt;Recovery. See below; do &lt;strong&gt;not&lt;/strong&gt; just re-run it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;409&lt;/code&gt; is worth defending, because the tempting alternative is to block: wait on a row lock until the first attempt finishes, then return its response. It reads beautifully and it's a bad idea at load. Every waiter is a held connection, so a provider that's gone slow converts directly into pool exhaustion, and the requests you're holding are by definition from clients that already gave up once. Hand back &lt;code&gt;409&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; and let the client's backoff do the waiting. It has a much better place to do it than your connection pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key has to outlive the process that made it
&lt;/h2&gt;

&lt;p&gt;Server-generated keys don't work. Fetching one is itself a network call that can time out, and now you need idempotency for your idempotency endpoint. So the client owns the key, and where the client stores it decides whether any of this functions.&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="c1"&gt;// Broken: a new key on every attempt. This is a plain retry loop with extra steps.&lt;/span&gt;
&lt;span class="k"&gt;for &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;delay&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/payments&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&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;span class="c1"&gt;// Also broken: survives the loop, not a crash. The retry after restart is a new key.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key belongs next to the intent, in whatever durable thing already represents "we mean to pay this": the row in your own database, the payload in the job queue. Generate it once, when the intent is created, and read it back on every attempt including the ones that happen after a deploy, a pod eviction, or somebody replaying a dead-letter queue by hand on Monday morning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The idempotency key is a property of the job, not of the attempt.&lt;/strong&gt; If you take one line from this post, take that one. Most idempotency that fails in production fails here, in the client, in code nobody thought of as payment code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope it, or you'll replay someone else's response
&lt;/h2&gt;

&lt;p&gt;Make the key composite: &lt;code&gt;(account_id, endpoint, key)&lt;/code&gt;. Global uniqueness on the key column alone has two failure modes, and one of them is a security incident.&lt;/p&gt;

&lt;p&gt;Two endpoints sharing a key namespace means a client that reuses &lt;code&gt;order-8812&lt;/code&gt; for both &lt;code&gt;/payments&lt;/code&gt; and &lt;code&gt;/refunds&lt;/code&gt; gets the payment response back from the refund call. Annoying.&lt;/p&gt;

&lt;p&gt;Two &lt;em&gt;tenants&lt;/em&gt; sharing a namespace is worse. Keys are frequently derived from things that aren't secret (an order number, an invoice id, a &lt;code&gt;checkout-2026-08-12-0001&lt;/code&gt;), so tenant B can arrive with a key tenant A already used and receive A's stored response body. You built a cross-tenant read out of a deduplication table. Scope by account and the whole class disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window is a promise about time, and 24 hours is usually a guess
&lt;/h2&gt;

&lt;p&gt;Stripe expires keys after 24 hours. That's a reasonable default for a browser and a bad fit for a lot of what actually retries.&lt;/p&gt;

&lt;p&gt;Think about the longest path a retry of your endpoint can take. A queue with seven-day retention. An operator replaying Friday's failures when they get in on Monday. A mobile client that was in a tunnel, then a plane, then a country with roaming disabled. If your window is 24 hours and your job queue retries for 7 days, then on day two the same key is a &lt;em&gt;new&lt;/em&gt; key and the retry is a fresh charge, with the idempotency system fully installed, monitored, and reporting green.&lt;/p&gt;

&lt;p&gt;The window must be at least as long as the longest retry horizon of any caller you have. When you can't bound that, split the record instead of expiring it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The response body is the expensive part.&lt;/strong&gt; JSONB of a full resource, times every write request. Expire that on the usual timescale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The key, fingerprint and &lt;code&gt;resource_id&lt;/code&gt; are 100 bytes.&lt;/strong&gt; Keep them for a year.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A retry that arrives after the body is gone but while the key survives is still recognisable. Return &lt;code&gt;409&lt;/code&gt; and point at &lt;code&gt;resource_id&lt;/code&gt;. "You already did this, it's payment &lt;code&gt;pay_…&lt;/code&gt;, go look" is an unhelpful answer that leaves the caller with a lookup to do. It's also infinitely better than charging them again, which is the only other option once you've forgotten the key entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency is not atomicity
&lt;/h2&gt;

&lt;p&gt;This is the part that separates a key column from a system that works.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&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;card&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ← crash here&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The process dies between those two lines. The provider has taken the money. Your ledger doesn't know. The key row says &lt;code&gt;in_flight&lt;/code&gt; and will say so until the lock expires. The key prevented a duplicate &lt;em&gt;request&lt;/em&gt;; it did nothing about partial &lt;em&gt;work&lt;/em&gt;, because it was never the kind of thing that could.&lt;/p&gt;

&lt;p&gt;An expired &lt;code&gt;in_flight&lt;/code&gt; lock is an &lt;strong&gt;unknown&lt;/strong&gt;, not a failure. That distinction is the whole game. Treat it as a failure and re-run the handler and you have built a double-charge machine whose trigger is your own worst outage: the one where processes were dying mid-request. Two things make the recovery path safe, and you want both:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push your key downstream.&lt;/strong&gt; Derive the provider's idempotency key deterministically from yours, so a re-run is also a re-run to &lt;em&gt;them&lt;/em&gt;:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;providerKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:provider:v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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;Now re-running the handler after a crash is safe at the boundary that matters, because the provider recognises the second call as the same one. The &lt;code&gt;:v1&lt;/code&gt; is there so you can roll the derivation without every historical key changing meaning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconcile before you retry.&lt;/strong&gt; On finding an expired lock, ask the provider what happened to &lt;code&gt;providerKey&lt;/code&gt; before doing anything else. If they have a charge, adopt it, write the ledger row you owe, and mark the key &lt;code&gt;succeeded&lt;/code&gt;. Only when they've never heard of it do you execute.&lt;/p&gt;

&lt;p&gt;Skipping the reconciliation step and relying purely on the downstream key mostly works, right up until the downstream key window is shorter than yours. Now you know to check that number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't cache failures you'd want retried
&lt;/h2&gt;

&lt;p&gt;The last decision the contract owes an answer to: when the work fails, does the key remember?&lt;/p&gt;

&lt;p&gt;It depends entirely on whether re-running would fail the same way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic failures&lt;/strong&gt; (schema validation, a hard decline, an unknown account) get stored and replayed. Re-running produces the identical error and costs you a provider call to learn it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transient failures&lt;/strong&gt; (provider &lt;code&gt;503&lt;/code&gt;, a socket timeout, a serialisation conflict, a pod OOM) release the key. Set it back to unclaimed, or clear the lock so the next attempt with the same key claims it cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get this backwards and you build a trap. Seal the key as &lt;code&gt;failed&lt;/code&gt; on a &lt;code&gt;500&lt;/code&gt; and the client's retry, using the same key, doing exactly what your docs told them to do, receives a permanent &lt;code&gt;500&lt;/code&gt;. Their only way out is a new key, which is precisely the double charge the header existed to prevent. You'd have been safer with no idempotency at all, because then at least the retry would have worked.&lt;/p&gt;

&lt;p&gt;The rule fits on one line: &lt;strong&gt;cache a failure if and only if re-running it would produce the same failure.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the contract actually says
&lt;/h2&gt;

&lt;p&gt;If your API documentation can't answer these, you don't have idempotency. You have a column.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who generates the key, and what should it be derived from?&lt;/li&gt;
&lt;li&gt;What's the scope: per account, per endpoint, or both?&lt;/li&gt;
&lt;li&gt;How long does a key live, and what happens on the first request after that?&lt;/li&gt;
&lt;li&gt;What defines "same request", and what's returned when the key matches but the body doesn't?&lt;/li&gt;
&lt;li&gt;What comes back while the first attempt is still running?&lt;/li&gt;
&lt;li&gt;Which failures are remembered and which are retryable?&lt;/li&gt;
&lt;li&gt;Is a replayed response marked as one? (An &lt;code&gt;Idempotent-Replay: true&lt;/code&gt; header costs nothing and makes client-side debugging dramatically less miserable.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seven answers. Publish them and callers can write a retry loop with confidence. Leave them implicit and every integrator discovers your semantics through an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't buy you
&lt;/h2&gt;

&lt;p&gt;Four honest limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not ordering.&lt;/strong&gt; Idempotent isn't commutative. A retried update and a retried cancel can still land in either order, and each one being safe on its own says nothing about the pair.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not exactly-once.&lt;/strong&gt; There's no exactly-once delivery over a network; the FLP result and every practical system agree. What you get is at-least-once delivery plus an idempotent receiver, which adds up to effectively-once. That's the real ceiling and it's enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not double-submit protection.&lt;/strong&gt; A user hammering Pay twice sends two different keys for two genuinely distinct requests, and idempotency will faithfully process both. That's a business uniqueness constraint (one pending payment per invoice), enforced somewhere else entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not correctness.&lt;/strong&gt; Every one of these mechanisms protects a &lt;em&gt;duplicate&lt;/em&gt;. None of them notices that the amount was wrong, the account was wrong, or the rate was stale.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Return the original response, not a bare acknowledgement. Retries need the resource id.&lt;/li&gt;
&lt;li&gt;Fingerprint the request. Same key with a different body is an error, never a coin flip.&lt;/li&gt;
&lt;li&gt;Insert first and let a unique constraint settle the race. Check-then-insert loses.&lt;/li&gt;
&lt;li&gt;Answer in-flight requests with &lt;code&gt;409&lt;/code&gt; and &lt;code&gt;Retry-After&lt;/code&gt;. Don't block on a lock.&lt;/li&gt;
&lt;li&gt;The client generates the key, and stores it with the intent, not inside the retry loop.&lt;/li&gt;
&lt;li&gt;Scope keys per account and per endpoint. Global keys leak responses across tenants.&lt;/li&gt;
&lt;li&gt;Size the window against your slowest retry path, and keep the key long after you drop the body.&lt;/li&gt;
&lt;li&gt;An expired in-flight record means &lt;em&gt;unknown&lt;/em&gt;. Reconcile before re-running.&lt;/li&gt;
&lt;li&gt;Pass a derived key downstream so a re-run is a retry all the way through.&lt;/li&gt;
&lt;li&gt;Remember deterministic failures. Release transient ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A concrete place to start: search your codebase for a &lt;code&gt;SELECT&lt;/code&gt; on an idempotency table that runs before the corresponding &lt;code&gt;INSERT&lt;/code&gt;. Every one of those is a double charge waiting for a slow afternoon, and the fix is a unique constraint and about twenty lines. Then go and read your queue's maximum retention, and compare it against your key expiry. Those two numbers disagree far more often than anyone expects.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mashhadi.me/blog/idempotency-is-a-contract" rel="noopener noreferrer"&gt;mashhadi.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>programming</category>
    </item>
    <item>
      <title>Money as a data type</title>
      <dc:creator>Rasikh</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:35:30 +0000</pubDate>
      <link>https://dev.to/mashhadi/money-as-a-data-type-14pk</link>
      <guid>https://dev.to/mashhadi/money-as-a-data-type-14pk</guid>
      <description>&lt;p&gt;Most guides open with &lt;code&gt;0.1 + 0.2 === 0.30000000000000004&lt;/code&gt; and conclude "don't use floats for money." True, and not very useful. The interesting question is what you replace it with, because "use decimals" and "use integers" are different answers that fail in different places. Neither of them addresses the bug most likely to reach production: a function that cheerfully adds 500 US dollars to 500 Japanese yen and returns 1000 of nothing.&lt;/p&gt;

&lt;p&gt;Money isn't a number. It's a number, a currency, a scale, and a rounding policy, and if your type only carries the first one the other three end up scattered across call sites as assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Floats lose money in ways that survive your tests
&lt;/h2&gt;

&lt;p&gt;The accumulation bug is the famous one:&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;let&lt;/span&gt; &lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;balance&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="c1"&gt;// false&lt;/span&gt;
&lt;span class="nx"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 0.9999999999999999&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is easy to catch, because the result looks obviously wrong the moment you print it. Here's the version that doesn't:&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.005&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;1.01&lt;/code&gt;. The nearest double to &lt;code&gt;1.005&lt;/code&gt; is &lt;code&gt;1.00499999999999989342&lt;/code&gt;, so multiplying by 100 gives &lt;code&gt;100.49999999999999&lt;/code&gt;, and rounding that down is arithmetically correct. Round-to-nearest-cent is the single operation you would never think to unit test, and it is wrong for a value a human typed into a form.&lt;/p&gt;

&lt;p&gt;There's also a ceiling. Doubles represent integers exactly only up to &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt;, which is &lt;code&gt;9007199254740991&lt;/code&gt;. In cents that's about 90 trillion dollars, so you're fine, right up until someone stores an amount in a currency with a smaller unit or you accumulate a notional across a whole book.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decimals fix the arithmetic, not the model
&lt;/h2&gt;

&lt;p&gt;Swap in &lt;code&gt;decimal.js&lt;/code&gt;, Java's &lt;code&gt;BigDecimal&lt;/code&gt;, or Postgres &lt;code&gt;NUMERIC&lt;/code&gt; and &lt;code&gt;1.005&lt;/code&gt; is exactly &lt;code&gt;1.005&lt;/code&gt;. Every arithmetic complaint above goes away.&lt;/p&gt;

&lt;p&gt;What doesn't go away:&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;new&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;500&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;500&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thousand what? A &lt;code&gt;Decimal&lt;/code&gt; carries no currency, no scale policy, and no rounding mode. You've fixed the representation and kept the hole in the type, and in my experience essentially every expensive money bug lives in that hole rather than in the third decimal place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store minor units, and make the exponent data
&lt;/h2&gt;

&lt;p&gt;Store amounts as integer minor units (cents, satang, fils) in a &lt;code&gt;BigInt&lt;/code&gt;. It's exact, there's no rounding at rest, and you're not near any ceiling.&lt;/p&gt;

&lt;p&gt;The trap is what people write next:&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;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// wrong for a third of the world&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ISO 4217 assigns every currency an exponent, and it is not always 2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Currency&lt;/th&gt;
&lt;th&gt;Exponent&lt;/th&gt;
&lt;th&gt;1234567 minor units&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JPY&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;¥1,234,567&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USD&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$12,345.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KWD&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1,234.567&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLF&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;123.4567&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero-decimal currencies include JPY, KRW and VND. Three-decimal currencies include KWD, BHD, OMR, JOD and TND. So the exponent is a lookup, never a literal:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EXPONENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;USD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;JPY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;KWD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toDecimalString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="kr"&gt;string&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;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EXPONENT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&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;negative&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minor&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&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;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;negative&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minor&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minor&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&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;whole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;e&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;frac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&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="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;negative&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&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="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;frac&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For display, hand that string straight to &lt;code&gt;Intl.NumberFormat&lt;/code&gt;. Its &lt;code&gt;format()&lt;/code&gt; accepts a string, not just a number, which means you can render an exact value without round-tripping it through a double:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&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;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toDecimalString&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123456&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt; &lt;span class="c1"&gt;// "$1,234.56"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Intl&lt;/code&gt; already knows each currency's exponent, so &lt;code&gt;¥1,234&lt;/code&gt; renders without a decimal point and &lt;code&gt;KWD 1,234.567&lt;/code&gt; renders with three, for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rounding is a policy decision, not a default
&lt;/h2&gt;

&lt;p&gt;Round half away from zero (what most people mean by "round") is biased. Every tie goes the same direction, so the error accumulates instead of cancelling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;values     0.5   1.5   2.5   3.5   4.5      sum
exact                                       12.5
half-up      1     2     3     4     5      15
half-even    0     2     2     4     4      12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Half-even, also called banker's rounding, sends ties to the nearest even integer. Half of them go up, half go down, and across a large book the drift is far smaller. It's also the IEEE-754 default.&lt;/p&gt;

&lt;p&gt;Half-even is not automatically the right answer. Tax authorities, card scheme rules and loan regulations sometimes specify a mode, and it isn't always the unbiased one. The point is that the mode is a decision that belongs in a signature:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&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="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RoundingMode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;not a default buried three layers down in a formatting helper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting a bill is where naive money types die
&lt;/h2&gt;

&lt;p&gt;Divide $10 three ways. Each share is $3.33, and three of those is $9.99. You have lost a cent, and if the other side of that entry is a real bank transfer, your ledger no longer balances.&lt;/p&gt;

&lt;p&gt;Division isn't the operation you want. Allocation is: hand out every minor unit, distribute the remainder deterministically, guarantee the parts sum to the whole.&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;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ratios&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt;&lt;span class="p"&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;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ratios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&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;sum&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&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;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ratios must sum to a positive value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&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;shares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ratios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&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;share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// BigInt division truncates toward zero&lt;/span&gt;
    &lt;span class="nx"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;share&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;share&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Hand the leftover units out one at a time, in order.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;%&lt;/span&gt; &lt;span class="nx"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;step&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="nx"&gt;shares&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// [334n, 333n, 333n]&lt;/span&gt;
&lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// [150n, 350n]&lt;/span&gt;
&lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// [15n, 15n, 14n, 14n, 14n, 14n, 14n]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;step&lt;/code&gt; variable exists because of a bug I wrote on the way to this post. My first version incremented by &lt;code&gt;1n&lt;/code&gt; unconditionally, which is correct for payments and quietly wrong for refunds: &lt;code&gt;allocate(-1000n, [1n, 1n, 1n])&lt;/code&gt; returned three shares of &lt;code&gt;-333n&lt;/code&gt;, summing to &lt;code&gt;-999n&lt;/code&gt;. A missing cent, on exactly the code path where a customer is getting money back.&lt;/p&gt;

&lt;p&gt;This is also the reason the ordering is worth thinking about. Handing the remainder to the first participants every time is deterministic, which is what you want for reproducibility, but it isn't fair over repeated splits. If the same three accounts split odd amounts every month, rotate the starting index by something stable. An invoice number works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The currency belongs in the type
&lt;/h2&gt;

&lt;p&gt;Make the currency part of the static type and cross-currency arithmetic stops compiling:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;JPY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KWD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="p"&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;add&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="o"&gt;&amp;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="na"&gt;minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minor&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&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;minor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;JPY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;//                                     ^ Type '"JPY"' is not assignable to type '"USD"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Types are erased at runtime, though, and money arrives over HTTP from systems that have never heard of your union. So keep the runtime guard as well:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&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;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Cannot add &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conversion is then a separate operation with a different shape. It is not multiplication by a number. It consumes a rate that knows where it came from, and it returns money in a different currency:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Rate&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;F&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// exact decimal, not a float&lt;/span&gt;
  &lt;span class="nl"&gt;quotedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ISO 8601&lt;/span&gt;
  &lt;span class="nl"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// which provider, which feed&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;Money&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rate&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RoundingMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist the rate you actually used alongside the resulting entry. Six months later, someone reconciling a break needs to know whether the discrepancy is a bug or a rate that moved between quote and capture, and "we looked it up at the time" is not an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON is a float in a trench coat
&lt;/h2&gt;

&lt;p&gt;You can do all of the above and give it away at the boundary:&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"amount": 9007199254740993}&lt;/span&gt;&lt;span class="dl"&gt;'&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="c1"&gt;// 9007199254740992&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON spec doesn't bound numeric precision, but &lt;code&gt;JSON.parse&lt;/code&gt; in every JavaScript runtime produces a double. Any consumer written in JS silently truncates whatever you sent.&lt;/p&gt;

&lt;p&gt;Two wire formats survive the trip. Integer minor units, which is what Stripe does: &lt;code&gt;"amount": 2000&lt;/code&gt; means $20.00, with a published list of zero-decimal currencies so clients know how to interpret it. Or a decimal string, &lt;code&gt;"amount": "12.34"&lt;/code&gt;, which is self-describing but needs an exact parser on the other end.&lt;/p&gt;

&lt;p&gt;One sharp edge if you go the &lt;code&gt;BigInt&lt;/code&gt; route:&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// TypeError: Do not know how to serialize a BigInt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need an explicit encoder. That's a feature. It forces the wire representation to be a decision someone made rather than whatever your ORM happened to emit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to put in the database
&lt;/h2&gt;

&lt;p&gt;In Postgres, &lt;code&gt;BIGINT&lt;/code&gt; minor units plus a currency column, or &lt;code&gt;NUMERIC(19, 4)&lt;/code&gt;. Both are exact. Pick based on whether you need sub-minor-unit precision, not on which one looks tidier.&lt;/p&gt;

&lt;p&gt;Don't use the &lt;code&gt;money&lt;/code&gt; type. Its fractional precision comes from &lt;code&gt;lc_monetary&lt;/code&gt;, a server setting, so the same column can mean different things on two machines and a dump/restore across locales can change your values. The Postgres documentation itself steers you elsewhere.&lt;/p&gt;

&lt;p&gt;Whatever you choose, the currency column travels with the amount, in the same table, non-null. A bare &lt;code&gt;amount&lt;/code&gt; column is the same bug as a bare &lt;code&gt;number&lt;/code&gt;, just durable. Constrain it against a currencies table that also carries the exponent, so there's exactly one place in the system that knows JPY has none.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this model stops working
&lt;/h2&gt;

&lt;p&gt;Minor units are a &lt;em&gt;settlement&lt;/em&gt; precision, and plenty of finance happens at a finer grain. Interest accrual, per-unit pricing, and FX rates all need more digits than the currency has. The fix isn't to abandon the model, it's to keep two scales explicitly: compute at high precision, round once at the point money actually moves, and store both the unrounded and the settled figure so the rounding is auditable.&lt;/p&gt;

&lt;p&gt;Three more honest limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;BigInt&lt;/code&gt; is meaningfully slower than &lt;code&gt;number&lt;/code&gt;. Irrelevant in a request handler, possibly relevant inside a risk-scoring loop that runs a million times a second. Measure before you care.&lt;/li&gt;
&lt;li&gt;Type-level currency only works if the currency set is closed at compile time. If yours is loaded from config, you're back to runtime checks, and the static version is a comfortable illusion.&lt;/li&gt;
&lt;li&gt;None of this catches a wrong rate, a wrong sign, or a posting to the wrong ledger account. It makes an entire class of representation errors impossible. It does not make you correct.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test the invariants, not the examples
&lt;/h2&gt;

&lt;p&gt;Money types are unusually well suited to property-based testing, because the rules are short and absolute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;allocate&lt;/code&gt; always sums back to the total, for every total and every set of ratios&lt;/li&gt;
&lt;li&gt;equal ratios never produce shares differing by more than one minor unit&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parse(format(m))&lt;/code&gt; round-trips to &lt;code&gt;m&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;addition is commutative and associative within a currency&lt;/li&gt;
&lt;li&gt;addition across currencies always throws&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are five properties covering more ground than a page of hand-picked examples. The first one is what caught the refund bug earlier in this post; I ran it across roughly 23,000 generated combinations of totals and ratios, and the negative case failed immediately. No example-based test I would have thought to write covers &lt;code&gt;allocate(-1000n, [1n, 1n, 1n])&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Never a &lt;code&gt;float&lt;/code&gt; or &lt;code&gt;double&lt;/code&gt; for a monetary amount, anywhere in the stack.&lt;/li&gt;
&lt;li&gt;Store integer minor units or an exact decimal.&lt;/li&gt;
&lt;li&gt;Look the exponent up per currency. It is not always 2.&lt;/li&gt;
&lt;li&gt;Put the currency inside the type, and reject cross-currency arithmetic loudly.&lt;/li&gt;
&lt;li&gt;Make the rounding mode a parameter, not a default.&lt;/li&gt;
&lt;li&gt;Allocate; don't divide.&lt;/li&gt;
&lt;li&gt;Cross the wire as minor units or a decimal string, never as a JSON number.&lt;/li&gt;
&lt;li&gt;Test invariants, not examples.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want a concrete place to start: grep your codebase for &lt;code&gt;/ 100&lt;/code&gt; and &lt;code&gt;* 100&lt;/code&gt;. Every hit is either a hardcoded currency assumption or a float round-trip, and usually both. It's an afternoon of work, and it's the highest-value refactor most financial codebases have sitting in front of them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mashhadi.me/blog/money-as-a-data-type" rel="noopener noreferrer"&gt;mashhadi.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>architecture</category>
      <category>typescript</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
