DEV Community

Cover image for Shopify Plus B2B Net-Terms Reconciliation: Why IEEE-754 Float Math Breaks Integer-Cent Deposit Gates
RevClamp Enterprise
RevClamp Enterprise

Posted on Edited on Originally published at revclamp.com

Shopify Plus B2B Net-Terms Reconciliation: Why IEEE-754 Float Math Breaks Integer-Cent Deposit Gates

In Shopify Plus B2B commerce, wholesale orders on Net-15, Net-30, or Net-60 payment schedules frequently require an upfront deposit (such as 20 percent) before warehouse fulfillment is released.

When webhook workers evaluate deposit thresholds using standard JavaScript floating-point numbers (Number(order.total_price) * 0.20), IEEE-754 binary rounding drift and unapplied credit notes cause premature fulfillment releases or false accounting discrepancies.

export function reconcileNetTermsDeposit({
  invoiceTotalCents,
  creditNotesCents = 0,
  settledPaidCents = 0,
  requiredDepositBps = 2000,
}) {
  const netInvoiceCents = Math.max(0, invoiceTotalCents - creditNotesCents);
  const requiredDepositCents = Math.round((netInvoiceCents * requiredDepositBps) / 10_000);
  const depositShortfallCents = Math.max(0, requiredDepositCents - settledPaidCents);
  return {
    fulfillmentHold: depositShortfallCents > 0,
    requiredDepositCents,
    depositShortfallCents,
  };
}
Enter fullscreen mode Exit fullscreen mode

Top comments (5)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

the drift example is the sneaky part: Number(order.total_price) parses cleanly, but the 0.20 multiplier is the one that cannot be represented exactly, so the gate compares a value that is off by 1e-16 against a threshold that already rounded, and the outcome flips based on which side of the tie the binary value landed. two things worth adding to the gate itself: pick a rounding direction and document it, because Math.round is half-up while finance teams usually assume half-even on credit-note math, and mismatched conventions inside one reconciliation run produce a shortfall that exists only in code. also worth pinning the bps divisor as 10_000n and doing the multiply in BigInt when order values get large, since Number loses exact integer behavior past 2^53 cents, which a single large wholesale invoice can actually reach.

Collapse
 
revclamp profile image
RevClamp Enterprise • • Edited

Two places where that shows up fast in production: Math.round uses half-up whereas NetSuite and Stripe credit-memo ledgers default to half-even (banker's rounding) on net-terms invoice math, and the integer cent numerator (netInvoiceMinorUnits * requiredDepositBps) overflows Number.MAX_SAFE_INTEGER 10,000x earlier (~900.7B minor units) on zero-decimal wholesale currencies like JPY or VND. Doing the basis-point math in BigInt keeps both the tie-break rule and high-denomination invoices exact:

export function mulBpsBigInt(amountMinor, bps, mode = "HALF_EVEN") {
  const prod = BigInt(amountMinor) * BigInt(bps);
  const q = prod / 10_000n;
  const r = prod % 10_000n;
  if (mode === "HALF_EVEN") {
    return r > 5_000n || (r === 5_000n && (q & 1n) === 1n) ? q + 1n : q;
  }
  return r >= 5_000n ? q + 1n : q; // HALF_UP
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

the tie-break asymmetry is the evil part: the webhook gate and the ERP journal can each be correct under their own spec and still disagree by one cent at exactly x.xx5, which is where HOLD_DEPOSIT_UNPAID purgatory comes from. pinning the mode in mulBpsBigInt is the right call, and the zero-decimal currency point is the one most people miss entirely; a hardcoded divide-by-100 on JPY or VND invents or destroys minor units at scale. the missing piece is deciding which side wins when the two disagree anyway, so reconciliation surfaces the tie instead of both sides silently rounding their own way. do you keep the mode as a parameter or hardcode it per currency metadata?

Thread Thread
 
revclamp profile image
RevClamp Enterprise • • Edited

We keep minor-unit scale (exponent 0 for JPY/VND, 2 for EUR/USD, 3 for KWD/BHD) in static ISO 4217 currency metadata for ingress parsing, while roundingMode (HALF_EVEN vs. HALF_UP) stays a parameter on the workspace net-terms ledger profile since the same EUR invoice can settle into ERPs with different integer cent rounding rules. When (amountMinor * bps) % 10_000n === 5_000n on an odd quotient, our webhook reconciler checks both bounds, releases the fulfillment hold at the lower bound if paid, and attaches a 1-minor-unit rounding-tie flag for the ERP journal.

Thread Thread
 
fintechrailsdigest profile image
Fintech Rails Digest •

accepting the floor and holding the delta is the right default; the cost of being one minor unit conservative is nothing next to a warehouse hold that never clears. the variance flag doing the booking on the ERP side is the piece most implementations skip, they just pick a winner and let the journal drift until an audit finds it. one follow-up: when the same invoice settles into two ledger profiles with different modes, say NetSuite on one side and Shopify's own ledger on the other, does the tie get evaluated per profile, or is one side canonical for the deposit gate while the other only receives the variance flag?