NexGate Shipping Service — Development guide
Status: design agreed in discussion (2026-10-12), not built yet. Built on the Location Service
(wards, shop locations, user addresses, frozen places — see commerce_logistics_changes.md).
Source specs: doc-hub Logistics Service book (location spec + commerce/location/logistics master guide).
Where this document and the master guide differ, this document wins (it records our decisions).
Rule of thumb for everything below: simple over clever. Shipping prices are placeholders (0) until this service is built; build what is decided, keep a visible list of what is not.
1. Principles
- Two ways to get an order: PICKUP or DELIVERY, chosen per shop. The shipping service owns both.
- One shop's order = one parcel. A cart with three shops = three parcels, three fulfillments.
- User addresses are places only (pin, label, directions). Order data — who receives, notes — lives on the order, never in the address book.
- Everything about places is frozen on the order when the checkout session is created. Later edits to a shop location, an address or a price never change an existing order.
- Money sits in escrow and moves on proof (codes), never on trust.
- Shippers describe their own coverage, prices and times. NexGate never forces one model.
- No manual approval queues. Trust comes from documents, codes, reviews and automatic rules.
- Orders are accepted 24/7; timing is honest. Closed hours delay work, never the sale.
- Pickup and delivery are designed together. Every feature must say what it does for a pickup order.
2. Big picture
MESSAGES (everyone talks only to NexGate; the SHIPPING AGREEMENT is the deal they all sign — §4.8)
buyer ── estimation / quotation / agreement / pay ──►┐
seller ── accept / ready / verify pickup code ───────►├── NexGate ── escrow: items + legs
shipper ◄── offers ── ── accept / status / code ─────►┘ (native: app · integrated: API)
PARCEL (physical, with the code that unlocks each handover)
pickup: shop ──[buyer's code]──► buyer
delivery: shop ──[pickup code]──► shipper ──[delivery code]──► buyer
chain: shop ──► shipper 1 ──[handover code]──► shipper 2 ──► … ──► buyer
Package layout (new top-level shipping_service/, next to location_service/)
| Package | Owns |
|---|---|
fulfillment/ |
one record per shop order: PICKUP or DELIVERY, frozen places, receiver, note, status, codes, timers. (Replaces the Step-4 e_commerce/fulfillment JSON — the order links to this record.) |
business/ |
native shippers ("delivery businesses"): KYC, Service Setup, coverage, pricing, hours, stations, reviews |
providers/ |
integrated shippers: one adapter per provider |
engine/ |
estimation + quotations: coverage, pricing, ETA, chains, ranking — native and integrated together |
jobs/ |
offers, accept / reject, reassignment, status steps, handovers |
3. Fulfillment: pickup and delivery
3.1 Pickup
placed → accepted → ready → collected → completed
exits: cancelled (buyer before accept, or seller) · expired (14 days)
- accepted — the seller took the order (the seller's signature, §4.8).
- ready — one tap, button "Packed & ready" (packing and ready are the same moment; not split) → buyer is told "ready at Ubungo, Gate B" (directions, shop phone).
- collected — the seller enters the buyer's 4-digit code at the counter.
- completed — the buyer tapped "✓ It's what I ordered" → money released.
- Pickup time (optional) — after Ready the buyer sees "When will you come?": a date + time picker offering only the shop's opening hours inside the window. The seller sees it on the order ("Buyer coming Thu 15:00"); the buyer gets a reminder that morning; time passed and not collected → the buyer is asked to pick a new time. Changeable any time, not binding, a field on the order (not a status); it does not move the window.
- Collection window 14 days, counted from Ready: reminders on the Ready day, day 7, day 12 and day 13 ("last day tomorrow"); not collected by day 14 → auto-cancel, full refund, stock back.
- The seller can cancel at once while only the buyer and the seller are involved — no waiting for the window;
they talk by phone (damaged, out of stock, the buyer asked…). No reason list: one pop-up —
"I have already informed the buyer about this cancellation" → confirm → cancelled, buyer notified, full
refund, stock back (the seller fixes stock by hand if the item is damaged).
- Pickup: any time before collected.
- Delivery: only before Ready — no shipper has an offer yet (heads-ups and Upcoming cards are not commitments; they disappear). After Ready, shippers are involved → open (§15 #13).
3.2 Delivery (door-to-door)
placed → accepted → ready → shipper accepted → heading to the shop → picked up → on the way → delivered → completed
(the same accepted / ready / completed as pickup; "ready" is when the offers go out)
Heading to the shop — the shipper taps it when they set out. It marks when cancelling the shipping starts to cost the buyer (§3.4a).
3.3 Delivery (station)
placed → accepted → ready → shipper accepted → heading to the shop → dropped at station → in transit → at destination station → collected → completed
(the parcel always starts with a collection at the shop — by a door shipper, or by the station business itself when
it collects from shops, §7.2a)
(the buyer collects at the station; station staff enter the buyer's code)
3.4 Switching (buyer only, many times allowed within the rules)
| From → to | Allowed | Money |
|---|---|---|
| Pickup → delivery | any time before the buyer collects | fresh quotes at today's prices; buyer pays the delivery fee from the wallet into new leg part(s); if the order is already ready, the offer goes to the shipper at once |
| Delivery → pickup | before pickup (same rules as cancelling the shipping, §3.4a) | leg part(s) refunded, minus the wasted-trip fee if a shipper is already heading to the shop |
3.4a Cancelling (buyer)
| Situation | Buyer can |
|---|---|
| Before the seller accepts | cancel the whole order — full refund |
| After the seller accepts, no shipper accepted yet | cancel the shipping (keep the order): pickup (delivery refunded) or a new delivery proposal (new quotation → new agreement; pay or get back the difference) |
| A shipper accepted, not heading to the shop yet | cancel the shipping → pickup or a new proposal; free (like Bolt); the shipper is released and told (no reliability effect on them) |
| A shipper is heading to the shop | cancel the shipping with a wasted-trip fee paid by the buyer: native = 50% of that leg's cost, Bolt = Bolt's own cancellation charge |
| Parcel picked up | no cancelling the shipping — only a destination change (§4.8) or, in a chain, stop at the next meeting point (legs not started are refunded) |
| Chain failed — a shipper didn't accept and no replacement was found, or the buyer declined the dearer proposal | drop the shipping → pickup, a new delivery proposal, end the chain earlier — or cancel the whole order (full refund, stock back; not the buyer's fault) |
Rule: whoever causes the cost pays it. Switching delivery → pickup after the seller signed does not need the seller to sign again (they agreed to hand the order over; who collects is the buyer's choice) — the seller is told.
3.5 What every fulfillment record holds
type · pickup place (the shop, frozen) · drop-off place (delivery, frozen) · receiver (name + phone; for pickup = who collects; default = the buyer) · parcel number (§11) · planned pickup time (pickup, optional) · note (this order only) · the agreement (legs, shippers, meeting points, costs, ETAs) · status · codes · who paid what (free shipping split) · timestamps of every step.
4. Choosing: estimation → quotation → agreement
4.1 Three stages
| Stage | What | Where | Can change? | Lifetime |
|---|---|---|---|---|
| Shipping estimation | a hint: cost {from,to}, eta {from,to} |
product cards and lists | — | cached, not stored |
| Shipping quotation | the proposal: recommended chain (legs, shippers, meeting points, cost, ETA) + alternatives per leg | add-to-cart / buy-now sheet, the cart | yes — destination, shipper per leg, meeting point, pickup ↔ delivery | prices held 5 days while the parcel is unchanged |
| Shipping agreement | the deal | created at checkout from the quotation | editable on the checkout screen until paid | locked once paid for the order's whole life |
card "🚚 20,000 – 35,000 to Mbeya" ← estimation
└ sheet / cart: QUOTATION — buyer edits ("collect at Mbeya Bus Stand", "use Abood")
└ checkout: AGREEMENT — still editable before paying
└ paid: LOCKED — shippers bound to these prices and legs
Quotation rules
- Holds its prices 5 days as long as the parcel is unchanged (same items, quantities, destination, choices).
- Any change → a new quotation at current prices (the buyer made the change and sees the new cost).
- Older than 5 days → refreshed at today's prices and shown in the cart before checkout.
- A shipper changing its price table never affects quotations it already gave, or agreements.
Agreement rules
- Made when the checkout session is created. Edits on the checkout screen (before paying) re-calculate it and re-check the wallet; the 30-minute session expiry does not restart.
- Once paid: the price is locked; the agreement still needs the seller's and every shipper's signature before the parcel moves. After payment it changes only through agreement changes (§4.8).
4.2 Destination (where the quotation ends)
The start is always the shop where the product is. The buyer picks the end — no saved address needed: saved address · recent pin · "use my location" · search a known place (Google Places in the app, e.g. "Mbeya bus stand") · drop a pin. All end as a confirmed pin that we resolve (Google only in the app; we never store Google's text; the buyer may add their own label).
Meeting-point recognition: a destination pin within 1 km of a station → the quotation offers
"🚌 Collect at
4.3 Add-to-cart / buy-now sheet
Deliver to (header address, changeable) · quantity · Pickup or Delivery with the ⭐ quotation pre-selected and "change" (edit legs / destination).
POST /api/v1/e-commerce/cart/add
{
"productId": "…", "quantity": 2,
"fulfillment": { "type": "DELIVERY", "quotationId": "…" }, // the SHOP's choice
"dropoff": { "addressId": "…" } // only if changed in the sheet: the CART's destination
}
4.4 Cart rules
-
The choice belongs to the shop, not the item — items from one shop share it.
-
One destination per cart; changing it re-quotes every shop.
-
Cart endpoints: set the cart's destination · set / edit a shop's quotation · get options for a shop.
-
Checkout from the cart turns its quotations into agreements. Buy-now does the same from the sheet.
-
Unsaved new pin: held on the cart as its destination (pin + directions) for as long as the cart exists; becomes a recent pin only after payment (abandoned carts never add recents). "Save as" → saved address at once.
-
Default for a shop newly added to the cart:
Buyer has… Shop's parcel defaults to a destination and a shipper covers it Delivery with the ⭐ quotation a destination, no shipper covers it Pickup + "Delivery not available to Tabata" no destination at all Pickup + "Add a delivery address to see delivery options"
4.5 Estimation
POST /api/v1/shipping/estimations
{ "dropoff": { "addressId": "…" }, "productIds": ["…", "…"] }
→ [ { "productId": "…", "cost": { "from": 3500, "to": 7000 }, "eta": { "from": "30 min", "to": "2 h" },
"freeDelivery": false, "pickupArea": "Ubungo, Ubungo, Dar es Salaam" } ]
cost range = cheapest → dearest of the sensible options (dominated / outliers removed). No shipper →
cost: null (card shows "Pickup only"). Cached shop-area → buyer-area (Redis ~1 h). The app asks with the
address in its header; list and feed responses are unchanged. Prices are always called cost
(cost, originalCost when a discount applies).
4.6 What the app keeps vs the server
Rule: anything that affects money or a promise stays on the server; anything that is only "where the user was" can live in the app.
| The app may keep (no backend storage) | Why it's fine |
|---|---|
| Header "Deliver to" for card estimations | sent with every estimation request; estimations are only hints |
| Last destination used / last map position | convenience for the pin screen |
| A sheet in progress (before tapping Add) | nothing committed yet |
| Recently searched places | the search runs in the app (Google) |
| UI preferences (list / map, "See all") | personal display |
| An unsaved pin, to offer it again on the pin screen | never becomes a backend recent unless the order is paid |
| Must stay on the server | Why |
|---|---|
| The cart's destination + each shop's quotation | quotations hold prices 5 days — a promise; must work on any device |
| Agreements | bind shippers and money |
| Saved addresses, recent pins | shared across devices, used when ordering |
4.7 Multi-shipper per order
One parcel can travel through up to 3 shippers (legs), e.g.:
Leg 1 🚪 Kibuti Express Mlimani City shop → Magufuli Terminal (Dar) 3,500 2–3 h
Leg 2 🚌 Shabiby Magufuli Terminal → Mbeya Bus Stand 21,000 1 day
Leg 3 🚪 Mbeya Riders Mbeya Bus Stand → buyer's door 2,500 1–2 h
- Meeting points: legs connect only where both businesses hand over — a station business's stations, a door business's office, or a station inside a door business's coverage. Two points within 1 km count as the same place.
- Fewer legs are fine; the chain ends at the door or at a station, as the buyer wants.
- The engine always builds chains next to single-shipper options; ranking decides.
- In the quotation the buyer can stop early (collect at a station), swap one leg's shipper, or change a meeting point.
- Pickup and money per leg: §10, §11, §12.
4.8 The shipping agreement — parties, signatures, changes
The agreement is the deal every party signs: buyer, seller and every shipper on the route. It is its own entity and controls every important action on a parcel: one place decides whether a change is allowed, one history records who proposed and who approved, and money moves only on an agreement event (no other code path releases or refunds an escrow part).
Signatures — each party already says yes somewhere in the flow:
| Party | Signs when | Agrees to |
|---|---|---|
| Buyer | pays at checkout | items, destination, legs, cost |
| Seller | accepts the order (within 24 opening hours) | pack it and hand it over |
| Each shipper | accepts the offer at Ready | carry their leg (capacity — the price came from their own price table) |
The buyer can be charged a fixed price before the shippers sign because each shipper's price table is a standing offer; a shipper's signature means "I'll carry it", not "I accept the price".
States:
DRAFT created at checkout; the buyer can still edit
PENDING the buyer paid → waiting for the seller, then the shippers
AGREED everyone signed → binding on all; the parcel can move
DONE buyer confirmed "✓ It's what I ordered" → all escrow parts released
CANCELLED refunded as the cancel rules say (§3.4a)
- A shipper rejecting or timing out = declining to sign → that party is replaced (reassignment, §10a); the agreement stays PENDING.
- "The parcel doesn't move until all legs accept" = it doesn't move until AGREED.
- A shipper's Upcoming list (§6.6) = agreements waiting for that shipper's signature.
- A bulky mismatch (§8.4) = a shipper withdrawing their signature before pickup for a reason that is not their fault → back to PENDING, the leg is reassigned.
Changes. A change only re-opens the signatures it affects (swapping leg 2 never asks the seller or leg 1). Three kinds:
- Affects only me → applied at once; the others are told.
- Affects others → proposed; the affected parties approve; silence falls back to a written default by the deadline (so one silent party never blocks the deal).
- Rule-based (NexGate / the system) → applied under the rule, logged.
| Change | Kind | Who must approve | No answer by the deadline |
|---|---|---|---|
| Buyer edits before paying (DRAFT) | 1 | — | — |
| Buyer cancels the whole order before the seller signs | 1 | — | — |
| Seller cancels (pickup before collected / delivery before Ready; pop-up "I informed the buyer", §3.1) | 1 | — (buyer notified at once) | — |
| Buyer cancels the shipping / switches to pickup (before pickup) | 1 | — (shipper released and told; fee if heading to the shop) | — |
| Buyer changes the destination before any shipper accepted | 1 | — (re-quoted at today's prices) | — |
| Buyer changes the destination after a shipper accepted | 2 | the shipper(s) whose leg changes | original destination stays |
| Accepted leg's time moves > 4 h | 2 | that shipper (re-confirm) | treated as "no" → leg reassigned |
| Reassignment — same / cheaper and not later | 3 | — | — |
| Reassignment — dearer / slower / nobody | 2 | the buyer | 24 h, then automatic switch to pickup |
| Chain not fully accepted 24 h after Ready | 2 | the buyer | 24 h, then automatic switch to pickup |
| Bulky mismatch | 3 | — (seller pays the difference automatically) | — |
| Admin action (§6.7.2) | 3 | — (reason required, audit-logged) | — |
Destination change (buyer) — when it is allowed (like AliExpress: the app shows "Change delivery location" only in these statuses, and says up front when a courier must approve):
| Order status | Change? | How |
|---|---|---|
| placed / accepted (before Ready) | ✅ freely | no approval — re-quoted at today's prices; same shipper covers it → only the cost changes (pay / refund the difference); else the buyer picks a new option |
| ready, offers out, nobody accepted | ✅ freely | same; pending offers withdrawn, new ones sent |
| shipper accepted (incl. heading to the shop) | ✅ with approval | below — decline = that leg reassigned |
| picked up / on the way | ✅ with approval | below — decline = the original location stays |
| chain, some legs done | ✅ with approval | only the legs still to come are asked |
| delivered / collected / completed | ❌ | — |
| pickup order | — | "Deliver it instead" (§3.4) until collected |
Limit: 3 location changes per order; after that only support (admin) can change it.
With approval. The buyer proposes a new pin; NexGate checks the affected shipper covers it and prices it from that shipper's table. The shipper sees old and new side by side (response window 30 min):
| Shipper answers | Result |
|---|---|
| Accept at the new cost | the buyer pays the difference into that leg's escrow, or gets it back if cheaper |
| Accept at the old price (waives the increase) | nothing more to pay |
| Decline — before pickup | that shipper withdraws → the leg is reassigned (§10a: automatic if same / cheaper and not later, else the buyer decides) |
| Decline — after pickup | the shipper cannot withdraw (never abandon a carried parcel) → the original destination stays |
- Only the legs whose from / to changes are asked (a new final door asks the last shipper only).
- New pin outside the shipper's coverage: before pickup → handled like a decline (reassignment); after pickup → not possible now (later: paid redelivery, §15b).
- Not allowed once the parcel is delivered.
Sketch of the entity:
shipping_agreement order, status, version
├ agreement_party role (buyer / seller / shipper), who, leg, signed / declined / withdrawn + when
├ agreement_leg from / to place, shipper, cost, ETA ← each LEG escrow part points here
└ agreement_change type, proposed by, who must approve, deadline, default, status, before / after
5. Group purchase and installments
Group purchase
- Each member chooses their own pickup / delivery and pays the delivery fee with their payment.
- Group fails or member withdraws → delivery fee refunded with the items. Transferred payment → fulfillment moves with it.
- Price frozen at payment, even if the group fills days later; a shipper rejecting then → normal reassignment.
Installments
- Immediate (ships after the down payment): the full delivery fee is paid with the down payment.
- Layaway (ships after the last instalment): no shipping at checkout; pickup / delivery is chosen and the fee paid after the last instalment (same mechanism as "switch to delivery").
- Agreement cancelled before shipping → delivery fee refunded.
6. Shippers
6.1 Two kinds
| Native (delivery business on NexGate) | Integrated (external provider) | |
|---|---|---|
| Set up by | owner, in the app | NexGate: one adapter + an admin row |
| Coverage / prices | owner's tables | the provider's API |
| Jobs | offer in the app (required); SMS = notifications only | NexGate calls their API |
| Status | owner taps in the app | their webhooks / polling, mapped to our statuses |
| Money | owner's NexGate wallet | a provider ledger account, settled by NexGate per contract / invoice |
| Delivery proof | our codes | their proof of delivery, else the buyer's "received" button |
| Chains | can be any leg | single leg only (whole trip, in one city) |
Both answer the same contract: covers? · quotation (cost + ETA) · book · cancel · status — the engine never changes.
Shipper registry: native shippers register themselves (§6.2). Integrated providers are defined by the system (an adapter in code + one row created by NexGate) — never registered through the app; NexGate controls them fully (on / off, API keys, categories, limits, spend cap). Each integrated provider has its own ledger account and an admin status view (live rides, today's spend, balance owed, receipt differences, failed calls, webhook health). The engine and the admin shipper list treat both kinds the same way.
6.2 Native registration — two parts
Part 1 — KYC (POST /api/v1/shipping/businesses) — legal, checked once, auto-approved:
business name · contact phone · office location (exactly one; pin screen like shops) ·
business licence (file + number + expiry; number unique) · director's NIDA copy (file + number) ·
LATRA licence (file + number + expiry). Documents are private (owner + admins).
→ status KYC_APPROVED (not live).
Part 2 — Service Setup (own endpoints, any time after KYC: .../{id}/setup/profile|hours|coverage|pricing|limits,
GET .../{id}/setup = checklist):
profile (logo, description, WhatsApp, email, vehicle badge) · service type (door and / or station) ·
working hours · coverage · pricing (station businesses: optional collection from shops — areas + fee, 0 = free) · limits (max value, accepts bulky + extra, response window, heads-up on/off).
Live: automatically once it has service type + coverage + pricing (hours default Mon–Sat 08:00–18:00).
Statuses: KYC_APPROVED → LIVE ⇄ PAUSED (owner), SUSPENDED (admin / automatic). A user may own several.
Needs two new File Thunder upload types: private business document, business logo.
6.3 Working hours (shared piece: businesses, shops, stations)
Several ranges per day · special days (holidays) · temporarily closed · same-day cut-off · EAT · overnight ranges allowed.
6.4 Shops: hours and pause
- Shops get opening hours (same piece): "Open now / Opens Sat 09:00"; seller timers count only open hours.
- Pause (existing
TEMPORARILY_OFFLINE+ until + reason) gets a mode chosen by the seller: Keep selling — ships when back (timers wait) or Stop orders (Buy off,SHOP_CLOSED). Orders placed before the pause must still be handled. Reopens automatically at "until".
6.5 Integrated provider: Bolt (first and only one for now)
API reference in the repo: docs/shipping/providers/bolt/Bolt-Ride-Booker-API-Docs.pdf (Ride Booker API).
Bolt confirmed the same API is used for parcels. The Bolt Business account is ready.
NexGate is a reseller of Bolt. The API is literally /reseller/v1/...: NexGate books rides with its business
account, owns the customer (buyer and seller never deal with Bolt), Bolt bills NexGate monthly, and claims /
refunds go NexGate → Bolt support (email) → credited on the monthly statement. To the buyer, Bolt is just one
delivery option sold by NexGate.
Where Bolt is used
- Single leg, in one city, door-to-door only. Never inside a multi-shipper chain (no handover codes, its price
fare_idlives 5 minutes, a ride can go back to "searching"). - Bolt offers boda in Tanzania → small, non-bulky parcels only.
- Usually ranks ⚡ Fastest; ranked like any other option.
Mapping to the contract
| Our question | Bolt |
|---|---|
| Covers? | estimations — boda category with an ETA; eta: null or AREA_NOT_SERVICED = no |
| Cost + ETA | estimations → price {minimum_amount, maximum_amount}, eta |
| Book | at Ready: estimate again → check the held price → POST rides (fare_id, valid 5 min, single use) |
| Cancel | allowed until the ride has started |
| Status | webhooks (live only, from Bolt's 3 fixed IPs, with our token) or polling ride details (≤ every 10 s) |
| Bolt state | Our meaning |
|---|---|
| SEARCHING | offer out |
| DRIVER_ON_ROUTE_TO_CLIENT | accepted — rider coming to the shop |
| ARRIVED_AT_CLIENT | rider at the shop |
| DRIVING_WITH_CLIENT | picked up → on the way |
| COMPLETED | delivered (Bolt's proof) → buyer confirms with the button |
| NO_DRIVER_FOUND / CANCELLED | rejected → reassignment (another Bolt try or a native shipper) |
| back to SEARCHING after accepting, > ~10 min | cancel the Bolt ride → reassignment |
| CLIENT_DID_NOT_SHOW (shop didn't hand over) | seller pays Bolt's charge (from the items part at release); re-book on the next Ready |
Price
- Quotation shows Bolt's maximum amount + 2%. NexGate absorbs any difference within that (Bolt's final price can move — the receipt may change up to 24 h later).
- At Ready: new estimate within the held price → book; above it → treated as a dearer reassignment (buyer decides).
- Real charges recorded on the Bolt ledger account, reconciled monthly with Bolt's invoice.
On the ride
- Bolt "user" = the shop's contact (driver calls them for pickup); the receiver goes in the note to the driver (template: "NexGate parcel. Pickup: <shop, landmark>. Deliver to: <receiver name, phone, directions>. Do not open.").
- Bolt returns the driver's name, photo, phone, plate and a tracking link → shown to seller (check the plate at pickup, instead of a code) and buyer ("with Juma, boda T 123 ABC — track").
- Codes: Bolt drivers can't enter ours → pickup = plate check; delivery = Bolt COMPLETED + buyer's "received" button.
Control
- Admin kill-switch (Bolt off instantly) and a daily spend limit.
- OAuth2 client credentials, tokens 10 min — backend only, cached and refreshed. Sandbox available for building.
6.6 The shipper app (native shippers)
Same NexGate app, in a "delivery business" mode (like managing a shop) — no separate rider app. For now only the owner uses it; team members (dispatcher / rider roles, assigning jobs, the rider's name shown to buyers) come later when businesses grow. Earnings always go to the owner's wallet.
| Screen | What it's for |
|---|---|
| Dashboard | today's jobs, open offers, upcoming orders, earnings held / released, reliability, "Paused" banner |
| Upcoming | orders in a paid agreement with this shipper, from payment, before any offer (below) |
| Offers | job offers with a countdown: from / to on a map, parcel value band, bulky, cost, when the parcel reaches them (chains) → Accept / Reject (reason) |
| Heads-ups | "order coming from Sinza in ~15 min" (non-binding) |
| Active jobs | exact from / to (office, map, phone, hours), receiver, note, navigation, one button per next step |
| Codes and handovers | show the pickup code to the seller; enter the buyer's delivery code; chain "handed over" / "received" + code; optional photo |
| Re-confirms | "parcel now reaches you at 23:00 instead of 15:00 — still OK?" |
| History · Earnings · Reviews & reliability · Setup | past timelines · held → released per job, wallet · ratings, replies, scores · KYC, Service Setup, pause / resume |
Upcoming orders
Upcoming (5)
🕐 Kariakoo → Mikocheni · small · 7,000 Waiting for the seller to accept
📦 Sinza → Mbezi · bulky · 22,000 Seller is packing — offer expected soon
🚌 Leg 2: Magufuli → Mbeya Stand · 21,000 Parcel expected at your office tomorrow ~10:00
- Shippers see a parcel, never the order: parcel number, size / bulky, value band, from / to, their cost — not the items, prices or order number.
- Shown from payment; chips: waiting for the seller → seller packing (= heads-up) → for chain legs, when the parcel is expected. On Ready the card moves to Offers.
- Not a commitment (accepting is at the offer). Cards can disappear (buyer cancels, switches to pickup, seller times out) with a short note.
- Privacy: until the shipper accepts the offer it sees areas only (ward / district), parcel size / bulky, value band and its cost — not the exact pin, directions or receiver phone. Full details after accepting.
Endpoints (sketch) — also pushed live to the app, like chat:
GET /api/v1/shipping/businesses/{id}/upcoming
GET /api/v1/shipping/businesses/{id}/offers
POST /api/v1/shipping/offers/{offerId}/accept | /reject (reason)
GET /api/v1/shipping/businesses/{id}/jobs?status=active
POST /api/v1/shipping/jobs/{jobId}/status (next step)
POST /api/v1/shipping/jobs/{jobId}/handover (code + optional photo)
POST /api/v1/shipping/jobs/{jobId}/reconfirm
GET /api/v1/shipping/businesses/{id}/earnings
6.7 Admin side and settings
Build order: the admin dashboard (screens) is the last thing built, after everything works. But the admin endpoints needed to fix stuck orders ship with the shipping service (no screens yet) — §6.7.2.
6.7.1 Where each setting lives
1 — Properties file: fixed platform rules (change = config + redeploy)
| Setting | Value |
|---|---|
| Meeting-point range | 1 km |
| Loop guard (reassignments per order) | 3 |
| Re-confirm threshold for accepted legs | 4 h |
| Chain safety net / buyer decision timeout | 24 h / 24 h |
| Closed shipper: wait if it opens within | 4 h |
| Seller accept window | 24 opening hours |
| Pickup collection window (shop) / station + shipper-office hold | 14 days / 7 days |
| Destination changes per order | 3 |
| Quotation price hold | 5 days |
| Outlier rule / "expensive parcel" | 3× / 500,000 |
| Quotation options shown | top 5 |
| Codes: length / max wrong tries | 4 digits / 5 |
| Distance factor / rounding | ×1.3 / up to the nearest 100 |
| Estimation cache | 1 h |
| Auto-pause: missed offers warn / pause | 2 / 3 |
| Auto-pause: suspend after auto-pauses | 3 within 7 days |
| Acceptance rate warn / pause (last 20) | 50% / 30% |
| Broke binding: suspend after / found at fault: suspend after | 2 in 30 days / 2 in 30 days |
| False bulky claims → suspend | 3 |
| On-time warning (last 20 jobs) | 60% |
| Grace period (rate rules) | first 10 offers |
| Bulky mismatch: wasted-trip fee / auto-flag after | 50% of the leg cost / 2 mismatches (different shippers) |
| Defaults shippers can override | response window 30 min · station reach 30 km |
| Bolt | price buffer 2% · category boda |
2 — Per shipper, set by NexGate (the NexGate ↔ shipper agreement)
| Setting | Notes |
|---|---|
| Delivery commission % | default = a new fee type in the effective-dated fee table (0% now); a shipper with its own deal gets an override with a start date (past orders keep the old rate) |
| Settlement terms (integrated) | Bolt: monthly invoice |
| Daily spend limit (integrated) | e.g. Bolt 2,000,000 / day |
| On / off (integrated) | the kill-switch |
| Suspended / lifted (native) | with a reason the shipper sees |
3 — Per shipper, set by the shipper (Service Setup): hours, coverage, stations, prices, limits, heads-up on/off, its own response window, its own station reach.
4 — Admin-managed data: free-shipping campaigns, integrated provider records, review moderation, manual money actions.
6.7.2 Admin endpoints that ship WITH the shipping service (no screens)
SUPER_ADMIN only · every call needs a reason · every call is written to the audit log (who, what, when, why).
| Endpoint (sketch) | Why it's needed from day one |
|---|---|
POST /api/v1/admin/shipping/escrows/{escrowId}/release · /refund |
stand-in for disputes: fix a stuck or reported order by hand |
POST /api/v1/admin/shipping/orders/{orderId}/reassign · /cancel |
unstick a chain |
POST /api/v1/admin/shipping/businesses/{id}/suspend · /lift |
stop a bad shipper |
POST /api/v1/admin/shipping/providers/{code}/enable · /disable |
Bolt kill-switch |
PUT /api/v1/admin/shipping/businesses/{id}/commission |
per-shipper commission override (with start date) |
6.7.3 The dashboard (built last)
| Area | Contents |
|---|---|
| Native shippers | list / search / filter; one shipper's page: profile, private KYC documents, coverage map, stations, prices, reliability, reviews, jobs, earnings; suspend / lift; hide review; documents expiring soon |
| Integrated providers | kill-switch, spend limit, category, buffer; status view: live rides, today's spend vs limit, balance owed, receipt differences, failed calls, webhook health; settlement: record the monthly invoice, match the ledger, mark paid. API keys live in Vault, never in the database |
| Operations board | stuck things: chains not fully accepted, buyer decisions waiting, money held a long time, problem reports, Bolt rides stuck searching — with the §6.7.2 actions and internal notes |
| Campaigns | create / edit / pause / stop; budget used / reserved / left |
| Reports | by ward / district / region only (never per person): deliveries, on-time rate, average cost, pickup vs delivery, shipper ranking, Bolt spend |
| Audit log | every admin action |
Who can do what (when the dashboard comes): STAFF_ADMIN — view everything, suspend / lift, hide reviews, notes,
force reassignment. SUPER_ADMIN only — anything with money (release / refund, settlement), settings, Bolt
kill-switch and limits, campaigns, commission overrides.
7. Coverage
7.1 One model for door and station businesses
- Where do you work? Tick areas from the region → district → ward list (with search). A read-only map shows the ticked areas to confirm. No radius, no drawing.
- Trips:
WITHIN_EACH(both ends inside the same ticked area) orWITHIN_AND_ACROSS(both ends inside any ticked areas — crossing borders between them). Both ends must always be inside the ticked areas. - Lines (optional, station businesses with separate lines): groups of areas; trips only between areas of the same line.
7.2 Stations (station businesses)
- As many stations as needed; at least one per ticked area, several per area allowed (e.g. Dar: Magufuli, Kariakoo, Ubungo). A station = a pin (Google map + our resolve, like a shop) + counter name + phone + own hours. Each business has its own stations.
- Reach: the buyer collects within 30 km of a station (the shop side is reached by a door leg, §7.2a).
7.2a Handover points — where each leg picks up and drops off
-
Leg 1 always starts with a collection at the shop (option A, decided 2026-10-13) — the seller never leaves the shop. Who collects:
- A station business that collects from shops — it ticks the areas where it collects (same area picker) and
sets a collection fee (flat; 0 = free). How it gets the parcel to its station is its own business;
we don't model it. The collection is part of its leg: one leg, one business, one escrow part, the pickup
code at the shop, "heading to the shop" applies.
Leg 1 🚌 Shabiby (collects free) shop (Ubungo) → Mbeya Bus Stand 21,000 1 day - Otherwise a door shipper — the station route becomes a chain: door leg shop → station, then the
station leg(s):
Leg 1 🚪 boda shop (Ubungo) → Magufuli Terminal 3,500 Leg 2 🚌 Shabiby Magufuli Terminal → Mbeya Bus Stand 21,000 → buyer collects, or leg 3 door - Neither → no station route from that shop (door-only options, else pickup only).
The engine builds both kinds side by side; no special boost — free collection wins on cost by itself, and "fewer handovers" breaks ties when cost and arrival are equal. After launch (§16): the seller may drop at stations.
- A station business that collects from shops — it ticks the areas where it collects (same area picker) and
sets a collection fee (flat; 0 = free). How it gets the parcel to its station is its own business;
we don't model it. The collection is part of its leg: one leg, one business, one escrow part, the pickup
code at the shop, "heading to the shop" applies.
-
Station businesses hand over only at their own stations (a bus company never drives to a door).
-
Door businesses go wherever the leg needs, inside their coverage: the shop, a station, the buyer's door. Door shippers always come to the shop for leg 1 (the seller does not drop at a door shipper's office — after launch).
-
The engine chooses which station: origin = a station a door shipper can reach from the shop (both inside that door shipper's coverage), nearest first; destination = its station nearest the buyer (within reach) and priced in its pair table; bus → bus offices within 1 km. Several stations in reach → each is a candidate; ranking picks; the others are alternatives in the quotation.
-
Every leg in the agreement stores its exact from / to place (office name, pin, phone, hours), which is what that shipper's app shows ("Collect at: Shabiby office, Magufuli Terminal, gate 3 · [map] · open 06:00–20:00").
7.3 How the engine checks a trip
- Fast filter by area codes (no maps): both ends inside, trips rule, line rule.
- Stations (station businesses): origin station reached by a door leg from the shop (§7.2a), destination station nearest the buyer within reach.
7.4 Coverage request (sketch)
PUT /api/v1/shipping/businesses/{id}/setup/coverage
{ "areas": [ { "level": "REGION", "regionCode": "07" },
{ "level": "DISTRICT", "regionCode": "05", "districtCode": "03" },
{ "level": "WARD", "wardId": 1146 } ],
"trips": "WITHIN_AND_ACROSS",
"lines": [ { "name": "Line A", "areas": [0, 1, 2] } ],
"stationReachKm": { "seller": 30, "buyer": 30 } }
Location helpers: region → district → ward lists + search; coverage outline for the map.
8. Pricing and ETA
8.1 Cost of one parcel
base (shipper's table) + value add-on (insurance) + bulky extra = cost
Base
- Door — distance bands (price + time each). Distance = straight line × 1.3. Last band's end = max distance.
0–3 km 2,000 30–60 min 3–7 km 3,500 1–2 h 7–15 km 5,000 2–3 h - Door — flat: one price + one time.
- Station — pair table: from station → to station, price + time, "both ways" ✓. The app pre-fills the pairs; an empty pair = not carried.
- Station — collection from shops (optional): areas where it collects + one flat collection fee (0 = free) + the time it adds. Cost = collection fee + the pair price from its station nearest the shop.
Value add-on (optional ladder; fixed amounts per value range, % allowed for the top range) + max value accepted. Parcel value = the shop's items in the cart.
Bulky: new product field bulky (category can pre-tick); a parcel is bulky if any item is.
Shipper: accepts bulky yes / no + extra. Non-accepting shippers are filtered out.
Rounding: whole TZS; add-ons rounded up to the nearest 100. New prices apply to new quotes only.
8.2 ETA
- Always from when the shop is ready, always a range.
- Door: the band's time. Station: the pair's time. Plus closed hours (shipper / station hours, cut-off).
- Record real delivery times from day one (reliability-based ETA after launch).
8.3 Fees
- Delivery commission: new fee type, 0% for now, deducted from the shipper's part.
- Seller's platform fee: items + tax only (changes today's "fee on full total incl. shipping").
8.4 Bulky mismatch (product not ticked "bulky" but it is)
Rule: the seller pays (they set the flag), the buyer's price never changes, repeated mistakes fix the flag.
- Shipper reports at the shop, before pickup: "Can't carry — too big / heavy" (+ optional photo). Not possible after the pickup code is used.
- That leg is reassigned for bulky only: rejection reason
BULKY_MISMATCH— not counted against the shipper's reliability; the parcel is now bulky → only bulky-accepting shippers, with their bulky extra. - Money: the replacement's extra cost is paid by the seller automatically (taken from the items escrow at release). The buyer is not asked — just told "too big for a boda; a car is coming, no extra cost to you".
- Wasted trip: native first shipper gets 50% of its leg cost (properties setting), paid by the seller; Bolt → Bolt's cancellation charge, paid by the seller.
- Nobody can carry it: the buyer chooses pickup (delivery refunded) or cancel the whole order (full refund — seller's fault); the seller still pays the wasted trip.
- Fixing the flag: 1st mismatch → seller told to tick "bulky" (with the photo); 2nd mismatch on the same product from a different shipper → bulky set automatically; mismatches count in the seller's private reliability.
- Abuse: a shipper claiming mismatches on products others carry fine → flagged for admins.
- Opposite case (ticked bulky, actually small): no automatic change — higher prices are incentive enough.
8.5 Packing — the seller packs
- The seller packs (like AliExpress / Jumia): they know the item (fragile, leaking, box vs bag); packing happens before any shipper has the parcel; the box is part of the seller's item price, never a separate charge. The Ready pop-up ("I have packed it well", §10) records it.
- Liability: damaged because of poor packing → the seller; packed well and crushed on the way → the shipper (decided from the handover timeline and optional photos).
- The shipper checks the outside at pickup (QR scan, a look, optional photo).
- "Badly packed" refusal — same pattern as a bulky mismatch (§8.4): at the shop, before pickup only; reason
BADLY_PACKED(torn, leaking, a fragile item with no protection) + photo; not counted against the shipper. The seller repacks and taps Ready again → the same shipper is offered it again (normal window), else the leg is reassigned. The seller pays the wasted trip (native 50% of the leg / Bolt's charge), from the items escrow at release. The buyer's price never changes; the buyer is told "the seller is repacking your parcel". - Repeated
BADLY_PACKEDreports count in the seller's private reliability; a shipper reporting it on parcels others carry fine → flagged for admins (as §8.4 rule 7).
9. Engine and ranking
- Where: both places' ward / district / region.
- Who: native coverage + integrated providers (skip a provider slower than ~2 s).
- Filter: coverage, max distance, max value, bulky, closed / cut-off (ETA shifts, not dropped), paused / suspended.
- Cost + ETA for each.
- Clean-up: hide dominated options (dearer and slower) and outliers (> 3× the cheapest of the same type) — "See all" shows them.
- Labels
- 💰 Cheapest — ties → earlier arrival → reliability.
- ⚡ Fastest — by the latest arrival of the range — ties → cheaper.
- ⭐ Recommended — balance score; parcel > 500,000 → reliability-heavy; cheap parcel → price-heavy; else price + speed. At launch: price + time only (reliability added after launch).
- One option may carry two labels.
- Order: labelled first (⭐ 💰 ⚡), then grouped "To your door" / "Collect at a station" by cost; top 5 + "See all".
- Fairness: new shippers get a neutral reliability + "New" badge; exact ties rotate.
- Quotations hold their prices 5 days (§4.1); the sheet pre-selects ⭐.
10. Job flow
- Seller accepts within 24 opening hours — else auto-cancel + full refund. Shipper gets a non-binding heads-up (switchable per shipper).
- Seller taps Packed / Ready → a confirm pop-up with two ticks, both required: ☐ "I have packed it well" · ☐ "The parcel label (QR) / number is on the package" (delivery only; pickup asks only the first). Recorded with the time — part of the evidence trail if a parcel arrives damaged or unlabelled. Then → pickup: buyer told; delivery: the offer goes to every leg at once, each showing when the parcel reaches them. The parcel does not move until all legs have accepted.
- Response window 30 min (per shipper), counted only while the shipper is open. Shipper closed at Ready → wait if it opens within ~4 h, else reassign.
- Accept = the shipper signs the agreement (§4.8) — capacity, not price (the price is the agreement). After accepting it is binding: the shipper must find a way — no buyer rescue mid-way; failing = heavy reliability hit, auto-pause, dispute / liability. Reject (reason) or timeout before accepting → reassignment (§10a).
- Who taps what: seller — accept, ready, verifies the pickup code; shipper — accept / reject, every status, submits the delivery code; buyer — gives the code (or the button), confirms "it's what I ordered".
10a. Reassignment (a shipper rejects or doesn't answer — before accepting)
Only before a shipper accepts; after accepting it is binding.
-
Record: leg marked rejected (with reason) or timed out — a timeout counts exactly like a rejection. That shipper is excluded from this order and takes a reliability hit; 3 missed offers in a row → auto-pause.
-
Replace that leg only. Other legs keep their acceptance; nothing moves until every leg has accepted. The replacement is the best-ranked option using the label the buyer chose (💰 → cheapest replacement …).
-
Search in steps — stop at district:
Step Replacement Neighbouring legs 1. Nearby office within 1 km of the original handover points keep shipper + cost; get the updated exact office; no re-confirm 2. Same district office in the same district as the original point door legs re-route inside their coverage and are re-costed; the total of the affected legs is compared; neighbours already accepted get a re-confirm ("drop at Ubungo Terminal instead, OK?") 3. Beyond the district — no automatic swap buyer decides Door legs follow the same steps (first the same points, then a meeting point elsewhere in the district).
-
Compare with the agreement (for the affected legs):
- same or cheaper AND arriving no later (by latest arrival time) → automatic; the buyer is told; leg escrow re-pointed, difference refunded;
- dearer / slower / nobody → the buyer decides while the parcel is still at the shop: pay the difference (top-up into the same leg escrow) · end the chain earlier (later legs refunded) · switch to pickup (all legs refunded) · cancel (everything refunded).
-
The new shipper gets the offer (normal response window) — and may reject too (back to step 1).
-
Loop guard: 3 reassignments per order (not per leg) → after that the buyer is always asked.
-
Closed shipper at Ready: its window starts when it opens if that is within 4 h; otherwise reassign at once. 7a. Accepted legs whose time moves: moved by ≤ 4 h → an update, still bound; moved by > 4 h → re-confirm ("now 23:00 instead of 15:00, still OK?"); "no" → that leg is reassigned (counts toward the 3 per order). 7b. Chain safety net: not fully accepted 24 h after Ready → the buyer is asked: keep waiting · end the chain earlier · pickup · new delivery proposal · cancel the whole order. 7c. Buyer doesn't answer a decision screen: reminders, then after 24 h → automatic switch to pickup (leg escrows refunded; the buyer can still ask for a new delivery proposal).
-
Free shipping: seller / campaign caps are recalculated for the new cost; the buyer pays only what's above them.
-
The agreement keeps its history ("leg 3: Mbeya Riders rejected 10:42, busy → Mbeya Express 2,500").
-
Who is told: buyer (notice on auto swaps / decision screen) · seller ("waiting for a courier", nothing to do) · legs already accepted ("still waiting for the full route", or a re-confirm when their point moved).
11. Codes and handovers
Parcel number — identifies the parcel (the codes below prove the handover):
- One per parcel (one shop order), created when the seller accepts the order — so the label can be printed while
packing, before the Ready pop-up asks for it. Every order gets one, pickup too (the counter finds the parcel on
the shelf by it; a later switch to delivery keeps the same number); short and readable aloud, no 0/O or 1/I — e.g.
NX-4K7P-2Q. - The same on every leg: boda, bus company and station staff all see it; station staff search by it on arrival and when the buyer collects; the buyer sees it too.
- QR label at launch, made by the app (frontend) — the backend only gives the parcel number. From accept the seller's app shows a label: QR (the parcel number only — no personal data) + the number in large text + receiver name + from / to areas → print or share it to a printer. No printer → the seller writes the number on the box (the fallback always works).
- Checked at every pickup / handover: the receiving shipper (or station staff) scans the QR or types the number → the app confirms "✓ this parcel is yours" or "✗ wrong parcel — not on your job" before the code is used. Stops the wrong box leaving the shop or a busy station.
- Not secret (it's on the box) and separate from the order number, which never reaches shippers.
Parcel number NX-4K7P-2Q |
4-digit codes | |
|---|---|---|
| Purpose | identifies the parcel | proves a handover |
| Secret? | no — written on the box | yes — hashed, never logged |
| How many | one per parcel, for life | one per handover |
Codes
- 4 random digits, hashed, never logged, valid for one order. 5 wrong tries → locked; the owner gets a new one.
- Pickup code — shipper receives it after accepting; at the shop the seller verifies it, then hands over (proves the right shipper collected).
- Delivery code — buyer receives it once the parcel is on the way; the shipper submits it at the door.
- Shipper → shipper (multi-shipper): the giver taps "I've handed over", the receiver taps "I've received" and enters that handover's code; optional photo from either side. One side missing → flagged for disputes.
- Pickup at the shop / collection at a station — the same delivery code, entered by the seller / station staff.
- Buyer option (account setting): use a "received" button instead of the code. Default = code.
- Handover photos: optional for now (stored if taken) — the evidence trail for disputes.
- Integrated providers: their proof of delivery, else the buyer's button.
12. Money
12.1 Escrow parts per order
Buyer pays once → ORDER ESCROW
├ items part → seller (fee: items + tax)
└ leg part → shipper owner (native) / provider ledger account (integrated) ← one per leg
(pickup = items part only)
"Held" = in escrow, nobody's yet. Release = to the payees. Refund = back to the buyer. Each part can be released or refunded on its own.
One escrow record per part, all children of the order:
Order ORD-… (one shop's parcel) ← parent
├ ESC-…101 part ITEMS payee seller 60,000 HELD
├ ESC-…102 part LEG payee Kibuti Express legId L1 3,500 HELD
├ ESC-…103 part LEG payee Shabiby legId L2 21,000 HELD
└ ESC-…104 part LEG payee Mbeya Riders legId L3 2,500 HELD
- Each record:
orderId(the escrow table's existing, unused field — now filled) ·part(ITEMS / LEG) ·legId· its own payee (user or provider account) · amount · fee · status · ledger account. - Why separate records: each part moves on its own (refund only leg 3, re-point only leg 2, pay an innocent
rider in a dispute); everyone's history shows only their own money; the ledger already works per escrow;
it is the cart-fix mechanism (one payment → several escrows), whose
session_partkey folds into this. - Release ("it's what I ordered") = every escrow of the order, found by
orderId, in one transaction. - The buyer still sees one payment line listing its escrows.
- Table changes:
part,legId,orderIdfilled, and a provider payee.
12.2 When money moves
- Everything stays held until the buyer taps "✓ It's what I ordered" (after receiving) — then all parts are released together (seller and shipper).
- "⚠ Report a problem" → all parts frozen; the dispute involves buyer, seller and the shipper (the rider could have swapped or damaged the parcel); the admin decides from the handover timeline.
- No automatic release for now.
12.3 Adjustments and refunds
| Event | Money |
|---|---|
| Reassign — same / cheaper | leg part re-pointed to the new shipper; difference refunded |
| Reassign — dearer | buyer tops up the difference into the same leg part, or switches / cancels |
| Pickup → delivery | new payment for the leg part(s) |
| Delivery → pickup / cancel the shipping, shipper not heading to the shop yet | leg part(s) refunded |
| Cancel the shipping, shipper heading to the shop | leg part refunded minus the wasted-trip fee (native 50% of the leg / Bolt's charge), which goes to the shipper |
| Destination change | difference paid into / refunded from that leg part (nothing if the shipper keeps the old price) |
| Cancel before the seller accepts / pickup not collected in 14 days / seller cancels (§3.1) / group fails | all parts refunded |
| Installment immediate / layaway | legs with the down payment / a new payment at the end |
The escrow table must allow a provider payee (today every escrow needs a user).
13. Free shipping
Seller-funded (shop setting): areas (same picker) · optional min order (this shop's items) · "I pay up to X per delivery" · product override (use shop / always free / never free). Mixed parcel: free only if every item qualifies.
NexGate campaigns (admin): areas · dates · min order · cap per delivery · audience (everyone / first order) · shops (all / selected) · total budget → stops automatically; budget reserved at payment.
Rules
- The cheapest qualifying option becomes free (up to the cap); faster options cost the difference.
- Stacking: delivery cost − seller (cap) − NexGate (cap) = buyer pays.
- Shipper always paid in full: leg part holds the full cost = buyer's share + NexGate's share (from the campaign ledger account at payment) + seller's share (taken from the seller's items part at release).
- Refund → NexGate's share returns to the campaign budget.
- Display:
3,500Free; card badge "Free delivery"; search filter "Free delivery to my area".
14. Reviews, reliability, auto-pause
-
Reviews like shops (rating 1–5 + text, owner reply, moderation statuses, summary) — no subscribe — and only from people the shipper served (buyers it delivered to, sellers it collected from), tagged "Verified delivery". Shown on the shipper profile and in quotes ("⭐ 4.6 (120)").
-
Reliability (internal, automatic): acceptance rate, on time vs quoted ETA, failed / returned, disputes lost.
-
Auto-pause — three levels, warnings first:
Level Undone by Effect ⚠ Warning — notice ("one more missed offer and you'll be paused") ⏸ Paused owner taps Resume no new quotations / offers ⛔ Suspended admin only (reason) same + hidden from buyers Accepted jobs are always finished (binding); agreements not yet offered are reassigned at Ready.
Signal ⚠ Warning ⏸ Paused ⛔ Suspended Missed offers in a row (timed out) 2 3 — Auto-pauses — — 3rd within 7 days Acceptance rate (last 20 offers) < 50% < 30% — Broke a binding job (accepted, then failed) — 1st time 2nd within 30 days Found at fault in a problem report 1st time — 2 within 30 days False bulky-mismatch claims (flagged) — — 3 (admin review) On time vs quoted ETA (last 20 jobs) < 60% — — - Rejecting with a reason is allowed — it only counts through the acceptance rate, never "in a row".
- Grace period: rate rules (acceptance, on-time) apply only after the first 10 offers.
- "In a row" resets on the next accepted offer; 7 / 30 days are rolling windows.
- Every action shows the shipper why ("Paused: 3 offers missed — 10:02, 10:41, 11:15") and is recorded for admins.
- Numbers live in the properties file. Integrated providers are not auto-paused (kill-switch instead).
-
Two-way ratings (private): shipper rates the buyer (address, no-show) and the seller (late handover).
15. Open — to talk about next
| # | Topic | Where we are |
|---|---|---|
| 1 | decided — see §3.4a | |
| 2 | Buyer never confirms (all parts held) | proposed: a check window after the delivery code; no decision |
| 3 | Disputes | skipped for now (decided). Draft discussed: buyer / shipper / seller can open; reasons; evidence + automatic handover timeline; 48 h to respond; outcomes no fault / full / partial / return & refund / replacement; shipper liability capped at its leg part; one module for digital + physical |
| 4 | Failed delivery (buyer unreachable, refuses the parcel, return trip) | with disputes |
| 5 | decided — §14 | |
| 8–12 | decided — §10a items 4, 7a–7c; door shippers always come to the shop (§7.2a) | |
| 6 | decided — §4.4 | |
| 7 | decided — §4.4 | |
| 13 | Seller cancels a delivery order AFTER Ready | open — before Ready is decided (§3.1); after Ready shippers have offers or signed (heading to the shop → who pays the wasted trip?) |
15a. Future — decide after implementation
Parked on purpose (2026-10-12): build the main flows first, then decide these with real cases.
| # | Scenario | What we know so far |
|---|---|---|
| F1 | Buyer never confirms the delivery (never there to receive / never gives the code) | idea: the shipper holds the parcel and it is returned to the shipper's office; how long, who pays storage / the trip back, what happens to the money — to decide |
| F1b | Buyer received (code entered) but never taps "✓ It's what I ordered" | all escrows stay held (no auto-release decided); a check window was proposed — to decide |
| F2 | Failed delivery — buyer unreachable, refuses the parcel, wrong address | "we will find a way later": re-attempt? return to the shop or the shipper's office? who pays? |
| F3 | Parcel never collected at a station | no rule yet (shop pickup has the 7-day rule); days to collect, storage, return — to decide |
| F4 | Disputes, returns, shipper liability | skipped for now — draft in §15 row 3 |
15b. Plan for the unhappy paths (recommended — NOT implemented now)
Based on common practice (Jumia, DHL-style couriers, Glovo, bus parcel services). Principle: whoever caused the failure pays for it. These are recommendations to confirm after the main flows are built.
Recommended rules
F1 — Buyer not there to receive
- Rider calls the buyer and waits ~10 min, then marks "attempt failed" (time, location, optional photo). Buyer is notified: "We missed you — reschedule?"
- Up to 2 attempts; the buyer picks a new time for attempt 2 in the app.
- After the last failed attempt the parcel goes to the shipper's office and is held 7 days — the buyer can collect it there or ask for redelivery (pays a new delivery cost).
- Not collected in 7 days → returned to the seller. Buyer refunded items only; the delivery cost is kept by the shipper (work done) and the return trip is charged to the buyer.
F1b — Delivered (code entered) but the buyer never taps "✓ It's what I ordered"
- Auto-confirm after 3 days (like digital products today); 7 days for parcels above 1,000,000.
- Reminders at 24 h and 48 h. "⚠ Report a problem" stops the clock.
- Safe because the delivery code proves the buyer has the parcel.
F2 — Failed delivery
| Cause | Rule |
|---|---|
| Unreachable | F1 |
| Refused at the door, no reason | back to the seller; buyer refunded items minus delivery + return |
| Refused because wrong / damaged (seen at the door) | rider records reason + photo; back to the seller; buyer full refund; cost on the at-fault party (dispute) |
| Wrong / unclear address | rider calls; corrected spot inside the shipper's coverage → deliver (buyer pays any difference); else F1 |
F3 — Parcel never collected at a station
- Arrival notice; reminders on days 1, 3, 6.
- 7 days free (shop pickup is 14 days — a shipper's shelf space costs them; a shop already holds the item).
- Not collected → returned to the seller via the reverse route; buyer refunded items minus delivery + return.
- Daily storage fee: possible later, not at first.
Who pays
| Failure caused by | Pays |
|---|---|
| Buyer (absent, refused without reason, never collected) | loses the delivery cost + pays the return; items refunded |
| Seller (wrong / damaged item seen at the door) | both ways; buyer fully refunded |
| Shipper (lost, broken, gave up after accepting) | its leg + liability (with disputes, later) |
Plan — prepare now, switch on later
| Phase | What | When |
|---|---|---|
| 0 — data hooks (build WITH the shipping service) | so the rules above slot in without rework: delivery attempt records (time, location, photo, outcome); failure reason codes (UNREACHABLE, REFUSED, REFUSED_DAMAGED, WRONG_ADDRESS, NOT_COLLECTED); statuses ATTEMPT_FAILED, HELD_AT_OFFICE, RETURNING, RETURNED; the held-at place on the fulfillment; timers stored as data (not code constants); the reverse direction allowed on a leg (A → B and B → A) | now |
| 1 — F1b auto-confirm | the 3 / 7-day window + reminders (reuses the digital auto-confirm job) | first, after launch — unblocks seller and shipper money |
| 2 — attempts + office hold (F1, F2 unreachable) | attempt button for riders, reschedule for buyers, 7-day hold, collect / redeliver | next |
| 3 — returns (F1 step 4, F2 refused, F3) | reverse legs back to the seller, refunds minus costs, who-pays rules | with or after disputes |
| 4 — disputes (F4) | the full draft in §15 | later |
16. To do after launch
- ⭐ Recommended uses reliability (delivery history).
- Real ETAs from recorded delivery times.
- Licence expiry / document renewal (KYC re-check).
- Coverage exclusions ("all Dar except Kigamboni").
- Shop front photo.
- Paid packing by shippers ("we pack for you") for sellers without boxes.
- Seller may drop a parcel at a door shipper's office (instead of being collected at the shop).
- Seller drops at stations: shop setting "I can drop parcels at stations for free" + the districts they will go to (area picker; the shop's own district pre-ticked) → a station route without the door leg.
- Shipper team members (dispatcher / rider roles, job assignment, rider shown to the buyer).
- Account deletion removes addresses.
- Location step 4 records recent pins at checkout-session creation — change to after payment when building (§4.4).
- New dedicated shipping notifications (after the shipping service).
- Staging / production: PostGIS, ward file (at deployment).
17. Worked scenarios
Illustrative numbers. Each scenario uses only rules decided above.
A — Pickup, collected
Buyer in Sinza buys shoes from a shop in Ubungo, chooses Pickup.
| Step | Who | What happens | Money |
|---|---|---|---|
| 1 | Buyer | pays 60,000 | items escrow 60,000 HELD |
| 2 | Seller | accepts, then taps Ready | — |
| 3 | Buyer | told "ready at Ubungo, Gate B" (directions, phone); picks "I'll come Thu 15:00" (optional) | — |
| 3a | Seller | sees "Buyer coming Thu 15:00"; buyer reminded Thursday morning | — |
| 4 | Buyer + seller | buyer shows code 4821, seller enters it → collected | — |
| 5 | Buyer | taps "✓ It's what I ordered" | items released to seller (minus fee) |
If the buyer never comes: reminders, then after 14 days auto-cancel → 60,000 refunded, stock back.
| Instead | Who | What happens | Money |
|---|---|---|---|
| Day 2, buyer phones: "I can't come, cancel" | Seller | Cancel → pop-up "I have already informed the buyer" → confirm | 60,000 refunded, stock back |
| Before Ready, seller finds the shoes damaged, calls the buyer | Seller | same pop-up → confirm; fixes stock by hand | 60,000 refunded |
B — Door-to-door, one shipper
Phone 400,000, shop in Kariakoo → buyer's Home in Mikocheni (~9 km).
| Step | Who | What happens | Money |
|---|---|---|---|
| 1 | Buyer | sheet shows quotation: 💰 Dar Cargo 7,000 · 1–2 h | — |
| 2 | Buyer | checkout → agreement; pays 407,000 | items 400,000 + leg 7,000 HELD |
| 3 | Seller | accepts (Dar Cargo gets a heads-up), packs, taps Ready | — |
| 4 | Dar Cargo | accepts the offer → receives pickup code | — |
| 5 | Seller | verifies the rider's pickup code, hands over → picked up | — |
| 6 | Buyer | told "on the way", receives delivery code 0937 | — |
| 7 | Rider | buyer tells the code, rider submits it → delivered | — |
| 8 | Buyer | opens, taps "✓ It's what I ordered" | items → seller, 7,000 → Dar Cargo (one transaction) |
C — Multi-shipper, door in another region
Laptop 1,500,000, shop at Mlimani City (Dar) → buyer's door in Mbeya.
| Leg | Shipper | From → to | Cost | ETA |
|---|---|---|---|---|
| 1 | 🚪 Kibuti Express | shop → Shabiby office, Magufuli Terminal | 3,500 | 2–3 h |
| 2 | 🚌 Shabiby | Magufuli → Shabiby office, Mbeya Stand | 21,000 | 1 day |
| 3 | 🚪 Mbeya Riders | Mbeya Stand → buyer's door | 2,500 | 1–2 h |
| Step | What happens |
|---|---|
| Quotation | buyer edits: none — or "collect at Mbeya Stand" removes leg 3 (−2,500) |
| Ready | all three legs get the offer at once; parcel stays at the shop until all accept |
| Handovers | shop → Kibuti (pickup code) · Kibuti → Shabiby ("handed over" / "received" + code) · Shabiby → Mbeya Riders (same) · Mbeya Riders → buyer (delivery code) |
| Release | buyer taps "✓ It's what I ordered" → items + all 3 legs released together |
D — Reassignment, nearby swap (automatic)
In C, Shabiby rejects ("full today").
| Step | Result |
|---|---|
| Search step 1 (1 km) | Abood: offices 300 m (Magufuli) and 500 m (Mbeya Stand) from Shabiby's · 20,000 · 1 day |
| Compare | cheaper, not slower → automatic |
| Money | leg-2 escrow re-pointed to Abood; 1,000 refunded to the buyer |
| Others | Kibuti and Mbeya Riders keep their leg and price; get the new exact office ("Abood office, gate 5"), no re-confirm |
| Buyer | told "New courier for Dar → Mbeya: Abood, 1,000 refunded" |
E — Reassignment, same district (re-confirm)
In C, no other bus company within 1 km at Magufuli; one leaves from Ubungo Terminal (same district).
| Step | Result |
|---|---|
| Search step 2 (district) | Kilimanjaro Express from Ubungo · 21,500 |
| Neighbours | Kibuti re-routed shop → Ubungo Terminal, re-costed 3,000 (shorter) |
| Compare | affected total 24,500 vs agreed 24,500 → same, not slower → automatic |
| Re-confirm | Kibuti had already accepted and its drop point moved > 1 km → re-confirm "drop at Ubungo Terminal instead, OK?" |
| If Kibuti says no | its leg is reassigned in turn (counts toward the 3 per order guard) |
F — Chain fails → buyer chooses
In C, nobody in the district can do leg 2 at the agreed price; best is 30,000 (+9,000).
| Buyer chooses | Result |
|---|---|
| Pay the difference | +9,000 from wallet into the leg-2 escrow; new shipper gets the offer |
| End the chain earlier | e.g. collect in Dar — later legs refunded |
| Pickup | all leg escrows refunded (27,000) |
| New delivery proposal | new quotation → new agreement |
| Cancel the whole order | full refund; stock back (allowed because the chain failed) |
G — Switching
| Case | Allowed? | Money |
|---|---|---|
| Ordered Pickup, seller already Ready, buyer taps "Deliver it instead" | ✅ before collecting | fresh quotation; pays delivery cost; offer goes to the shipper at once |
| Ordered Delivery, no shipper accepted yet, buyer switches to pickup | ✅ | legs refunded |
| Ordered Delivery, shipper accepted, not heading to the shop yet | ✅ free | legs refunded |
| Ordered Delivery, shipper heading to the shop | ✅ with a fee | leg refunded minus the wasted-trip fee |
| Ordered Delivery, parcel picked up | ❌ | only a destination change (scenario O) |
H — Cancelling
| When | Allowed? | Money |
|---|---|---|
| Before the seller accepts | ✅ whole order | full refund |
| Seller accepted, no shipper accepted | ✅ shipping only (pickup / new proposal) | legs refunded or adjusted |
| Shipper accepted, not heading to the shop | ✅ shipping only, free | legs refunded |
| Shipper heading to the shop | ✅ shipping only | Mbezi Boda leg 3,500 → buyer gets 1,750 back, Mbezi Boda gets 1,750 |
| Parcel picked up | ❌ shipping | only a destination change |
| Chain failed | ✅ shipping or the whole order | as chosen |
I — Free shipping, stacked
Shoes 60,000, shop in Kinondoni → buyer in Kinondoni (~4 km). Shop: free delivery in Kinondoni, pays up to 4,000. NexGate weekend campaign in Dar: pays up to 3,000.
| Option | Cost | Seller pays | NexGate pays | Buyer pays |
|---|---|---|---|---|
| 💰 Mbezi Boda (cheapest qualifying) | 3,500 | 3,500 | 0 | 0 (Free) |
| ⚡ Express Riders (faster) | 5,000 | 3,500 | 0 | 1,500 (the difference — the discount is what the cheapest qualifying option costs) |
| a 9 km trip (other buyer) | 7,500 | 4,000 | 3,000 | 500 |
Shipper always paid the full cost: leg escrow = buyer share + NexGate share (campaign account, at payment) + seller share (taken from the items escrow at release).
J — Group purchase
| Case | Result |
|---|---|
| Member pays (delivery 3,500) | items + leg escrows HELD; price frozen even if the group fills days later |
| Group fails / member withdraws | items + 3,500 refunded |
| Payment moved to another group | fulfillment moves with it |
| Group fills, shipper rejects at Ready | normal reassignment (D–F) |
K — Installments
| Mode | Delivery |
|---|---|
| Immediate (ships after down payment) | full delivery cost paid with the down payment |
| Layaway (ships after the last instalment) | pickup / delivery chosen and paid after the last instalment |
| Agreement cancelled before shipping | delivery cost refunded |
L — Estimation on a card
Buyer's header address: Home, Kijitonyama. Product from a shop in Ubungo.
| Shippers covering | Card shows |
|---|---|
| Mbezi Boda 3,500 · 40–60 min, Dar Cargo 7,000 · 1–2 h | 🚚 3,500 – 7,000 · Kijitonyama |
| one shipper, 5,000 | 🚚 5,000 |
| free option + faster ones up to 2,000 | 🚚 Free – 2,000 |
| none | Pickup only at Ubungo |
N — Bulky mismatch
Seller lists a 65" TV (value 1,800,000) without "bulky". Buyer chose 💰 Mbezi Boda 3,500.
| Step | What happens | Money |
|---|---|---|
| Rider arrives at the shop | taps "Can't carry — too big" + photo (before pickup) | — |
| Reassignment (bulky only) | Dar Cargo (car) 7,000 + bulky extra 15,000 = 22,000 | buyer pays nothing more |
| Seller pays | difference 22,000 − 3,500 = 18,500 + Mbezi Boda's wasted trip 1,750 (50%) | taken from the items escrow at release |
| Buyer told | "Too big for a boda — a car is coming, no extra cost to you" | — |
| Product flag | 1st mismatch → seller asked to tick bulky; if a 2nd shipper reports it on a later order → bulky set automatically | — |
| Mbezi Boda | reliability not affected | gets 1,750 |
M — A shipper's day in the app (Mbezi Boda, owner only)
| Time | Screen | What the owner sees / does | Data visible |
|---|---|---|---|
| 08:05 | Upcoming | new card "Sinza → Mbezi · small · 3,500 — Waiting for the seller to accept" (buyer just paid) | areas only, size, value band, cost |
| 08:40 | Upcoming | chip changes to "Seller is packing" + heads-up push "order coming from Sinza in ~15 min" | areas only |
| 09:00 | Offers | seller tapped Ready → offer with a 30-min countdown | areas only, map of the two areas |
| 09:04 | Offers | taps Accept → job moves to Active; pickup code 4821 shown | full details now: shop Gate B + phone, buyer pin, directions "blue gate", receiver Mama Json +255712… |
| 09:20 | Active job | at the shop: tells code 4821, seller verifies → taps Picked up | — |
| 09:45 | Active job | at the door: buyer says 0937 → enters it → Delivered | — |
| 11:30 | Earnings | job shows 3,500 held (waiting for the buyer's "✓ It's what I ordered") | — |
| 12:10 | Earnings | buyer confirmed → 3,500 released to the wallet (commission 0%) | — |
| 14:00 | Offers | another offer, but busy → Reject "too busy" → reliability notes it; order is reassigned | — |
O — Destination change (agreement change)
Buyer ordered shoes to home in Sinza with Mbezi Boda 3,500; at work in Mlimani City they change the destination.
| Case | Mbezi Boda answers | Result |
|---|---|---|
| Before pickup, new cost 4,500 | Accept at the new cost | buyer pays +1,000 into the leg escrow |
| Before pickup, new cost 4,500 | Accept at the old price | nothing to pay |
| Before pickup | Decline | leg reassigned: Kibuti Express 4,000 is dearer → buyer decides (pay +500 / keep Sinza / pickup) |
| Before pickup | no answer in 30 min | Sinza stays; the buyer is told |
| After pickup, new cost 2,500 | Accept | buyer gets 1,000 back |
| After pickup | Decline | Sinza stays — the rider keeps the parcel and delivers as agreed |
| Delivered | — | not allowed |
P — Cancelling the shipping (signed shipper)
Same order, Mbezi Boda signed (accepted) at 09:04.
| When the buyer cancels the shipping | Fee | Money |
|---|---|---|
| 09:10 — accepted, not set out | none (like Bolt) | 3,500 refunded; Mbezi Boda released, reliability unaffected |
| 09:15 — Mbezi Boda tapped Heading to the shop | wasted trip 50% | 1,750 refunded to the buyer, 1,750 to Mbezi Boda |
| 09:20 — picked up | — | not allowed; destination change only |