TL;DR: Money in ParkEase is stored as integer paise, rates as basis points, and every rupee movement is written to a double-entry ledger that Postgres refuses to update or delete. This post covers how a price gets calculated and checked twice, and how refunds get split without drifting by one paisa. It also covers why the refund split uses
BigInteven though amounts are just rupees.
Part 3 of my series on building ParkEase, a peer-to-peer parking marketplace for India. Earlier parts covered double-booking prevention with exclusion constraints and a pagination bug inside a geo-search cache.
Rule 1: no floats, ever
Every amount is an integer number of paise (₹1 = 100 paise), stored as bigint. Every rate is either a branded Rate type in code or basis points in the database (1.0x surge is stored as 10000).
base_paise bigint NOT NULL,
surge_premium_paise bigint NOT NULL DEFAULT 0,
surge_multiplier_bp integer NOT NULL DEFAULT 10000,
parkease_fee_paise bigint NOT NULL,
gst_paise bigint NOT NULL,
total_paise bigint NOT NULL,
owner_earnings_paise bigint NOT NULL,
The booking stores the whole quote, not just the total. The price a driver agreed to can be rebuilt from the booking row alone, without re-running whatever the rate card said that day.
How a price is built
The model: the owner sets a base price. ParkEase takes a 15% commission from the owner's side. Surge (1.0x–3.0x) adds a premium that goes to the platform. GST at 18% applies only to the ParkEase fee, not to the whole booking.
export function quote(input: FeeInput & { gstRate?: Rate }): Quote {
const fee = parkEaseFee(input); // commission + surge premium
const gstPaise = mulRate(fee.parkeaseFeePaise, gstRate);
const ownerEarningsPaise = subPaise(fee.basePaise, fee.commissionPaise);
const driverTotalPaise = addPaise(fee.basePaise, fee.surgePremiumPaise, gstPaise);
const result = { ...fee, gstPaise, driverTotalPaise, ownerEarningsPaise, /* bp fields */ };
assertQuoteBalances(result);
return result;
}
A worked example with a ₹100 base price and 1.5x surge:
| paise | |
|---|---|
| Base | 10,000 |
| Surge premium (0.5 × base) | 5,000 |
| Commission (15% of base) | 1,500 |
| ParkEase fee (commission + surge) | 6,500 |
| GST (18% of fee) | 1,170 |
| Driver pays (base + surge + GST) | 16,170 |
| Owner earns (base − commission) | 8,500 |
Check: owner 8,500 + fee 6,500 + GST 1,170 = 16,170. Every paisa the driver pays ends up somewhere specific.
Checked twice: in code and in the database
assertQuoteBalances throws if owner + fee + GST ≠ driver total. The database checks the same thing again:
CONSTRAINT bookings_balance_check
CHECK (total_paise = base_paise + surge_premium_paise + gst_paise)
It looks redundant, and that's the point. The TypeScript check catches a bad formula in tests. The CHECK catches the code path I haven't written yet: a future admin tool, a data fix, or a migration. Booking extensions add to each money column instead of replacing them, so the constraint keeps holding after an extension too.
Rates have history
Tax rates change, and commission rates will too. So rates are dated lists, not constants:
export const GST_RATE_HISTORY: readonly DatedRate[] = [
{ rate: toRate(0.18), effectiveFrom: '2026-01-01',
note: '18% on the ParkEase Fee only, not on the booking total.' },
];
export function rateAt(history: readonly DatedRate[], at = new Date()): Rate {
const applicable = history
.filter((e) => new Date(`${e.effectiveFrom}T00:00:00+05:30`) <= at) // IST midnight
.at(-1);
if (!applicable) throw new RangeError(`No rate in force at ${at.toISOString()}`);
return applicable.rate;
}
Two small details I like here. The effective date is midnight IST, not UTC, because that's when an Indian rate change actually takes effect. And tax items I haven't confirmed yet (TCS/TDS) are listed at 0% with a note saying they're pending review by a chartered accountant, so the open question is written down in the code itself.
The ledger: double-entry, append-only
Every money movement is a set of ledger_entries that share a txn_id. Each entry is a debit or credit against a named account:
CONSTRAINT ledger_entries_account_check CHECK (account IN (
'driver_receivable','owner_payable','platform_revenue','gst_payable',
'tcs_payable','tds_payable','gateway_fees','refunds_payable','promo_expense'
)),
CONSTRAINT ledger_entries_amount_check CHECK (amount_paise > 0),
The ledger service checks that the debits and credits balance before inserting, and it only ever runs inside the caller's transaction:
async post(tx: TxHandle, posting: LedgerPosting): Promise<string> {
assertEntriesBalance(posting.entries); // fails the whole transaction, naming the bad posting
const txnId = posting.txnId ?? uuidv7();
await tx.insert(ledgerEntries).values(posting.entries.map((e) => ({ txnId, ...e })));
return txnId;
}
An unbalanced posting aborts the business operation that caused it, instead of landing in the table for a nightly check to find. The nightly ledger-balance job still exists as a second check.
"Append-only" enforced by the database
Mistakes in a ledger get fixed with a reversing entry, never an edit. Two layers make sure of that:
CREATE FUNCTION ledger_entries_reject_mutation() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'ledger_entries is append-only. Post a reversing entry instead of a %', TG_OP
USING ERRCODE = 'restrict_violation';
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER ledger_entries_no_mutation
BEFORE UPDATE OR DELETE ON ledger_entries
FOR EACH ROW EXECUTE FUNCTION ledger_entries_reject_mutation();
REVOKE UPDATE, DELETE, TRUNCATE ON ledger_entries FROM parkease_app;
GRANT SELECT, INSERT ON ledger_entries TO parkease_app;
Why both? The REVOKE stops the application role outright. The trigger catches a privileged session, such as a migration or someone in psql with the owner role. Row-level triggers don't fire on TRUNCATE, which is one reason the REVOKE includes it. A superuser can still disable triggers, so this is a strong guardrail rather than tamper-proofing. It turns "someone edited a ledger row" from a risk you might never notice into something that takes a deliberate action.
Splitting refunds without losing a paisa
A partial refund has to be spread back across the owner's share, the fee and the GST. The obvious approach, mulRate(owner, 0.5) and then the same for the fee and the GST, rounds three times on its own, and three roundings can be off by one paisa in total. In this system, one paisa off means an unbalanced posting, and assertEntriesBalance would reject the whole refund transaction.
So refunds use largest-remainder allocation:
export function allocateProportionally(totalPaise: number, weights: readonly number[]): number[] {
const divisor = BigInt(weights.reduce((s, w) => s + w, 0));
const shares = weights.map((w) => {
const n = BigInt(totalPaise) * BigInt(w);
return { floor: n / divisor, remainder: n % divisor };
});
const parts = shares.map((s) => Number(s.floor));
let spare = totalPaise - parts.reduce((s, p) => s + p, 0);
// Hand the leftover paise to the largest remainders; ties go to the lower index.
const order = shares
.map((s, index) => ({ index, remainder: s.remainder }))
.sort((a, b) => (a.remainder === b.remainder ? a.index - b.index : a.remainder > b.remainder ? -1 : 1));
for (const { index } of order) {
if (spare === 0) break;
parts[index] += 1;
spare -= 1;
}
return parts; // always sums to exactly totalPaise
}
Two decisions in there:
- Deterministic tie-breaking. A refund recalculated during an incident investigation has to match the one that was actually written.
-
BigIntfor the multiplication. ₹1 crore is 10⁹ paise. Multiply that by a weight of similar size and you're past 2⁵³, where JavaScript numbers stop being exact integers. The biggest amounts are exactly where a silent rounding error would hurt most.
None of this is exotic. It's integers, CHECK constraints, one trigger and a 40-line function. Put together, they make whole categories of money bugs impossible to write instead of just unlikely.
Question for you: do you enforce append-only tables in the database, or trust the application layer? And has anyone here been bitten by float money in JavaScript?
Top comments (2)
@rakno Storing the quoted components and using deterministic remainder tie-breaking makes the calculation reproducible without today's rate card. One TypeScript boundary I'd harden is
weights.reduce(...)before theBigIntconversion: an unsafe sum can already have rounded before the exact arithmetic begins. Do you constrain every input and the sum to safe integers, or could the weights staybigintend-to-end? Conservation and deterministic-allocation property tests would be a useful companion to the database guards.plot twist: a ledger row you can edit later is still a story.
1 cut: when the dispute opens, can a buyer GET a signed usage tip, or only another mutable balance?
receipts > seals. marker0028h