Search

    Lolo Cab: three ways to ask a vendor's balance, three answers

    Backend notes from Lolo Cab — money as an append-only ledger, the readers that disagreed with it, and the idempotency key that was already in the schema.

    7 min read

    Vendors on Lolo Cab earn on completed trips, get charged penalties when they cancel late, and buy subscription plans out of the same pot. TDS comes off the top. Somebody, eventually, has to answer one question: how much does this vendor actually have?

    This log is the money path — the part we got right, and the readers that quietly stopped agreeing with it.

    Money is a log, not a field

    The tempting shape is a balance number on the vendor document that you $inc up on a completed trip and down on a penalty. It reads well and it is one query.

    It is also a counter, and counters race — except this time losing an update means losing money, and nothing in the document tells you it happened. A wrong seat count gets reported by an angry driver within the hour. A wrong balance just sits there.

    So there is no balance field anywhere in Lolo Cab. There is a Transaction collection, append-only, with a signed amount and a type:

    trip_purchase          + earnings on a completed trip
    credit                 + manual credit
    penalty_removed        + penalty reversed
    tds                    − tax deducted at source
    penalty                − late cancellation
    purchased_subscription − plan purchase

    Earnings are positive, deductions are negative, and the balance is a question you ask the log:

    // illustrative — derive, don't store
    const [row] = await Transaction.aggregate([
      { $match: { vendorId, status: 'completed' } },
      { $group: { _id: null, balance: { $sum: '$amount' } } },
    ]);

    Every number has a row behind it with a description, a bookingId, and a timestamp. When a vendor disputes a deduction, support has the line item instead of a theory.

    Fix: for anything a user can argue with you about, store the events and derive the total. A mutable balance throws away the only evidence you have.

    Three readers, three answers

    That was the design. Then features shipped, and each one grew its own way of asking the same question.

    Screen What it sums Source
    Vendor dashboard — totalEarnings completed Booking fares Booking collection
    Admin user detail — walletBalance signed Transaction amounts the ledger
    Vendor dashboard — totalDeductedDts a separate Deduction collection neither

    Three sources. The vendor’s own dashboard reports earnings straight off the bookings, so it never subtracts a penalty, a TDS line, or a subscription the ledger already recorded. Admin reads the ledger and gets a smaller, correct number. Nobody is wrong in isolation; the vendor and the admin are simply looking at two different collections and calling both of them “earnings.”

    The Deduction collection is the worst of it — a second money model holding vendorId, amount, reason, duplicating what penalty and tds rows in the ledger already say. Write a deduction and the ledger doesn’t move. Write a penalty and the deductions total doesn’t move. Two records of one fact, drifting apart from the day both existed.

    Fix: one function that reads the ledger, called by every surface that shows money. If a concept needs its own collection, it stores the reason — the ledger still stores the amount.

    The ledger write is not part of the state change

    Completing a booking wrote the earnings row first and moved the booking second:

    // before — money moves first, the booking moves second
    await Transaction.create({
      vendorId,
      transactionType: 'trip_purchase',
      amount: booking.totalAmount || booking.fareAmount || 0,
      bookingId: booking._id,
      status: 'completed',
    });
    
    await Booking.findOneAndUpdate({ _id: id, vendorId }, { status: 'completed' });

    Two problems in four lines. If the second call fails, the vendor has been paid for a trip that is still started. And there is no guard on the booking’s current status — a flaky connection, a double tap, a client retry, and the same booking pays out twice. The ledger is append-only, so both rows are now permanent and the balance is permanently wrong.

    The cancellation path has the same shape in reverse: the penalty row is created before booking.save(), so a failed save leaves a vendor charged for a booking that never got cancelled.

    Fix: make the state transition itself the guard, and let the ledger row follow it.

    // after — the transition is the lock
    const booking = await Booking.findOneAndUpdate(
      { _id: id, vendorId, status: { $ne: 'completed' } },
      { $set: { status: 'completed', completedAt: new Date() } },
      { new: true },
    );
    
    if (!booking) return; // already completed — don't pay for it again

    Same lesson as the seat count: the condition belongs in the filter, not in an if above it.

    The idempotency key was already in the schema

    The best part. Transaction declares this:

    referenceId: { type: String, trim: true, default: null, index: true }

    Indexed. Never written. Not one call site sets it.

    The field exists because past-me knew retries were coming and stopped one step short. Filled in with something deterministic, it turns a replay into a no-op:

    referenceId: `booking:${booking._id}:trip_purchase`

    Add a unique partial index on referenceId and the second write fails on duplicate key instead of silently doubling a payout. The retry becomes boring, which is what you want from a retry.

    Fix: an idempotency key is only a key if something enforces uniqueness. An indexed field nobody writes is a comment.

    A withdrawal that never checks the balance

    // before — any positive number is a valid withdrawal
    const payout = await Payout.create({ vendorId, amount, status: 'pending' });

    That is the whole check. Ops caught bad requests by hand, which worked because volume was low and the admin queue is small — but the API’s opinion on whether a vendor can withdraw ₹50,000 is “sure.” The cancel path is careless in a smaller way: cancelling a pending withdrawal writes status: 'failed', so ops can’t tell a vendor who changed their mind from a payout that broke.

    Fix: validate withdrawals against the derived balance minus anything already pending, and give cancelled its own status. Manual review is a safety net, not an authorization layer.

    One more: money in floats

    Penalties are percentages of a fare, held as JavaScript numbers, rounded on the way out:

    Math.round(penaltyAmount * 100) / 100

    Fine for one 20% penalty. Less fine summed across a few hundred ledger rows, where the rounding is applied at display time and never at storage time. Integer paise would have cost nothing on day one.

    Takeaways

    1. Store the events, derive the total. A mutable balance field loses updates and destroys the audit trail at the same time.
    2. An append-only ledger only stays honest if one reader owns it. Every screen that sums a different collection is a future support ticket.
    3. Never write the money row before the state change lands — put the prior status in the update filter and write the row only if the transition actually happened.
    4. A field named referenceId is not idempotency. A unique index that rejects the second write is.
    5. Manual ops review is not access control, on withdrawals or anything else.

    More Lolo notes if the pricing and TDS rules turn out to be interesting. The private setup guides stay in the project notes — this log is the lessons, not the inventory.

    Fawad Naeem

    Fawad Naeem

    Full-stack developer in Lahore building React Native apps, Astro sites, and AWS-backed products. This log is where the shipping notes live.

    View portfolio ↗

    Working on something?

    If you want help shipping a mobile or web product, email me — or see past work on the portfolio.

    Occasional shipping notes

    No spam — just new build logs when they ship. Form is ready; provider wiring comes later.