← back to journal
EngineeringJun 11, 20264 min

We lowered the price in one place. We charged the old one.

arc: cut the price for rural schools → ship it to the landing page, defer the rest on purpose → the amount actually charged came from the copy nobody updated → a deferral is only safe if the thing you deferred is one thing

we were pushing to reach government and low-fee schools across AP and TS, and the pricing was wrong for that market. so we cut it. starter went from ₹4,499 a month to ₹1,999. professional from ₹19,999 to ₹4,999.

the ticket shipped the new numbers to the landing page and explicitly deferred everything else — the registration flow, the backend — as a known, tracked inconsistency to be picked up next.

i want to be fair to that decision, because it wasn't laziness. marketing needed the new number live. the rest was scoped, written down, and linked. that is how you're supposed to defer work.

it still turned into a customer-facing pricing bug, and the reason is more interesting than "we forgot".

the price had three homes

when i went to do the follow-up, the amount wasn't in one place waiting to be edited. it was defined independently in three, across two repositories:

landing pagenew pricethe ticket updated thisregistrationold priceuntouchedPLAN_PRICINGold pricerazorpay orderwhat is chargedthe customer pays the old pricefrom the copy nobody thought to look at
three definitions, one of them wired to the payment gateway — the ticket changed the number the customer reads, not the one they pay.

so here is what a school actually experienced. they read ₹1,999 on the landing page. they started registering and the plan step quoted them ₹4,499. and whichever number they'd made peace with by then, the amount that reached the payment screen came from the third one — the backend — because that's the value the razorpay order is built from.

| Surface | showed | should have shown | |---|---|---| | Starter — monthly | ₹4,499 | ₹1,999 | | Starter — annual (per month) | ₹3,824 | ₹1,799 | | Professional — monthly | ₹19,999 | ₹4,999 | | Professional — annual (per month) | ₹16,999 | ₹4,499 | | Annual discount label | Save 15% | Save 10% |

and it didn't stop at the screen. the backend number is what flows into the quotation PDF, the invoice, and the receipt — so the stale price wasn't just being displayed, it was being charged and then written down permanently on documents a school keeps.

we advertised one price and billed another. that's not a UI inconsistency, that's the kind of thing that costs you a customer's trust in one step.

the deferral wasn't the mistake. the mistake was thinking there was a single "backend price" to defer, when the number had quietly become three facts that happened to agree with each other.

why three copies feel fine right up until they don't

they agreed for months. that's the trap.

three identical values are indistinguishable from one value — every test passes, every screen matches, nothing is wrong. the duplication isn't a bug while nobody changes anything. it only becomes a bug the first time someone updates one, and at that exact moment it becomes several bugs at once, on the most sensitive screen in the product.

i had already been taught this and hadn't generalised it. my lead once made me extract every hardcoded string into constants and i thought it was busywork until we needed a second language. i'd filed that away as a lesson about strings. it was never about strings. it was about a fact being allowed to exist in more than one place.

a label rendering in the wrong language is embarrassing. a price rendering in the wrong number is a refund and an apology.

Ex: duplication doesn't announce itself when you create it. it announces itself the first time the copies need to disagree — which is always later, and always under pressure.

the fix

the backend became the one source. PLAN_PRICING holds the amounts in paise, everything else derives from it, and the surfaces that were reading their own hardcoded copies stopped having copies to read. the paywall, the change-plan screen, the subscription page, the PDFs and the razorpay order all resolve to the same number now because there is only one number.

one invariant got pinned down while we were in there: annualTotal must equal 12 × annualMonthly. it's a small thing, but a discount label saying "save 10%" while the arithmetic says something else is the same class of bug, one level down.

honest edges

worth writing these down rather than claiming it's fully closed:

  • the GST semantics still differ on purpose. the landing page quotes GST-exclusive with tax applied at checkout; in-app is GST-inclusive. the headline numbers match, the framing doesn't. we decided that's acceptable and left it — but it's a decision, not a fix.
  • historical invoices and receipts keep the old amounts, correctly. those are records of what actually happened, and the whole point of freezing them is that a price change must not reach backwards. the receipts already learned that lesson the hard way.
  • the change only took effect on the backend deploy, so there was a window where the repos disagreed by design. two repos, two PRs — a price change can't be atomic across them, and i don't have a good answer for that beyond deploying the backend first.
a price isn't a number you display. it's a fact you charge against, and a fact can only be true in one place.

#buildinpublic #softwareengineering #payments #startup #maahitatechnologies

Send this as proof →Share on LinkedIn