Case Study: One Penny, Four Languages, Zero Consensus
How inconsistent rounding rules across ASP Classic, .NET, SQL Server, and React Native created "impossible" invoice discrepancies—and how we fixed it.
Case Study: One Penny, Four Languages, Zero Consensus
This is a case study from a B2B purchasing/AP system for medical manufacturing supplies where we hit a rare but stubborn bug:
- POs, Invoices, and reports showing a line item total of, say, $13.01
- Those (seemingly same) documents and reports sometimes showing $13.00 instead
- Client-facing invoices not matching the ones coming from the vendors
- We double and triple checked all the math, but it always lined up across the stack, even with strict adherence to company-defined orders of operations for things like applying percentages.
It was an annoying head scratcher that popped up once in a while in our 20 year old legacy behemoth of a stack, but we were a small team and and we couldn't justify the discovery time for something when we already had a quick SQL script for the occasional manual override. Alas, everything has to be happen someday and one was that day.
It turned out to be a classic story of multiple default rounding rules living in one stack:
- ASP Classic / VBScript frontend
- .NET C# backend
- This was also consumed as a module in a newer React Native / TypeScript companion app managed by a separate team.
- SQL Server database
...each with their own hidden variances in how they handle .5
Numbers and examples below are simplified, but the shape of the problem and the solution are faithful to the real incident.
Context: Medical Supply Purchasing and AP
The system was responsible for:
- Pulling in daily item data from multiple vendors via SQL punchout catalogues
- Letting hospital staff browse items and create purchase orders (POs)
- Automating:
- PO submission to vendor APIs
- shipping/receipt updates
- invoice ingestion
- payments between clients and vendors
The important constraints:
- Hundreds of stored procedures in SQL Server:
- generating POs and invoices
- applying discounts and taxes
- posting payments
- feeding various reports
- A small team (us) owned the VBScript/C#/SQL stack, focused entirely on Purchasing/AP
- Another team owned a React Native app (TypeScript) that plugged into our backend as part of a larger suite
The Symptom: "Your Totals Don't Match the Vendor's"
The initial reports were very mundane, which is what made this harder to see:
- Customer service: “Vendor says this invoice total is wrong by $0.01”
- Vendors: “Your remittance doesn’t match our expected balance”
- Clients: “Our PO and your invoice differ by a cent here and there”
Every time we pulled up the raw data, we’d see something like:
- Vendor invoice line: 14.45 item price, 10% discount, net line 13.01
- Our system: sometimes net 13.01, sometimes 13.00, depending on which part of the stack did the math
When humans recalculated on a calculator or in Excel, both numbers looked defensible.
So this turned into a classic “ghost in the machine” that no single team could reproduce consistently—because the stack was inconsistent, not any single component.
The Stack: Four Layers, Four Rounding Behaviors
The rough architecture:
- Classic ASP frontend (VBScript)
- legacy web UI used by most office users
- .NET C# backend
- APIs, business logic, services talking to vendor APIs
- SQL Server
- hundreds of stored procedures, the canonical source for POs, invoices, and payment records
- React Native app (TypeScript)
- mobile companion used by staff in the field
- owned by another team, hitting our APIs
Crucially:
- Each layer did its own rounding for money in at least some places
- Each layer had a different default tie-breaking rule for
.5values
Quick reference (for positive numbers):
| Layer / Tech | Typical API | Tie rule for .5 |
|---|---|---|
| VBScript (ASP Classic) | Round | Banker’s (half to even) |
| .NET C# | Math.Round(x, n) w/o mode | Banker’s (half to even) |
| SQL Server (T‑SQL) | ROUND(x, n) | Half up / away from zero |
| React Native / TS (JS) | Math.round(x * 100) / 100 | Half up / towards positive innfinity |
That’s three different tie-breaking rules across four layers.
A Concrete Example: 14.45 With a 10% Discount
Here’s a simplified version of the sort of calculation that bit us:
- Vendor item price: 14.45
- Discount rate: 10%
Raw discount amount:
- ( 14.45 × 0.10 = 1.445 )
We need to round that to 2 decimal places:
- What is the discount? 1.44 or 1.45?
- Net line =
price - discount= 13.01 or 13.00?
Different layers answered differently.
VBScript / ASP Classic (banker’s rounding)
<%
price = 14.45
discountRate = 0.1
discount = Round(price * discountRate, 2) ' VBScript Round is half-to-even
net = price - discount
Response.Write "Discount: " & discount & ", Net: " & net
%>
Result:
price * discountRate= 1.445Round(1.445, 2)→ 1.44 (half to even: between 1.44 and 1.45, choose 1.44)- Net = 14.45 - 1.44 = 13.01
.NET C# (also banker’s rounding by default)
var price = 14.45m;
var discountRate = 0.10m;
var raw = price * discountRate; // 1.445m
var discount = Math.Round(raw, 2); // 1.44m (half-to-even)
var net = price - discount; // 13.01m
Same result:
Math.Round(1.445m, 2)= 1.44 by default (MidpointRounding.ToEven)- Net = 13.01
So VBScript and C# agreed with each other.
SQL Server (half up / away from zero)
DECLARE @price decimal(10,2) = 14.45;
DECLARE @rate decimal(5,4) = 0.10;
SELECT
RawDiscount = @price * @rate, -- 1.445
Discount = ROUND(@price * @rate, 2), -- 1.45
Net = @price - ROUND(@price * @rate, 2) -- 13.00
;
Here:
ROUND(1.445, 2)→ 1.45 (SQL’sROUNDdoes half up for positives)- Net = 14.45 - 1.45 = 13.00
So the database thought the net line was 13.00.
React Native / TypeScript (JS Math.round)
const price = 14.45;
const rate = 0.1;
const raw = price * rate; // 1.445
const discount = Math.round(raw * 100) / 100; // 1.45
const net = price - discount; // 13.0
For this value:
raw * 100= 144.5Math.round(144.5)= 145- discount = 1.45
- net = 13.0
So the mobile app’s naive helper agreed with SQL, not the VBScript/C# side.
How This Surfaced in the Real World
In practice, values like 1.445 are rare—but not that rare:
- Complex contracted prices
- Percentage-based discounts
- Layered tax rates
We had flows like:
- Vendor sends us a punchout catalogue with prices and discount rules
- Our system:
- lets a user compose POs
- generates vendor-facing invoices
- posts payments and builds aging reports
- The vendor has their own backend doing the same math on their side
In borderline cases:
- Vendor portal showed a line net of 13.01
- Our legacy web UI (VBScript / C#) showed 13.01
- Our database had stored 13.00
- Some reports that re-aggregated in SQL showed totals based on 13.00
- The mobile app (React Native) showed 13.00 when it recomputed locally
So depending on:
- which UI you looked at
- which API shape you hit
- which report you opened
you could see different but “plausible” numbers, all derived from the same underlying price and discount.
That led to:
- Vendors escalating “your totals do not match ours”
- Customers asking why line totals on the PO didn’t match the invoice or report
- Support tickets where every screenshot was internally consistent within a given component, but inconsistent across components
When we sat down with calculators and Excel, we could make either 13.00 or 13.01 appear just by picking the “right” rounding rule.
Root Cause: Everyone Was "Just Rounding" Differently
Once we lined everything up, we realized:
- VBScript
Roundand C#Math.Round(decimal, 2)
→ banker’s rounding (half to even) - SQL Server
ROUND(value, 2)
→ half up / away from zero for positives, different on negatives - JavaScript
Math.round(value * 100) / 100
→ ties toward +∞ (for positive numbers this matches half up)
In other words, we had:
- Legacy web UI + .NET backend + (in this case) vendor math using one rule
- Database + React Native app using a different rule
With enough volume, that mismatch turned into visible, user-facing pennies.
The Fix: A Single, Deterministic Rounding Authority in SQL
I don’t remember every historical detail, but the resolution looked roughly like this:
- We treated the vendor’s specification as the source of truth.
- e.g. “round to the nearest cent, with ties to the nearest even cent”
(banker’s rounding) or whatever the particular vendor documented.
- e.g. “round to the nearest cent, with ties to the nearest even cent”
- We encoded that behavior into a deterministic T‑SQL function/stored procedure.
- We routed all money calculations that affected persisted data through that SQL entry point.
- Every other layer stopped “being clever” about rounding and:
- either used the values as returned from SQL
- or called an API that, under the hood, called the canonical SQL function
A concrete pattern (simplified)
Instead of letting each layer compute discounts and taxes like:
ROUND(price * discountRate, 2)(SQL)Math.Round(price * discountRate, 2)(C#)Round(price * discountRate, 2)(VBScript)Math.round(price * rate * 100) / 100(TypeScript)
we moved to:
- A canonical T‑SQL function that implemented vendor-style rounding.
- Stored procedures that only used that function.
- API endpoints that returned those values as canonical.
Example: canonical rounding function
If the vendor used banker’s rounding (ties to even), we couldn’t rely on
SQL Server’s ROUND, so we implemented something like this:
CREATE FUNCTION dbo.fn_RoundBankers
(
@value decimal(19, 6),
@scale tinyint
)
RETURNS decimal(19, 6)
AS
BEGIN
DECLARE @factor decimal(19, 6) = POWER(10.0, @scale);
DECLARE @scaled decimal(38, 10) = @value * @factor;
DECLARE @floored decimal(38, 10) = FLOOR(@scaled);
DECLARE @fraction decimal(38, 10) = @scaled - @floored;
IF @fraction > 0.5 RETURN (@floored + 1) / @factor;
IF @fraction < 0.5 RETURN @floored / @factor;
-- Exactly .5: round to even
IF (@floored % 2 = 0)
RETURN @floored / @factor;
RETURN (@floored + 1) / @factor;
END;
Then a line-calculation function:
CREATE FUNCTION dbo.fn_CalcLineAmounts
(
@unitPrice decimal(19, 4),
@quantity int,
@discountRate decimal(9, 6),
@taxRate decimal(9, 6)
)
RETURNS TABLE
AS
RETURN
(
WITH raw AS (
SELECT
Gross = @unitPrice * @quantity,
DiscRaw = @unitPrice * @quantity * @discountRate,
Dummy = 0 -- just to keep the shape simple
)
SELECT
GrossAmount = Gross,
DiscountAmount = dbo.fn_RoundBankers(DiscRaw, 2),
NetBeforeTax = Gross - dbo.fn_RoundBankers(DiscRaw, 2),
TaxAmount = dbo.fn_RoundBankers(
(Gross - dbo.fn_RoundBankers(DiscRaw, 2)) * @taxRate,
2
),
LineTotal = dbo.fn_RoundBankers(
(Gross - dbo.fn_RoundBankers(DiscRaw, 2)) +
dbo.fn_RoundBankers(
(Gross - dbo.fn_RoundBankers(DiscRaw, 2)) *
@taxRate,
2
),
2
)
FROM raw
);
Any time we needed invoice line amounts, we went through this function or a stored procedure wrapping it.
C# backend: stop re-implementing the math
On the .NET side, instead of recomputing and rounding, we read the canonical values:
public async Task<InvoiceLineDto> CalculateLineAsync(
decimal unitPrice,
int quantity,
decimal discountRate,
decimal taxRate,
SqlConnection connection)
{
using var cmd = new SqlCommand("dbo.usp_CalcLine", connection);
cmd.CommandType = CommandType.StoredProcedure;
cmd.Parameters.AddWithValue("@UnitPrice", unitPrice);
cmd.Parameters.AddWithValue("@Quantity", quantity);
cmd.Parameters.AddWithValue("@DiscountRate", discountRate);
cmd.Parameters.AddWithValue("@TaxRate", taxRate);
using var reader = await cmd.ExecuteReaderAsync();
await reader.ReadAsync();
return new InvoiceLineDto
{
GrossAmount = (decimal)reader["GrossAmount"],
DiscountAmount = (decimal)reader["DiscountAmount"],
NetBeforeTax = (decimal)reader["NetBeforeTax"],
TaxAmount = (decimal)reader["TaxAmount"],
LineTotal = (decimal)reader["LineTotal"],
};
}
No more Math.Round(...) in business code for these flows.
React Native / TypeScript: trust the API
On the mobile side, we asked the other team to treat totals as data, not as a formula to be recomputed.
Instead of:
// Old approach (problematic)
const discount = Math.round(price * discountRate * 100) / 100;
const net = price - discount;
the app would get:
// New approach (simplified)
type LineFromApi = {
unitPrice: number;
quantity: number;
grossAmount: number;
discountAmount: number;
netBeforeTax: number;
taxAmount: number;
lineTotal: number;
};
const line: LineFromApi = await api.getLine(...);
// Just display these; don't recompute:
displayPrice(line.lineTotal);
If the app needed to recompute (e.g. optimistic UI), we gave them a direct port of the same banker’s rounding logic with tests aligned to SQL’s behavior.
Results: One Rounding Rule to Rule Them All
After pushing the canonical SQL function/procedure and removing ad-hoc rounding from other layers:
- Vendor portals and our system consistently agreed to the cent
- Customers saw the same totals:
- in the legacy web UI
- in the mobile app
- on PDFs and reports
- Reconciliation noise in reporting dropped
Most importantly, we had something we could point to:
- “Here is the exact rounding rule”
- “Here is the function that implements it”
- “Every total you see comes from here”
That turned a fuzzy “the math looks fine to me” discussion into something that could be debugged, tested, and reasoned about.
Takeaways
From this particular incident, I’d summarize the lessons as:
-
Rounding mode is a product decision, not an implementation detail.
If you don’t specify it, each language will happily pick a different one. -
Cross-stack systems need a single authority for money math.
In our case that was a deterministic SQL function / stored procedure that matched the vendor’s spec. -
APIs should return canonical monetary values, not just formulas.
Frontends (web, mobile) should render amounts, not reinvent accounting logic in JavaScript. -
Test with “ugly” numbers on purpose.
Values like 14.45 with a 10% discount (1.445) are exactly where rounding modes diverge; those should be in your test suite.
If you're building anything that moves money across multiple stacks, it's worth running through your own equivalent of this exercise before you ship—even if you haven't yet had the "your totals don't match ours" call from a vendor.