DEV Community

Omar Derbala
Omar Derbala

Posted on

Cash on delivery is a ledger problem

A lot of online orders in Egypt are still paid in cash, at the door, to a courier. When your platform's money arrives in a courier's pocket instead of through a payment gateway, a delivery app quietly becomes an accounting system.

I built the backend for Rahwan, a logistics platform for Egyptian merchants run by Giantrex, where I'm founder and CTO. This post is about the part nobody demos: how the money is tracked between the vendor who sold the product, the courier holding the cash, and the platform that earns the shipping fee.

The problem

Take one order: a 500 EGP product with 50 EGP shipping. The customer pays the courier 550 EGP in cash. At that moment:

  • the courier is holding 550 EGP that isn't theirs,
  • the vendor is owed 500 EGP for the product,
  • the platform has earned 50 EGP in shipping.

None of that money has moved through a bank yet. It moves later, in batches: couriers hand in cash, vendors get paid out. And sometimes the delivery fails, and the vendor owes the platform shipping for a trip that earned nothing.

A balance column per user can't explain any of this. When a vendor asks "why am I owed 12,340 EGP?", you need every movement that produced that number.

One wallet per party, one row per movement

Every party gets a wallet: vendors, couriers, and one system wallet for the platform. A wallet is polymorphic, so the same table serves vendors and users:

Schema::create('wallets', function (Blueprint $table) {
    $table->id();
    $table->morphs('walletable');               // Vendor or User
    $table->decimal('balance', 15, 2)->default(0);
    $table->decimal('total_credited', 15, 2)->default(0);
    $table->decimal('total_debited', 15, 2)->default(0);
    $table->timestamp('last_transaction_at')->nullable();
    $table->timestamps();
    $table->unique(['walletable_type', 'walletable_id']);
});
Enter fullscreen mode Exit fullscreen mode

The balance is a cache. The truth is the transactions table, which is append-only. Each row says who, which direction, why, and what the balance was afterwards:

Schema::create('transactions', function (Blueprint $table) {
    $table->id();
    $table->string('reference_number', 50)->unique();
    $table->foreignId('order_id')->nullable()->constrained();
    $table->morphs('transactable');             // the party
    $table->string('type', 20);                 // credit | debit
    $table->string('category', 50);             // why
    $table->decimal('amount', 15, 2);
    $table->decimal('balance_after', 15, 2);    // snapshot
    $table->json('metadata')->nullable();
    $table->foreignId('created_by')->nullable()->constrained('users');
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

Two words carry the whole model. A credit means the platform owes that party money. A debit means the party owes the platform. The category says why: order_subtotal, shipping_fee, cod_collection, settlement, adjustment.

balance_after costs one column and pays for itself the first time someone disputes a number: you can read any wallet's history top to bottom and watch the balance move, without replaying anything.

What happens when a parcel is delivered

Nothing is written when the courier picks the parcel up. Money only becomes real when the order is delivered (or fails). For a cash order, delivery writes three rows in one database transaction:

Party Type Category Amount
Vendor credit order_subtotal 500
Courier debit cod_collection 550
Platform credit shipping_fee 50

The credits (500 + 50) equal the debit (550). Every delivered order nets to zero across the three wallets, which is the property that makes this a ledger and not a pile of numbers. If an order ever doesn't net to zero, something is wrong, and you know exactly which order.

return DB::transaction(function () use ($order, $courier) {
    $vendor = $order->vendor;

    $this->wallets->credit($vendor, $order->subtotal,
        TransactionCategory::ORDER_SUBTOTAL, $order->id);

    $this->wallets->debit($courier, $order->total_amount,
        TransactionCategory::COD_COLLECTION, $order->id);

    $this->wallets->creditSystem($order->shipping_cost,
        TransactionCategory::SHIPPING_FEE, $order->id);
});
Enter fullscreen mode Exit fullscreen mode

Two other cases fall out of the same vocabulary:

  • Failed delivery. The customer refuses the parcel. The vendor is debited the shipping fee: they owe the platform for the trip.
  • Prepaid orders. The vendor was already paid online, so there's nothing to collect. The product revenue is still recorded for the vendor's reports, as a row flagged internal that never touches a balance or a settlement.

Settling up

Balances drift for days: couriers carry cash, vendors accumulate credit. Settlement is the moment the platform squares up with one party for a period.

It works on transactions, not balances. Take every transaction for that party in the period that isn't already in a settlement, and sum them with their sign:

$transactions = $this->wallets->getUnsettledTransactions($party, $from, $to);

$total = $transactions->sum(fn ($t) => $t->signed_amount);

$type = $total >= 0
    ? SettlementType::PAYOUT       // we owe them
    : SettlementType::COLLECTION;  // they owe us
Enter fullscreen mode Exit fullscreen mode

The settlement stores each transaction it covers as a settlement item, so a transaction can only ever be settled once, and the vendor's statement can list exactly which orders a payout covers.

When the money actually changes hands, someone confirms the settlement, and that writes one more transaction that offsets exactly the settled amount: a debit for a payout, a credit for a collection. The settlement itself becomes part of the history rather than an edit to it.

A courier who delivered ten orders ends up with one collection for the total cash they hold. A vendor ends up with one payout: product revenue minus any shipping they owe for failed deliveries.

Concurrency

Two deliveries for the same vendor can complete within the same second. Every write happens inside a database transaction that locks the wallet row first:

$wallet = Wallet::where('walletable_type', $party::class)
    ->where('walletable_id', $party->id)
    ->lockForUpdate()
    ->first();
Enter fullscreen mode Exit fullscreen mode

The second writer waits for the first, reads the updated balance, and writes its own row with the right balance_after. Without the lock, both would read the same balance and one credit would quietly disappear.

What I'd do differently

The model has held up, but three things are on my list:

  1. Integer money. The columns are decimal(15,2), but the arithmetic happens in PHP floats. That's fine at this scale and wrong in principle. Amounts should be integers in the smallest unit (piastres), or every operation should go through bcmath. I did this from day one on another booking platform, and I'd do it here too.
  2. The first wallet. lockForUpdate() can't lock a row that doesn't exist yet, so two simultaneous first transactions for a brand-new courier can both try to create their wallet. The unique index stops the duplicate, but the loser should retry instead of failing.
  3. The invariant as a test. "Every delivered order nets to zero" is the best property this system has, and it deserves a test that asserts it for every order type, plus a nightly check against production data.

The takeaway

Cash on delivery looks like a payments feature and is actually a bookkeeping problem. The shape that works is old and boring: an append-only journal, a balance that's only a cache of it, every movement balanced, and settlements that add rows instead of editing them. Accountants worked this out a few centuries ago. Software just has to not forget it.


I'm Omar Derbala, founder and CTO of Giantrex. More of my work is at derbalajr.com.

Top comments (0)