Let's talk
engineering

The deposit doubled because the payment landed beside the expectation, not against it

The first real customer payment through the new gateway went through cleanly. The customer paid the rental balance and a five thousand rupee security deposit in one transaction. The booking page then asked him for five thousand rupees, and the expected deposit on the settlement screen had gone up to ten thousand.

Nothing about the payment was wrong. The ledger write was wrong, and it was wrong in a way worth spelling out because it is a modelling mistake rather than a coding one.

How the deposit got to be a row at all

For most of this system’s life a deposit entry in the ledger meant one thing: money received. The row required a payment method. Writing one asserted that cash was in hand, and the settlement maths subtracted it from what the customer still owed.

That made the expected deposit unrecordable. It was derived and only derived — the vehicle’s standard deposit, plus an inter-state amount when the booking’s upstream extras carried a particular key. Both figures live on the vehicle. So a booking that needed a larger deposit than the car’s standard one had nowhere to say so, and the office had no way to write down “we are going to ask for thirty thousand on this trip” without asserting that thirty thousand had already been taken, which would have reduced what the driver was told to collect. Backwards, in exactly the direction that loses money.

The first attempt at this was a pair of override columns on the booking, with a reason, an author and a timestamp. It worked. The client looked at it and asked for something else: put the deposit in the settlement table as a row like everything else, and let the office collect it by flipping the row. So the override was dropped entirely and the deposit became a ledger row carrying the same settled-via flag that deductions already had, seeded as “to be collected” the moment a booking arrives from upstream.

That is a better model, and I want to say why in plain terms, because it is the transferable part. One row now covers the expectation, any correction to it, and the collection. The office edits the figure while it is uncollected. Collecting it flips a state on the same row. There is never a moment where the expected amount and the received amount are two separate records that have to be reconciled by whoever is reading the screen.

Checking the impact before building was what made this cheap: all 410 existing deposit rows in production already carried a settled-via value of cash, so nothing already recorded changed meaning, and unlike the override version this needed no migration at all — only columns that already existed.

What the gateway payment did wrong

The gateway handler credited money by writing ledger rows. Rental balance: write a payment receipt. Deposit: write a deposit receipt with the payment method set to the gateway.

Which is precisely the old model. A booking now already has a deposit row in it, seeded as uncollected, and the handler wrote a second one beside it. Expected deposit is resolved from the rows, so two rows meant ten thousand expected. Five thousand of that was now marked collected, five thousand was still pending, and the customer was asked for the pending one.

The fix is one sentence: a gateway deposit payment settles the pending rows in place, oldest first, under a row lock, and a part payment splits a row so the two halves always add back to the original figure. Nothing new is inserted unless the money exceeds every expectation on the booking.

When a record exists that says money is expected, a payment must resolve that record. Creating a new one is only correct when the payment corresponds to no expectation at all — and that case should be visible, not the default. If you find yourself writing an insert on the receipt path, ask what happens to the row that was already waiting.

Four things the testing found that reading would not have

The rebuild was tested end to end on a copy of production, and four defects came out of that which no amount of reading the diff would have surfaced. They are all the same category — a rule written for the old model still enforcing itself against the new one.

The payment-method guard rejected a deposit nobody had collected yet. Correct under the old rule, where a deposit row meant money in hand. Nonsense under the new one.

The split first-payment form vanished the moment a seeded row existed. That form is offered only while every row on the booking is upstream-sourced. Seeding a deposit made the booking mixed-source, so the form the counter uses to take the first payment simply disappeared.

Collecting through that form created a second deposit row instead of flipping the seeded one — the same defect the gateway later hit, found earlier and fixed in one place but not the other.

The expectation was resolved from the rows as a whole rather than per lane. So a booking with a security-deposit row seeded but no inter-state row silently stopped asking for the inter-state deposit. That one is the nastiest of the four, because the answer it produces is a smaller number and smaller numbers do not look like errors. It now resolves per lane, with a row winning where one exists and the vehicle’s figure filling the gap.

The form that asked the same question twice

One more thing came out of it, and it is a design point rather than a bug.

Giving deposits a collected state left the entry form asking two overlapping questions: Direction (“money in” or “money out”) and “has this been collected?”. Four combinations, of which one means nothing at all — money out, not yet collected — and two say the same thing in different words.

The office found that confusing, and they were right to. It was replaced with a single Status control carrying the three states a deposit actually has: to be collected at pickup, collected and held, returned to the customer. Direction is hidden for deposits entirely and derived from the answer, so the stored pair is unchanged and nothing downstream needed touching.

If a form offers a combination of inputs that cannot mean anything, the inputs are the wrong inputs. The test is mechanical: enumerate the combinations, and if any of them is nonsense or duplicated, you are asking about the implementation rather than about the thing. The user is being made to translate.

Where this leaves the model

Deposits taken by the gateway now carry their own provenance and are locked to everyone, because settling a row in place left it still looking like the office-owned expectation it used to be, with edit and delete controls beside it. Seeded rows stay editable, so an inter-state top-up still works — as a separate row, which is the truthful record anyway.

The rule I would give anyone modelling money in a workflow: decide whether each row is a fact about what happened or a statement about what is meant to happen, make that state explicit on the row, and make the transition between them the only way a fact ever gets written.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch