Back to writing

The Quote Was ₹55. The Bill Was ₹175.

9 min read

₹55 at checkout, ₹175 on the invoice

The checkout page of a store I've been building asks the courier for a delivery price before the shopper pays. Type a PIN code, get a number, pay it. It worked. The number showed up, the shopper paid it, the order went out.

Then the courier invoices came in. A parcel the shopper had paid ₹55 to ship was billed to the shop at ₹175. Not one parcel. Every one of them. The shop was quietly paying the difference on every order it sent.

No request had failed. Every call returned a 200 with a valid price in it. Both prices were even correct, in the sense that the courier really did quote ₹55 and really did bill ₹175. They were just prices for two different parcels, and only one of those parcels ever existed.


Couriers don't charge for weight, they charge for space

This is the part I didn't understand well enough going in. A courier's van fills up by volume long before it fills up by weight. So couriers don't simply bill on what your parcel weighs. They bill on whichever is bigger: what it weighs, or what it would weigh if it were as dense as they'd like it to be.

That second number is volumetric weight:

volumetric weight (kg) = length × breadth × height (cm) / 5000
applied weight         = max(dead weight, volumetric weight)
billed weight          = applied weight, rounded up to the next 0.5 kg

Run an ordinary shoebox through it. Say 33 × 22 × 12 cm, weighing 0.8 kg with the shoes inside:

dead weight        0.80 kg                   → billed as 1.0 kg
volumetric weight  33 × 22 × 12 / 5000
                 = 1.74 kg                   → billed as 2.0 kg

Anything light and bulky loses this comparison: shoes, pillows, lampshades, most things that come in a box. As far as the courier is concerned, you're shipping the box, not what's in it.


Two code paths, two different parcels

The store talks to the courier twice per order. Once at checkout, to ask "what would this cost to ship to this PIN code?" And once after payment, to book the shipment. I wrote those two calls on different days, for different reasons, and they described the parcel differently.

// checkout: what the shopper is quoted
const rates = await courier.rates({
  pickup: PICKUP_PIN,
  delivery: pincode,
  weightKg: totalWeightKg(items),
});
 
// after payment: what the courier bills
await courier.book({
  ...address,
  weightKg: totalWeightKg(items),
  length, breadth, height, // from the product's packaging
});

The booking API requires dimensions, so the booking call sent them. The rates API treats them as optional, so the rates call didn't. Nothing complained. The courier priced the quote on dead weight, because that was all it was given, and priced the bill on volumetric weight, because the booking described a real box.

That's the whole bug. One optional parameter, left out of one call, changing the answer completely.

And the gap wasn't a fixed amount. With boxes stacked on top of each other, it grew with the cart:

boxes   quoted on (dead weight)   billed on (volumetric)
1       1.0 kg                    2.0 kg
2       2.0 kg                    3.5 kg
3       2.5 kg                    5.5 kg

The bigger the order, the more the shop lost on it. The orders it wanted most were the ones costing it most.


Fix one: one function builds the parcel

The real fix isn't "add the dimensions to the rates call". It's making sure the two calls can't disagree again. So the parcel became a value with exactly one source, and both calls take it from there.

type Parcel = {
  weightG: number;
  lengthMm: number;
  breadthMm: number;
  heightMm: number;
};
 
export function packParcel(items: CartItem[]): Parcel {
  const parcel = { weightG: 0, lengthMm: 0, breadthMm: 0, heightMm: 0 };
 
  for (const { packaging, quantity } of items) {
    const p = packaging ?? DEFAULT_BOX;
    parcel.weightG += p.weightG * quantity;
    parcel.lengthMm = Math.max(parcel.lengthMm, p.lengthMm);
    parcel.breadthMm = Math.max(parcel.breadthMm, p.breadthMm);
    parcel.heightMm += p.heightMm * quantity;
  }
 
  return parcel;
}

It's deliberately naive. Weights add up. The footprint is the biggest box in the order. Heights stack, because that's what you do with shoeboxes. A real packing algorithm would find tighter arrangements, and I don't need one, because the two ways of being wrong here aren't equal. Declare a box slightly too big and you overpay by a few rupees. Declare one too small and you're back to quoting a parcel you'll never ship.

A product with no packaging measurements falls back to a default box. It never falls back to nothing, because nothing is exactly what the old rates call was sending.

One more habit that paid off here: every quantity is stored as an integer in the smallest unit that matters (grams, millimetres, paise) and only converted to kilograms and centimetres at the moment of the API call. Floats in a unit conversion are how a 0.7 kg parcel becomes 0.6999999999999999.


Fix two: send the box, not your own maths

Once I understood volumetric weight, my first instinct was to compute it myself and send the bigger number as weightKg. That would have been a smaller bug, not no bug.

The 5000 in that formula is the courier's choice, not a law of physics. Most use 5000. Some use a smaller divisor, which makes the same box heavier. And an aggregator quotes many couriers in a single response, each with its own rule. If I do the maths, I'm right for some of them and under-quoting the rest.

So the rates call now describes the box and lets every courier apply its own rule:

const parcel = packParcel(items);
 
const rates = await courier.rates({
  pickup: PICKUP_PIN,
  delivery: pincode,
  weightKg: parcel.weightG / 1000,
  length: toWholeCm(parcel.lengthMm),
  breadth: toWholeCm(parcel.breadthMm),
  height: toWholeCm(parcel.heightMm),
});

The booking call uses the same packParcel(items). The parcel we quote is, by construction, the parcel we declare.


Fix three: round up, and never to zero

The rates API takes whole centimetres. Packaging gets measured with a tape, to the millimetre. Something has to round, and the direction mattered more than I expected.

Take a box measured at 32.4 × 21.3 × 11.2 cm:

Math.round  →  32 × 21 × 11 / 5000 = 1.48 kg  → 1.5 kg slab
Math.ceil   →  33 × 22 × 12 / 5000 = 1.74 kg  → 2.0 kg slab

Less than a centimetre of rounding on each edge moved the price by a whole slab. And the courier isn't going to measure your box generously. Round down and you've quoted a box slightly smaller than the one that ends up on their scale.

const toWholeCm = (mm: number) => Math.max(1, Math.ceil(mm / 10));

The Math.max(1, ...) is there for the same reason. A zero on any edge makes the volume zero, which makes the volumetric weight zero, which hands you a dead-weight quote. That's the original bug, smuggled back in through one empty field. Between this and the default box, the rates call can't send an empty edge anymore.


Fix four: the cache key has to know about the box

Delivery prices get asked for a lot (on the product page, then again at checkout), and each lookup is a slow call out to the courier. So quotes are cached for twelve hours. The original cache key was:

pickup PIN : delivery PIN : weight : cod

That key was correct for the old function, because the old function only took weight. The moment dimensions started changing the price, the key became wrong, and wrong silently. A tall parcel asking about a PIN code that a flat parcel of the same weight had already asked about would get the flat parcel's price, straight from the cache. A cache that answers a question nobody asked.

const bucketUp = (grams: number, step = 500) =>
  Math.max(step, Math.ceil(grams / step) * step);
 
const rateKey = (pincode: string, parcel: Parcel, cod: boolean) =>
  [
    PICKUP_PIN,
    pincode,
    bucketUp(parcel.weightG),
    `${parcel.lengthMm}x${parcel.breadthMm}x${parcel.heightMm}`,
    cod ? "cod" : "prepaid",
  ].join(":");

Two details worth pointing out. The weight goes into the key bucketed to half a kilo, and the request on a cache miss is sent at that bucketed weight too, so the cached answer is always for the top of the bucket, never the bottom. A coarse cache should err over the bill, not under it.

And adding the box to the key didn't blow up the number of entries the way I worried it would. A shop has a handful of carton sizes, and a bigger cart only changes the stacked height.

Cash on delivery gets its own entry because it's a genuinely different question. Some couriers don't carry COD to some PIN codes at all, and the ones that do charge extra for it.

The general rule I took from this: when a function's inputs grow, its cache key has to grow with it. Nobody remembers to do that, because the cache was fine yesterday.


Fix five: the price they saw is the price they pay

Fixing the quote fixed the shop's side of the problem. There was still a hole on the shopper's side, and an earlier version of the store had fallen straight into it.

Quotes go stale. A shopper checks delivery, wanders off, comes back twenty minutes later and pays. My first version asked the courier again at the moment the order was placed, to be safe. Which meant a shopper could be charged a delivery price they had never been shown. That's worse than the original bug. The shop losing ₹120 on a parcel is a margin problem. A shopper paying more than the screen said is a trust problem.

So the quote is now sealed. When checkout gets a price, the server encrypts it into a token together with everything that price depended on:

type QuoteClaims = {
  pincode: string;
  cartHash: string;      // which items, which sizes, how many
  subtotalPaise: number; // free-delivery thresholds depend on this
  paymentMethod: "PREPAID" | "COD";
  shippingPaise: number; // the number the shopper was shown
  expiresAt: number;
};

The token is encrypted with AES-GCM, so the client can neither read it nor edit it, and it expires after 45 minutes. The order endpoint doesn't accept a delivery price at all. It accepts the token, opens it, and checks that it still describes this order:

function acceptQuote(token: string, order: OrderDraft): QuoteClaims {
  const claims = openQuote(token); // throws if forged or expired
 
  if (claims.pincode !== order.pincode) throw stale("address changed");
  if (claims.cartHash !== hashCart(order.items)) throw stale("bag changed");
  if (claims.subtotalPaise !== order.subtotalPaise) throw stale("a price changed");
  if (claims.paymentMethod !== order.paymentMethod) throw stale("other payment method");
 
  return claims;
}

A token that can't be forged can still be replayed: against a different bag, a different address, or the other payment method. Every one of those changes the price, so every one is checked. A stale token isn't a dead end either. The error tells the payment page to fetch a fresh quote and show the new total, and the shopper pays the number on their screen.

That puts the cost of any drift in one place, on purpose. If rates move between the quote and the booking, the shop absorbs the difference. Rounding up and bucketing upward keep that difference small, and mostly in the shop's favour.


The test that would have caught it

Every unit test passed before the fix. The rates call built a valid rates request. The booking call built a valid booking. Each half was correct on its own. The bug only existed between them, so that's where the test has to live:

it("quotes the same parcel it books", () => {
  for (const quantity of [1, 2, 3]) {
    const items = [cartItem({ quantity })];
 
    const quoted = ratesRequest(items);
    const booked = bookingPayload(orderWith(items));
 
    // quotes round up to whole cm, bookings keep one decimal
    expect(quoted.length).toBeGreaterThanOrEqual(booked.length);
    expect(quoted.breadth).toBeGreaterThanOrEqual(booked.breadth);
    expect(quoted.height).toBeGreaterThanOrEqual(booked.height);
    expect(quoted.weightKg).toBeGreaterThanOrEqual(booked.weightKg);
  }
});

Next to it sit a few smaller ones: toWholeCm never rounds down and never returns 0, and a three-box cart quotes a taller parcel than a one-box cart. None of them are clever. Each one is there because it's a way the quote and the bill could quietly drift apart again.


The actual lesson

The courier was right both times. My code was right both times. The bug was that "the parcel" existed in two places in my codebase, written separately, with nothing forcing them to agree.

That pair turns up everywhere once you start looking. The quote and the bill. The price on the product page and the price at checkout. The estimate and the invoice. Client-side validation and server-side validation. Any time two pieces of code describe the same real thing, they will drift, and the drift hides in whatever is optional, defaulted or rounded.

So now, when I catch myself writing the second half of a pair, I stop and go find the first half, and make both read from one function. Then I write the test that checks the pair, not the halves.