The problem
A courier service is more than storing records. At any moment it must know who has each parcel, what state it is in, which hub it is at, and how much money is owed to whom.
A parcel goes from the merchant to a pickup rider, through a hub, to a delivery rider who collects the cash, and finally the merchant is paid after fees. Any step can fail or be retried, and the money must always add up.
Who it is for
It is designed for three groups: merchants who send parcels, riders who pick up and deliver them, and admins who manage hubs and approve payouts. It has not been deployed yet, so it has no users.
What I built
- Merchant registration, hubs, riders and user management.
- Delivery pricing, parcel creation and pickup requests.
- A parcel status workflow with full history and public tracking.
- Pickup and delivery flows for riders.
- A cash-on-delivery ledger, a merchant dashboard, and payout requests with admin approval.
Architecture
The project is split into Maven modules by business area: users, hubs, pricing, parcels and the ledger. Each module owns its own tables, so the parcel module cannot change ledger data directly. I chose Spring MVC so that a status change, its history and its ledger entry are saved in one transaction.
Key decisions
Optimistic locking on parcels
Each parcel has a version number. If two people update the same parcel at the same time, the second update fails instead of silently overwriting the first.
Database migrations from day one
I used Flyway instead of letting Hibernate generate the schema. That made it possible to store money with exact precision, enforce unique tracking numbers and seed pricing rules, and it keeps development and production databases identical.
Net payouts
Merchants are paid the cash collected minus delivery and cash-handling fees. Any negative balance carries over to the next payout, so the ledger always balances.
How I solved it
Payouts are where money bugs hide. A merchant might request a payout twice, or two admins might approve and reject the same payout at once. When a payout is requested, the merchant’s open ledger entries are locked, so a second request has to wait and then finds nothing left to claim:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select e from CodEntry e where e.merchantId = :merchantId and e.settlementId is null "
+ "and e.status <> com.delivery.ledger.enums.CodEntryStatus.PENDING")
List<CodEntry> findSettleableForUpdate(@Param("merchantId") Long merchantId);Approving or rejecting a payout locks it in the same way, so two admins are handled one after the other. Both cases are covered by tests. I also tested the complete flow from merchant sign-up to payout and logged every issue I found to fix before deployment.
What’s next
- Fix the remaining issues from end-to-end testing, then deploy.
- Make parcel creation safe to retry, so a repeated request cannot create two parcels.
- Send status updates to merchants and receivers, then add proof of delivery.