Back to Blog
Published
·12 min read·Code Safety

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 .5 values

Quick reference (for positive numbers):

Layer / TechTypical APITie rule for .5
VBScript (ASP Classic)RoundBanker’s (half to even)
.NET C#Math.Round(x, n) w/o modeBanker’s (half to even)
SQL Server (T‑SQL)ROUND(x, n)Half up / away from zero
React Native / TS (JS)Math.round(x * 100) / 100Half 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)

vb
<%
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.445
  • Round(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)

csharp
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)

sql
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’s ROUND does 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)

ts
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.5
  • Math.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 Round and 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:

  1. 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.
  2. We encoded that behavior into a deterministic T‑SQL function/stored procedure.
  3. We routed all money calculations that affected persisted data through that SQL entry point.
  4. 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:

  1. A canonical T‑SQL function that implemented vendor-style rounding.
  2. Stored procedures that only used that function.
  3. 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:

sql
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:

sql
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:

csharp
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:

ts
// Old approach (problematic)
const discount = Math.round(price * discountRate * 100) / 100;
const net = price - discount;

the app would get:

ts
// 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:

  1. Rounding mode is a product decision, not an implementation detail.
    If you don’t specify it, each language will happily pick a different one.

  2. 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.

  3. APIs should return canonical monetary values, not just formulas.
    Frontends (web, mobile) should render amounts, not reinvent accounting logic in JavaScript.

  4. 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.