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,
};
}
- Engineering deep-dive: Shopify B2B Net-Terms Integer-Cent Reconciliation
- Interactive sandbox: Free B2B Net-Terms & Deposit Calculator
Top comments (5)
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.
Two places where that shows up fast in production:
Math.rounduses 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) overflowsNumber.MAX_SAFE_INTEGER10,000x earlier (~900.7B minor units) on zero-decimal wholesale currencies likeJPYorVND. Doing the basis-point math inBigIntkeeps both the tie-break rule and high-denomination invoices exact: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?
We keep minor-unit scale (exponent 0 for
JPY/VND, 2 forEUR/USD, 3 forKWD/BHD) in static ISO 4217 currency metadata for ingress parsing, whileroundingMode(HALF_EVENvs.HALF_UP) stays a parameter on the workspace net-terms ledger profile since the sameEURinvoice can settle into ERPs with different integer cent rounding rules. When(amountMinor * bps) % 10_000n === 5_000non 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.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?