# Guides (don't use)

# NexGate — Commerce, Location & Logistics Master Guide

> **Purpose.** This is the single reference for how buying, location and shipping work in NexGate.
> It captures every decision made so far and the reasoning behind each one, so that any
> implementer (human or Claude Code) can build from it, and future-you remembers *why*.
>
> **Companion file:** `location-service-spec.md` is the detailed build spec for the Location
> Service. This guide is the wider picture; that spec is the first thing to build.
>
> **Guiding rule for everything here:** simple over clever. No approval workflows, no features
> nobody asked for, no machinery a solo developer can't maintain. Where a simpler rule does the
> job, it wins.

---

## 0. The idea in one picture

NexGate is a social-commerce super app: users open **shops** to sell and **delivery businesses**
to ship, and buyers order and get goods delivered or collect them. NexGate never owns stock or
vehicles — it connects the three sides and holds the money safely in between.

```
            ┌──────────────┐     browse / order / pay      ┌──────────────┐
            │    BUYER     │ ───────────────────────────►  │   NexGate    │
            └──────────────┘ ◄───────────────────────────  │  (platform)  │
                   ▲   collect / receive + confirm (code)   │   + escrow   │
                   │                                        └──────┬───────┘
                   │                                   recommends & books
                   │                                               │
         ┌─────────┴─────────┐                        ┌────────────┴───────────┐
         │   SHOP (seller)   │  hand over + code      │  DELIVERY BUSINESS     │
         │  items, location  │ ─────────────────────► │  (shipper) moves goods │
         └───────────────────┘                        └────────────────────────┘

  Money path:  Buyer → escrow → (on delivery code) → seller wallet + shipper wallet + NexGate fee
```

Everything in this guide hangs off two services:

1. **Location Service** — *where* things are. The foundation. Built first.
2. **Shipping Service** — *how* goods move. Built on top of Location.

---

## 1. Core principles (apply everywhere)

1. **The pin (lat/lng) is the single source of truth** for any place. Text is only a label.
2. **Location is collected only for a stated reason**, never on app open, never as background tracking.
3. **The administrative hierarchy (region/district/ward) comes from NexGate's own data**, computed
   from official NBS ward boundaries. Google is used **only on the client** for the map, place
   search and the live label — never called from the backend, never stored.
4. **Orders freeze a copy** of every address and price. Later edits never change a past order.
5. **Users own businesses; businesses earn into the owner's one wallet.** Shops sell, delivery
   businesses ship. Same ownership model for both.
6. **Money is held in escrow and released on a delivery code.** Proof of handover, not trust.
7. **One shipment = one pickup (one shop) → one shipper → one drop-off.** No multi-stop.
8. **Accept orders 24/7; be honest about timing.** Closed hours delay the work, never the sale.
9. **Shippers describe their own pricing and coverage.** NexGate never forces a pricing model.
10. **Trust is automatic, not a manual queue** — phone verify, limits that grow with good history,
    ratings, and auto-pause on repeated failure.

---

## 2. Location Service (foundation)

> Full build detail is in `location-service-spec.md`. This is the summary.

### 2.1 What it owns
- Tanzania ward boundaries (NBS 2022) and **pin → region / district / ward** resolution
- **Shop locations** (one per shop, mandatory to publish)
- **Buyer saved addresses** (many, optional) and **recent locations**
- **Order address snapshots** (frozen copies)
- **Stations** and future **zones** (same shape-based pattern)
- Privacy rules for who sees which part of a location

### 2.2 How a pin becomes a place

```
          📍 confirmed pin (lat, lng)
         /                           \
  NBS ward shapes (ours)          Google (client only)
  region, district, ward, codes    "Mlimani City, Sinza Mori…"
       │                                │
    STORED (truth)              shown while placing the pin only
                                (never sent to backend, never stored)
```

The lookup is a point-in-polygon test in PostGIS: *which ward shape contains this point?* It runs
in milliseconds thanks to a spatial (GIST) index. If no ward contains the pin (beaches, lakes,
borders), fall back to the **nearest ward within 500 m**; beyond that, the pin is "outside coverage".

### 2.3 The ward data (done)
- Source: **NBS 2022 Population & Housing Census — Tanzania Wards shapefile** (official, ~3 m accuracy).
- Already converted to **GeoJSON WGS84 (EPSG:4326)**, geometries repaired, delivered as
  `tz_wards_2022.geojson.gz`.
- **4,344 wards, 31 regions.** Primary key is `ward_id` (1–4344) because NBS codes are not unique.
- Loaded by a startup **seeder** (no Flyway, no version tracking) into PostgreSQL + PostGIS.
- The data file lives outside Git (`data/geo/`, mounted read-only into the container).

**District and region need no separate data.** Every ward row already carries its council, district
and region names + codes, so one pin lookup returns all levels at once. Coverage checks and reports
just use the stored codes. The only case needing a district/region *shape* is drawing an outline on
a map — build it on demand by dissolving wards (`ST_Union(geom) GROUP BY reg_code[, dist_code]`),
never store a second dataset.

### 2.4 Address capture UX (same pin screen everywhere)
- **Never ask for location on app open.** Ask only when the user taps "Use my location".
- **Fixed centre pin, the map moves** under it (Bolt/Uber pattern). Satellite toggle. Accuracy circle.
- **Confirm disabled until zoomed to street level.** Search only moves the map, never confirms.
- **Shop:** pin + landmark + phone required (+ optional building, official address, photo). Mandatory.
- **Buyer:** pin + optional rider note only. Pin-only, Bolt style. Saving is optional; many saved
  addresses, plus recents.

---

## 3. Shipping Service (built on Location)

### 3.1 The whole shipping picture

```
                          ┌───────────────────────────────┐
                          │   RECOMMENDATION ENGINE        │
   order (items, value,   │   1. read situation            │   ranked options:
   pickup+dropoff wards,  │   2. gather eligible shippers  │   💰 Cheapest
   bulky?) ──────────────►│   3. filter (coverage/limits)  │──►⚡ Fastest
                          │   4. price each                │   ⭐ Recommended
                          │   5. rank & label              │
                          └───────────────┬────────────────┘
                                          │ one standard contract per shipper:
                                          │ covers? · quote · book · cancel · status
                     ┌────────────────────┼─────────────────────┐
                     ▼                                          ▼
          ┌───────────────────┐                     ┌───────────────────────┐
          │ NATIVE shipper    │                     │ INTEGRATED shipper    │
          │ (NexGate business)│                     │ (external API)        │
          │ tables + app/SMS  │                     │ Bolt, DHL… adapters   │
          └───────────────────┘                     └───────────────────────┘
```

### 3.2 Two classifications of every shipper

A shipper is described by **two independent fields**:

**A. Service type — how goods move**

| Type | How it works | Coverage set by | Examples |
|---|---|---|---|
| **🚪 Door-to-door** | Picks up at seller, delivers to buyer | Areas (ward/district/region, or nationwide) + optional max distance | Boda groups, Kibuti Express, **DHL / EMS** (wide area) |
| **🚌 Station** | Seller drops at a station, buyer collects at a station | Stations (pins) + lanes between them | Bus companies, truck offices |

> "Local" vs "intercity" is **not** a separate type — it falls out of how wide the area is.
> DHL is just door-to-door with a nationwide area.

**B. Connection — how NexGate talks to them**

| | **Native** (NexGate business) | **Integrated** (external API) |
|---|---|---|
| Set up by | The owner, in the app | NexGate, once, in code + admin |
| Pricing | Owner's tables | Live from their API |
| Jobs arrive | App notification + SMS/WhatsApp link | NexGate calls their API |
| Status | Owner taps buttons / link | Their webhooks or polling → mapped |
| Money | Into owner's NexGate wallet | NexGate pays them; they invoice NexGate |
| New one | Zero code (user signs up) | One adapter per company |

They combine freely: a native door-to-door rider, a native station bus, integrated Bolt, integrated DHL.

### 3.3 One standard contract (why the engine never changes)

Every shipper, native or integrated, answers the same five questions. That's all the engine knows:

```java
boolean covers(pickupWard, dropoffWard);     // area / lane check
Quote   quote(order);                         // price + ETA
Booking book(order);                          // returns a reference
void    cancel(ref);
Status  status(ref);                          // mapped to NexGate statuses
```

- **Native** answers from the owner's tables and app/link updates.
- **Integrated** answers by calling the company's API.

**Standard status ladder** (every provider's own statuses are translated into this, so the buyer's
tracking screen never changes):

```
CREATED → BOOKED → PICKED_UP → IN_TRANSIT → AT_STATION → OUT_FOR_DELIVERY → DELIVERED
                                                   ↘ FAILED / CANCELLED / RETURNED
```

### 3.4 Adding a provider = data, not code (for native)

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  Add delivery business
┆ Name:    Shabiby Bus Parcels          ┆
  Type:    ○ Door-to-door  ● Station
┆ Connect: ● Native  ○ Integrated (API) ┆
  Contact: +255 7XX…  (booking line)
┆ ───────────────────────────────────── ┆
  Coverage
┆  Door-to-door → pick areas            ┆
   Station      → add stations + lanes
┆ Pricing method (§3.6)                 ┆
  Limits: max distance / max value
┆ Bulky:  ○ No  ● Yes (+extra)          ┆
  Settlement: ● Wallet  ○ Pay at station
┆ [ ] Enabled                           ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

Integrated providers (Bolt, DHL) enter nothing here; their adapter handles it.

### 3.5 Coverage: picked from what the system already knows

**Door-to-door → areas** (from the region → district → ward list, or nationwide):
```
Mbeya Riders   🚪  area: Mbeya district        → local only
Kibuti Express 🚪  area: Dar + Pwani           → regional
DHL / EMS      🚪  area: nationwide            → intercity door-to-door
```
Offered only when **both** pickup and drop-off pins fall inside its area.

**Station → stations + lanes** (each station is a pin resolved to a ward, like a shop):
```
Shabiby Bus 🚌
  Stations: Mbeya Stand · Magufuli Terminal (Dar) · Dodoma Terminal
  Lanes:    Mbeya ↔ Dar   · Mbeya ↔ Dodoma
```
Offered when it has a station near pickup **and** near drop-off. Buyer sees nearest stations first.
Optionally a station shipper also delivers to the door around a station (then it also picks areas).

**Routes with multiple stops on the way.** A bus line usually stops at several towns in order
(Mbeya → Iringa → Morogoro → Dar). The shipper enters **one route = an ordered list of its stations**,
not every pair of lanes. The system then treats **any earlier stop → any later stop** on that route
as a valid trip (both directions if the route runs both ways):

```
Route "Mbeya–Dar daily"  (both ways)
  1. Mbeya Stand   2. Iringa Office   3. Morogoro Stand   4. Magufuli (Dar)
  → valid trips: Mbeya→Iringa, Mbeya→Morogoro, Mbeya→Dar,
                 Iringa→Morogoro, Iringa→Dar, Morogoro→Dar (+ reverses)
```
4 stops → all pairs auto-derived. Add a 5th stop and its new pairs appear at once.

**Pricing along a route** (shipper's choice):
- **A. Per segment, summed** — one price per gap between neighbours; a longer trip is the sum
  (`Mbeya→Dar = Mbeya→Iringa + Iringa→Moro + Moro→Dar`). Simplest to keep accurate.
- **B. Stop-to-stop table** — a full price per pair, when it isn't a clean sum (e.g. a long-haul discount).

Plus the usual value add-on (insurance) and bulky extra. First/last mile to a buyer not near a stop
is added as a separate local door-to-door leg, each with its own code — unchanged.

> A **lane** is just the special case of a route with two stops. Model everything as routes.

### 3.6 Pricing — the shipper chooses the model, NexGate never forces one

At registration the shipper picks **one** method:

```
How do you charge?
 ○ Flat price
 ○ By distance            (bands: 0–3 km, 3–7 km, …)
 ○ By package value       (ladder: insurance — the shipper carries the risk)
 ○ By distance + value    (added together)
 ○ Combined table         (distance × value grid, each cell a full price)
```

Plus, on top: **bulky extra** (if accepted), **max distance** and **max value** (filters, only when set).

**Why value-based for parcels:** the shipper holds the insurance, so charging by the goods' value
is fair, and "max accepted value" caps their risk. Quantity is handled for free — more items means
a higher order value.

**Distance** is measured from the two pins: start with straight-line × 1.3 (free, instant), switch
to a routing service later if needed without changing shipper tables.

```
Examples (5 km trip, phone worth 800,000):
  Mbeya Riders     distance only      3,500
  Shabiby (lane)   value only        20,000
  Kariakoo courier distance + value   3,500 + 3,000 = 6,500
  Combined table   (3–7km × 500k–2M)             7,000
  Small rider      flat               3,000
```

### 3.7 Package info — minimal

No size or weight. Only:
- **Order value** — from the cart, automatic.
- **Bulky? (yes/no)** — one checkbox on the product (category can pre-tick furniture, fridges).
  Shippers that don't accept bulky are filtered out. A fridge never gets offered a motorbike.

**Vehicle type is not needed** — "bulky vs not" is the only decision it would inform. Shippers may
add a vehicle **badge** for display only (e.g. "Boda", "Pickup"); it drives no logic. Integrated
providers (Bolt) pick the vehicle category inside their adapter.

**Mismatch rule:** if the real package differs (bulky not ticked), the extra cost is the **seller's**
(they chose), the buyer's price never changes after payment, and repeated mismatches auto-correct
the product's bulky flag.

### 3.8 Who enters what

| Party | At setup (once) | Per order |
|---|---|---|
| **Seller** | Shop location, landmark, phone | `[ ] Bulky` (usually none); tap "Ready for pickup" |
| **Shipper** | Coverage + one pricing method + limits + bulky | Tap link; update status |
| **Buyer** | — | Pick a delivery pin; tap one option (Cheapest/Fastest/Recommended) |

The system works out the rest: value, bulky, pickup+drop-off wards, eligible shippers, price, ranking.

### 3.9 The recommendation engine, step by step

```
1. WHERE   pickup ward + drop-off ward → same district / same region / different region
2. WHO     shippers whose coverage (areas / lanes) includes both wards
           + build station + door-to-door combinations where needed
3. FILTER  drop any that fail a hard rule:
             area/lane not covered · over max distance · over max value
             · bulky needed but not accepted · closed & past cut-off (→ ETA shifts, not dropped)
             · provider currently auto-paused (repeated failures)
4. PRICE   live quote (integrated) or the shipper's table (native)
5. RANK    score by price · speed · reliability(history); weight by situation
           (urgent → speed; cheap item → price; expensive → reliability)
           label 💰 Cheapest / ⚡ Fastest / ⭐ Recommended; drop absurd outliers
```

**Backup:** if the chosen shipper fails (no pickup, cancels), auto-offer the next best and tell the buyer.

**Reliability needs history**, so at launch show only **Cheapest** and **Fastest**; add
**Recommended** once real delivery data exists. Reliability is your long-term moat — competitors
can copy features, not your data.

### 3.10 Shipper onboarding: register → KYC → go live

Shippers handle other people's goods and money, so onboarding has three parts. KYC gates **going
live**, not filling the form — let them build the profile, then verify to switch on. Trust then
grows automatically; **no manual approval queue** except on a flagged exception.

```
Register (6-step form) → KYC verify → GO LIVE (low limit) → limits & badge grow with history
```

**Registration form (a short wizard, save-as-draft, admin can fill it for offline shippers):**

| Step | Fields | Feeds the engine as |
|---|---|---|
| 1 Basics | name · service type (DOOR/STATION) · booking phone · WhatsApp? · logo | identity, notify, branches next step |
| 2 Coverage | DOOR: areas (region/district/ward) or radius-from-base · STATION: stations + routes | **coverage check** |
| 3 Pricing | method (flat/distance/value/dist+value/grid; route segment or table) · bulky yes/no (+extra) | **quote** |
| 4 Limits & alerts | max value · max distance · won't-carry list · response window (default 30 min) · advance heads-up (default ON) | **filters**, job alerts (§4.3a) |
| 5 Hours & money | opening hours + cut-off · settlement (wallet / pay-at-station) · payout account | **ETA/availability**, money routing |
| 6 Verify | phone OTP · agree shipper terms (liability up to declared value) | go live |

Minimum to go live = steps 1–3; steps 4–5 have working defaults; step 6 is just verify. A one-man
rider finishes in ~2 min; a bus company takes longer only because it adds stations.

**KYC — fast, tiered, mostly automatic:**

```
Tier 0  phone verified            → register, build profile (not live)
Tier 1  + NIDA + ID photo + selfie match  → GO LIVE, low max value (e.g. 200,000/order)
Tier 2  + payout name matches NIDA → higher limit
Tier 3  good history (N jobs, rating, low failures) → higher limit + "Verified" badge
```
- Auto-checks first (OTP, face-match selfie↔ID, name-match payout↔NIDA). Pass → live, no human.
- The **mobile-money number is itself a KYC signal** (already tied to a real NIDA in TZ).
- Manual review only on a flag (blurry ID, mismatch) — a small queue, not every shipper.
- **Sellers** get a lighter version of the same module (phone + NIDA + payout match before first payout).

**Licences & compliance — collect now, confirm mandatory ones with a professional later.**
Store each as *document type + file + expiry*. Mark required/optional per shipper type & tier once
advised. Non-blocking at launch; uploading the real ones earns a **"Verified business"** badge.

| Group | Documents (collect if the shipper has them) |
|---|---|
| Identity & business | NIDA · TIN (TRA) · BRELA (business name / company) · local-govt **business licence** · VAT (VRN) if over threshold |
| Transport / courier | **LATRA** licence (road carriage of goods/passengers — replaced SUMATRA) · **TCRA** courier/postal licence (door-to-door couriers) · TASAC (if freight-forwarding) |
| Vehicle & driver (own fleet) | vehicle registration · commercial/PSV licence · motor insurance (+ goods-in-transit) · driving licence · inspection/fitness |
| Employees (if any) | NSSF · WCF · OSHA |
| Money / insurance | mobile-money/bank payout · goods-in-transit / cargo insurance |

> ⚖️ **Flag (not legal advice):** which licences are legally mandatory — especially **LATRA** for
> goods carriage and **TCRA** for courier services — and NexGate's own liability as the connecting
> platform, plus KYC/AML and escrow rules (Bank of Tanzania), must be confirmed with a Tanzanian
> lawyer / compliance professional. See §8.

---

## 4. Order & money flow

### 4.1 Checkout — pickup or delivery, per shop

Each shop in the cart is its own shipment, so the choice is per shop:

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  Deliver to: 🏠 Home, Mbezi Juu
┆ 🏪 Shop A · Kariakoo · 2 items        ┆
   ○ Pickup at shop            Free
┆  ● Delivery 🚪 Kibuti Express 4,000 ▾ ┆
  🏪 Shop B · Sinza · 1 item
┆  ● Pickup at shop            Free     ┆
   ○ Delivery
┆ 🏪 Shop C · Mbeya · 1 item            ┆
   ● Delivery 🚌 Shabiby       20,000 ▾
┆ ─────────────────────────────────────┆
  Delivery total (2 deliveries) 24,000
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

- **One shipment = one shop → one shipper → one drop-off.** No multi-stop, ever.
- A 3-shop cart = 3 shipments, each with its own shipper, price, codes and escrow release.
- **Pickup** also uses escrow + a code (buyer shows code at the shop; seller enters it).

### 4.2 Escrow + delivery codes (proof of handover)

```mermaid
sequenceDiagram
    actor Buyer
    participant NexGate as NexGate (escrow)
    participant Seller
    participant Shipper
    Buyer->>NexGate: pay (item + delivery)
    Note over NexGate: funds held in escrow
    Seller->>Shipper: hand over + SELLER pickup code
    Shipper->>NexGate: enter pickup code (proves shipper has it)
    Shipper->>Buyer: deliver
    Buyer->>Shipper: give DELIVERY code
    Shipper->>NexGate: enter delivery code
    Note over NexGate: release:
    NexGate->>Seller: item price − commission
    NexGate->>Shipper: delivery fee − commission
    NexGate->>NexGate: commissions
```

**Code rules (decided):**
- **Pickup code** (seller → shipper): proves the shipper received the parcel — matters if it's lost.
- **Delivery code** (buyer → shipper): releases the money.
- **Station collection:** buyer gives the code to station staff, who enter it. Same as door-to-door.
- **Multi-leg trips** (rider → bus → rider): **each handover has its own code**, so each leg is paid
  as soon as it's done — the first-mile rider isn't left waiting days for a small fee.

**Open decisions to finalise (see §8):** buyer unreachable / forgets code; item wrong or damaged
after code; return window; cancellation cost.

### 4.3 Switching pickup ↔ delivery after ordering

```
Order placed as PICKUP → "Get it delivered instead"
   → pick drop-off pin → fresh delivery quotes (today's prices)
   → pay delivery fee only → into escrow → shipment created, seller notified
   Allowed until the buyer has collected.

Order placed as DELIVERY → "Collect it myself"
   → allowed only BEFORE the shipper has picked it up
   → delivery fee refunded to the buyer
```

### 4.3a When the shipper is contacted — offer at "Ready", not before

The seller has **two separate actions**, and the shipper is only offered the job at the second one:

```
Order arrives → seller ACCEPTS (yes, I'll fulfil)   ← shipper NOT contacted yet
             → seller PACKS
             → seller taps READY FOR PICKUP          ← NOW the binding offer goes to the shipper
```

Offering at **Ready** (not at Accept) means: no shipper rides over to wait while the shop packs; if
the seller can't fulfil, the order dies before any shipper was bothered; and the ETA/response-window
start from a real ready moment.

**Heads-up (optional, per-shipper setting — default ON, can be disabled).** So shippers can plan
without being booked early, NexGate sends a **non-binding** heads-up when the seller accepts/starts
packing. It commits nothing and starts no timer:

```
On seller accept/pack →  Heads-up (no commitment):  "Order coming from Sinza, ~15 min. Be ready?"
On seller READY       →  Binding OFFER + response window (30 min) starts   ← the one that counts
```

- The heads-up is a shipper **preference**: `Advance heads-up [✓] (default on)`. A shipper who finds
  it noisy turns it off and only sees the binding offer at Ready.
- **Re-check at Ready:** if packing ran long and the chosen shipper has since **closed / passed
  cut-off**, the engine re-evaluates at Ready — same shipper still open → offer it; otherwise fall
  into normal reassignment (§4.4). A slow pack never leaves the order stuck with an offline shipper.

### 4.4 Shipper rejects the job → auto-reassign

A shipper can decline an offered shipment (too far, too busy, bulky they can't carry, value too
high, closed). This must resolve cleanly, because it touches money. Two rules make it safe:

1. **Escrow holds two parts — item money and delivery money.** A rejection only touches the
   **delivery** part. The **item money stays held, and the seller never re-approves** — their job
   ended at "ready".
2. **The buyer's agreed price is protected.** The system can reassign silently only if it holds that
   price or beats it; a *higher* price needs the buyer's yes.

**The flow:**

```
Seller taps READY (§4.3a) → shipment OFFERED to chosen shipper (delivery fee held)
   → shipper has a response window (e.g. 30 min, or until they next open)
        ├─ ACCEPT          → pickup (code) → normal flow
        └─ REJECT (reason) or TIMEOUT
              → that shipper's delivery fee released back in escrow
              → shipment = REASSIGNING, recompute options excluding rejecters
```

```mermaid
flowchart TD
    A[Shipper rejects / times out] --> B{Next-best shipper?}
    B -- none --> C[Buyer: switch to pickup refund, or cancel full refund]
    B -- same price or cheaper --> D[Auto-book next · refund difference to wallet]
    D --> E[Notify buyer: new courier, same price · NO action needed]
    B -- costs more --> F[Delivery money to buyer wallet · show fresh options+prices]
    F --> G[Buyer picks one · pays difference from wallet]
    G --> H[New shipper acts]
    E --> H
```

- **Same or cheaper → auto, buyer does nothing** (just a notification). The system walks **down the
  ranked list on its own** — shipper 1 rejects → shipper 2 auto-assigned → acts, and so on.
- **Dearer → buyer confirms** the new price (pays the difference from wallet, or gets a refund).
- **None left → buyer chooses** pickup (delivery refunded) or cancel (full refund).
- **Reason feeds reliability.** Chronic rejecters sink in the rankings and can auto-pause.
- **Loop guard:** cap auto-reassign at ~3 tries (or the order's handover window); after that, hand
  it to the buyer so a shipment never spins forever.

> There is **no repeated approval** — the seller is out of it after "ready", and the buyer is pulled
> in only when the price would rise or nobody is left. Otherwise the system does the chasing.

---

## 5. Free shipping

Two funders, same mechanism. The shipper is **always paid in full**; the discount is covered by
whoever offered it, deducted at escrow release.

### 5.1 Seller-funded (per shop or per product)

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  Free delivery (shop setting)
┆ [✓] Offer free delivery              ┆
  Where:  ○ Within [5] km of shop
┆         ● Areas: Ubungo, Kinondoni   ┆
  When:   order over [50,000] (opt.)
┆ I pay up to: [5,000] per delivery    ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
Product override:  ● Use shop setting  ○ Always free  ○ Never free
```
Uses your ward boundaries and pin distance to decide eligibility. The **cheapest qualifying option**
becomes free; a faster choice costs the difference. The **cap** protects the seller (buyer pays the
rest above it). In a mixed shipment, free only if **every** item qualifies.

### 5.2 NexGate-funded (campaigns)

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  Campaign: Free delivery Dar weekend
┆ Areas: Dar es Salaam · Dates: 10–12 Oct
  Min order: 30,000 · NexGate pays up to 3,000
┆ Who: First order only · Shops: All    ┆
  Total budget: 2,000,000 → auto-stops
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

**Stacking** (seller first, then NexGate):
```
delivery price            7,000
− seller pays (cap 4,000) −4,000
− NexGate pays (cap 3,000) −3,000
= buyer pays                  0
```
A **total budget** auto-stops the campaign — no runaway cost. Each shipment records **who paid what**.

---

## 6. Opening hours & orders outside hours

### 6.1 Hours apply to three things

| Who | Why |
|---|---|
| **Shops** | Pickup availability; when a shipper can collect |
| **Delivery businesses** | Offered as "today" only when working; else ETA shifts |
| **Stations** | Where buyers collect parcels (often different hours from the company) |

### 6.2 Hours editor (same screen everywhere)
```
Mon–Fri  08:00–13:00 · 14:00–18:00      ← several ranges per day (lunch break)
Sat      09:00–15:00
Sun      Closed
Special days: 25 Dec Closed · 9 Dec 10:00–14:00   ← override weekly (holidays, Eid)
[ ] Temporarily closed (one-tap pause)
Same-day cut-off: 16:00 (shippers)       ← after this, pickup = next opening
```
All times **EAT (UTC+3)**. Overnight ranges (18:00–02:00) allowed.

### 6.3 Orders outside hours — accept 24/7, be honest

```
Order at Fri 22:00, shop opens Sat 09:00
  ○ Pickup   → "Ready from Sat 09:00"
  ● Delivery → "Arrives Sat ~12:00–15:00"
```

Rules:
- **Always accept orders.** Blocking a night buyer loses the sale.
- **Seller notified at once, but timers start at opening.** "Accept within 2h / ready within 24h"
  pauses while closed — a 22:00 order isn't overdue at 07:00.
- **Shipper jobs are scheduled, sent at the shipper's opening** (not midnight). Shipper may accept early.
- **Handover time** = first moment the **shop AND shipper are both open** (after "ready", before cut-off).
  **ETA** = handover + the shipper's normal delivery time, skipping their closed hours.
- **Temporarily closed** → seller chooses "still accept (ships when back)" or "stop orders (hide Buy)".
- **Safety net:** if the seller never acts within 24 *opening* hours → auto-cancel + refund from escrow.

---

## 7. What we learned from others (and what to copy / avoid)

| Topic | Jumia / Glovo / Uber / Bolt do… | NexGate decision |
|---|---|---|
| Ordering when closed | Pre-order / scheduled | ✅ Accept 24/7, show real time |
| Delivery choice | Door vs pickup station, options with price+ETA | ✅ Per shop, Cheapest/Fastest/Recommended |
| Money safety | Escrow + 4-digit code | ✅ Have it |
| Seller onboarding | Heavy docs + approval queue | ❌ Avoid — phone verify + auto-limits |
| Surge pricing | Uber/Bolt dynamic | ❌ Avoid — shipper ladders are clearer |
| Separate rider apps | Yes | ❌ Avoid — NexGate deals with the business, not riders |

**To add from others (currently gaps):**
1. **Problem-report window** — buyer can report a problem within ~24h of delivery before escrow releases.
2. **Two-way ratings** — rate the buyer too (bad address / no-show), so shippers aren't stuck with them.
3. **Cancellation policy with cost** — who pays when a buyer cancels after the shipper collected.
4. **Late-handover consequence** — feed seller late handovers into the same reliability score shippers have.
5. **Address quality nudges** — already helped by pin + landmark + ward check.

---

## 8. Open decisions to finalise before building Shipping

| # | Decision | Suggested default |
|---|---|---|
| 1 | Buyer unreachable / forgets delivery code | Shipper marks "Delivered, no code" + photo → buyer 48h to dispute → else auto-release |
| 2 | Item wrong/damaged after code | Short report window (24h) before release, or code = final |
| 3 | Lost / damaged parcel | Shipper liable up to declared value; refund from shipper wallet; pickup code shows who held it |
| 4 | Cash on delivery (COD) | **Prepaid only at launch**; add "accepts COD" per shipper later |
| 5 | Escrow legality | **Check with a lawyer / payment partner** (BoT, National Payment Systems Act) — *not legal advice* |
| 6 | Cancellation cost | Free before pickup; buyer bears shipper's cost after pickup |
| 7 | Bolt for parcels | **RESOLVED (confirmed with Bolt): no parcel/delivery API in Tanzania — passenger rides only.** Bolt is out as a shipper; rely on native shippers. Bolt only relevant if NexGate later adds an in-app passenger-ride feature (separate from shipping). |
| 8 | Mandatory licences & platform liability | Which of LATRA / TCRA / business licence etc. are required per shipper type, and NexGate's liability as connector — **confirm with a Tanzanian lawyer / compliance pro** (§3.10) |
| 9 | KYC / AML level | How much identity NexGate must verify & keep for shippers and sellers holding escrowed money — confirm with payment partner / lawyer |

---

## 9. Note on the Bolt Ride Booker API (reference)

> **Confirmed with Bolt (Oct 2026):** no parcel/delivery API in Tanzania — passenger rides only.
> So Bolt is **not** a NexGate shipper. This section is kept only for a possible future **in-app
> passenger-ride** feature (separate from shipping). Prime Stack already has a Bolt **Business**
> account (ride billing), which is *not* the same as Ride Booker API access.

The API doc shared earlier is a **reseller passenger-ride** API, useful as a model for *integrated*
providers but **not a parcel API**. Key facts if it's ever used (e.g. in-app rides):

- **Auth:** OAuth2 client credentials, 10-minute tokens, backend-only (never in the mobile app).
- **`fare_id` is fragile:** expires in 5 min, single-use, stops must match the estimate exactly.
  On `INVALID_FARE_ID`, re-estimate and retry.
- **Tracking:** poll ride every 10 s / driver every 1 s; webhooks live-only (not sandbox), send just
  `ride_id`/`type`/`state`, whitelist Bolt's 3 IPs.
- **States aren't linear** (can bounce SEARCHING ↔ DRIVER_ON_ROUTE); final price can change for 24h+.
- **Doc quirks:** garbled Cancel section, misspelled `reseler` destroy URL, `Fare_id` capitalised once.
- **Before use:** confirm Ride Booker is enabled for Tanzania, currency and payment setup (samples
  are Tallinn/EUR).

For NexGate's **parcel** shipping, native shippers matter far more than Bolt. Treat any Bolt/DHL
integration as one adapter behind the standard contract (§3.3).

---

## 10. Build order (roadmap)

```
Phase 1 — Location Service  ◄── START HERE (spec + data ready)
  Docker PostGIS · seeder · pin resolution · shop location · buyer addresses · snapshots

Phase 2 — Native Door-to-door shipping, one city
  Delivery business type · coverage (areas) · one pricing method · bulky flag
  Escrow + pickup & delivery codes · prepaid only · status via app/SMS link
  Checkout: pickup/delivery per shop · Cheapest + Fastest only

Phase 3 — Station shipping (intercity)
  Stations + lanes · multi-leg codes · collect-at-station

Phase 4 — Recommendation engine grows
  Reliability scores from history → add "Recommended"
  Free shipping (seller + NexGate campaigns) · opening-hours scheduling

Phase 5 — Integrated providers
  Integrated adapters behind the standard contract — e.g. DHL (confirm parcel APIs first).
  NOT Bolt: confirmed no parcel API in TZ (rides only).

Cross-cutting (fold in as you go): two-way ratings, problem-report window,
cancellation policy, late-handover scoring.
```

**Do first:** sign up your own first shippers (a few boda groups + one bus company) in one city —
recommendations are worthless with no shippers.

---

## 11. Data model sketch (shipping — for when you build it)

> Logical only. Follow the project's ID type, audit and error conventions. All areas/stations point
> to the **ward / district / region codes** from the Location Service — no new map data needed.

```
delivery_businesses   owner_user_id, name, service_type(DOOR|STATION),
                      connection(NATIVE|INTEGRATED), adapter_name?, contact_phone,
                      accepts_bulky, bulky_extra, max_distance?, max_value?,
                      response_window_min(default 30), advance_headsup(default true),
                      settlement(WALLET|PAY_AT_STATION), enabled, rating, reliability

business_areas        business_id → region/district/ward codes it serves   (DOOR)
business_stations     business_id, name, pin(lat,lng), ward_id, phone, hours (STATION)
business_routes       business_id, name, both_ways                          (STATION)
route_stops           route_id, station_id, stop_order                      (ordered list)
                      → valid trip = earlier stop_order → later stop_order on same route
                        (a 2-stop route is just a "lane")
pricing_rules         business_id, method(FLAT|DISTANCE|VALUE|DIST_VALUE|GRID),
                      bands/ladder/grid as JSON
route_pricing         route_id, mode(SEGMENT|TABLE),
                      segment prices between neighbours, or stop-to-stop table (STATION)

opening_hours         owner_type(SHOP|BUSINESS|STATION), owner_id,
                      weekly ranges, special_days, temp_closed, same_day_cutoff

shipments             order_id, shop_id, shipper_business_id, service_type,
                      pickup_snapshot, dropoff_snapshot (frozen),
                      price, who_pays(buyer/seller/nexgate split),
                      status(OFFERED|ACCEPTED|REASSIGNING|PICKED_UP|IN_TRANSIT|
                             AT_STATION|OUT_FOR_DELIVERY|DELIVERED|FAILED|CANCELLED),
                      offer_expires_at, reassign_count,
                      pickup_code, delivery_code, leg_no?
shipment_offers       shipment_id, business_id, state(OFFERED|ACCEPTED|REJECTED|TIMEOUT),
                      reject_reason?, offered_at, responded_at   (one row per attempt)
shipment_legs         shipment_id, from, to, provider, status, cost, code   (multi-leg)

escrow_holds          order_id, item_amount, delivery_amount,
                      state(HELD|RELEASED|REFUNDED), breakdown   (two parts: item + delivery)
campaigns             areas, dates, min_order, cap_per_delivery, budget, audience, active
```

---

## 12. Worked examples (engine walk-throughs)

> Illustrative numbers only. Shows the 5-step engine (WHERE · WHO · FILTER · PRICE · RANK) end to end.
> Reminder of the rules: **value → price + insurance**, **bulky → vehicle/who can carry**,
> **quantity flows into value by itself**, hours & limits filter, area/route decides eligibility.

### The registered shippers (set up once — used by all examples)

```
① Mbezi Boda        🚪 DOOR · Native
   Areas: Kinondoni, Ubungo (Dar)      Pricing: distance
     0–3km 2,000 · 3–7km 3,500 · 7–15km 5,000 · max 15km
   Bulky: NO        Hours: 07:00–20:00

② Dar Cargo         🚪 DOOR · Native
   Areas: whole Dar es Salaam region   Pricing: distance + value
     dist: 0–5km 4,000 · 5–15km 7,000   value add: >500k +2,000
   Bulky: YES (+15,000, car)           Hours: 08:00–18:00

③ Shabiby Parcels   🚌 STATION · Native
   Route: Mbeya–Dar (both ways): Mbeya ▸ Iringa ▸ Morogoro ▸ Dar
     segment: Mbeya–Iringa 8k · Iringa–Moro 6k · Moro–Dar 7k
   value add: >1M +5,000 · Bulky: YES (+10,000)  Hours: 06:00–19:00

④ EMS Courier       🚪 DOOR · Native · nationwide
   Pricing: value ladder   ≤200k 15k · ≤1M 25k · >1M 3%
   Bulky: YES (+5,000)     Hours: 08:00–16:00 Mon–Fri
```

### A — small but many, same city
**20 phone cases**, Sinza (Ubungo) → Mbezi (Kinondoni). Value **120,000**, not bulky, ~6 km, Sun 14:00.
```
1 WHERE   both in Dar region, ~6 km
2 WHO     ① ✓  ② ✓  ③ ✗ (no Dar-local)  ④ ✓
3 FILTER  value ok · not bulky · ④ CLOSED Sun ✗   → candidates ① ②
4 PRICE   ① 6km = 3,500      ② 7,000 + value add 0 = 7,000
5 RANK    💰⚡ ① Mbezi Boda 3,500 ~40min · alt ② 7,000
```
Buyer sees: `🚪 Mbezi Boda — 3,500 · ~40 min`. Quantity raised value, but it still fits a bike.

### B — one but large (bulky, cheap)
**1 mattress**, same shops. Value **85,000**, **Bulky ticked**, ~6 km, Sun 14:00.
```
2 WHO     ① ② ④ cover area
3 FILTER  BULKY required → ① NO ✗ · ④ closed Sun ✗ → only ②
4 PRICE   ② 7,000 + value 0 + bulky 15,000 = 22,000
5 RANK    ⭐ only: ② Dar Cargo (car) 22,000
```
Buyer sees: `🚪 Dar Cargo (car) — 22,000`. Cheap item, but bulky kept it off the bike.

### C — intercity, multi-stop route, first/last mile
**Laptop**, shop in Mbeya → buyer in Morogoro town. Value **1,500,000**, not bulky, Mon 10:00.
```
1 WHERE   different regions (intercity)
2 WHO     ①② don't reach Mbeya ✗ · ③ route: Mbeya(1) before Morogoro(3) ✓ · ④ nationwide ✓
3 FILTER  value ok · not bulky · both open Mon 10:00
4 PRICE   ③ Mbeya→Moro 8k+6k = 14,000 + value>1M 5,000 = 19,000 (collect at Morogoro Stand)
          ④ EMS >1M = 3% of 1.5M = 45,000 door-to-door 2–3 days
5 RANK    💰 ③ Shabiby 19,000 tomorrow · 📦 ④ EMS 45,000 to door 2–3 days
```
Buyer sees two groups:
```
🚌 Collect at station   Shabiby · Morogoro Stand (2 km) · tomorrow · 19,000
📦 To your door         EMS · 2–3 days · 45,000
```
Route matched automatically; priced only the Mbeya→Morogoro segment, not the whole line.

### D — shipper rejects → auto-reassign (from §4.4)
From A, buyer picked **① 3,500**.
```
Seller "Ready" → offer ① → ① TIMEOUT (busy)
  → 3,500 back in escrow · REASSIGNING · exclude ①
  → next = ② 7,000  → DEARER than 3,500
  → delivery money → buyer wallet, ask: "Mbezi Boda unavailable. Dar Cargo 7,000 (+3,500). Continue?"
  → buyer accepts → pays 3,500 from wallet → ② gets the job
  (if next were ≤3,500 → auto-assigned silently, no buyer action)
```

### E — mixed cart, two shops (two shipments)
Cart: **phone** from Shop X (Kariakoo, Ilala, value 400k, not bulky) + **blender** from Shop Y
(Mbezi, Ubungo, value 90k, not bulky). Buyer in Mikocheni (Kinondoni). Sat 11:00.
```
One shipment per shop (§4.1), priced independently:
  Shipment 1  X Kariakoo → Mikocheni (~9 km)
     WHO ② (Dar region) ✓ · ① ✗ (Ilala not in its areas)
     PRICE ② 7,000 + value 0 = 7,000
  Shipment 2  Y Mbezi → Mikocheni (~5 km)
     WHO ① ✓ (Ubungo+Kinondoni) · ② ✓
     PRICE ① 3,500 (cheapest) · ② 4,000
Checkout shows:
  🏪 Shop X · Dar Cargo       7,000
  🏪 Shop Y · Mbezi Boda      3,500
  Delivery total (2 deliveries) 10,500
```
Two shippers, two codes, two escrow releases. One slow shop never blocks the other.

### F — free shipping (seller + NexGate stacked)
**Shoes**, Shop Z in Kinondoni → buyer in Kinondoni. Value **60,000**, ~4 km, Fri 15:00.
Shop Z offers free delivery in Kinondoni, pays up to **4,000**. A NexGate weekend campaign in Dar
pays up to **3,000**.
```
Cheapest qualifying option: ① Mbezi Boda 4km = 3,500
  − seller pays (cap 4,000)  −3,500   → fully covered by seller
  − NexGate not needed
  = buyer pays 0
Shipment records: who_pays = seller 3,500
```
Buyer sees: `🚪 Free delivery · ~35 min` (badge). Shipper still paid 3,500 in full from seller's earnings.
If the trip were 9 km = 5,000: seller 4,000 + NexGate 1,000 = buyer still 0, split recorded.

---

### One-line summary

**Build the Location Service first** (spec + data are ready). Then layer shipping on top:
shippers are businesses with a wallet, described by *service type* × *connection*, answering one
standard contract; they set their own coverage and pricing; money sits in escrow and releases on a
delivery code; orders are accepted 24/7 with honest timing; and free shipping, hours and ratings
slot into the same engine without changing it.

# NexGate Location Service — Implementation Spec

> **For the implementer (Claude Code):** read this whole file before writing code.
> Follow the existing conventions of the NexGate codebase (package layout, REST path style,
> auth/security, error format, ID type, DTO style, tests). Where this spec and the codebase
> conventions differ on *style*, follow the codebase. Where they differ on *behaviour/rules*,
> follow this spec. Keep it simple — no approval workflows, no extra services, no over-engineering.

---

## 0. Summary

The Location Service is a **module inside the existing Spring Boot backend** (not a microservice).
It owns everything about *where* things are:

- Tanzania ward boundaries (official NBS 2022 data) and **pin → region/district/ward** resolution
- **Shop location** (one per shop, mandatory before a shop can be published)
- **Buyer saved addresses** (many per buyer, optional) and **recent locations**
- **Order address snapshots** (frozen copy of pickup/drop-off for an order)
- Privacy rules for who may see which part of a location

Shipping (providers, quotes, tracking) is **out of scope** — it will be built later *on top of* this service.

### 0.1 Where the Location Service is used across NexGate

It is the shared foundation for every feature that cares about *where*. Build the two core calls —
`resolve(pin) → ward/district/region` and `areaMatch(areaSet, pin-or-area) → bool` — as reusable
functions, because all of these call them:

- **Shop setup** — the shop's mandatory pickup location (pin → ward).
- **Buyer addresses & checkout** — saved/recent delivery addresses; the delivery pin.
- **Order snapshots** — frozen pickup + drop-off for every order.
- **Shipping — coverage** *(later)* — is a trip inside a shipper's areas / route stops?
- **Shipping — pricing** *(later)* — pin-to-pin distance for distance-based fees.
- **Shipping — trip type** *(later)* — same district / same region / cross-region, from the two wards.
- **Free-shipping zones** *(later)* — seller or NexGate "free within these areas / X km of shop".
- **"Near me" feed & search** *(later)* — show shops/products by the viewer's rough area.
- **Geo-targeted ads / boosting** *(later)* — show a boosted post only in chosen areas, or only where
  the seller can deliver; match on the viewer's **rough area only**, aggregate counts, no tracking.
- **Reports & analytics** — group orders/activity by ward/district/region codes (never per person).

> Everything above reuses the **same ward data and the same two functions** — no new map work per
> feature. Only the two core calls are in scope now; the rest are listed so the API is designed to
> serve them.

---

## 1. Principles (non-negotiable)

1. **The pin (lat/lng) is the truth.** Every saved location has a pin confirmed by the user on a map.
   No location is ever saved without coordinates.
2. **We take location only for a reason.** No location request on app open, no background tracking
   for buyers or shops, no movement trails. Store only the pin the user *confirmed*.
3. **Hierarchy comes from our own data, not Google.** Region/district/ward are computed on the
   backend from NBS ward shapes. Google is used **only on the client** (map display, place search,
   live label while placing the pin). The backend **never calls Google** and **never stores Google text**.
4. **Orders keep their own frozen copy** of addresses. Editing or deleting a saved address never
   changes past orders.
5. **Simple.** One module, plain SQL for spatial work, no Flyway, no version tracking, no JPA entity
   for the ward table.

---

## 2. Architecture

```
┌──────────────────────── CLIENTS (Android · iOS · Web) ────────────────────────┐
│  Pin screen: Google Map + Places search + live Google label (display only)    │
│  Sends: confirmed lat/lng + user text (landmark/note/phone/label)             │
└───────────────────────────────────────┬───────────────────────────────────────┘
                                        │ REST
┌───────────────────────────────────────▼───────────────────────────────────────┐
│                     NexGate Spring Boot backend                                │
│                                                                                │
│  ┌──────────────────────── location module ───────────────────────────────┐  │
│  │  WardResolver        pin → ward/district/region (PostGIS query)         │  │
│  │  LocationValidator   coverage, nearest-ward snap, device-distance check │  │
│  │  UserAddressService  saved addresses, default, duplicates, limits       │  │
│  │  RecentLocationSvc   last unsaved pins (max 5, 60 days)                 │  │
│  │  ShopLocationService one mandatory location per shop                    │  │
│  │  SnapshotFactory     frozen AddressSnapshot for orders                  │  │
│  │  WardDataSeeder      creates tables/indexes + loads ward file once      │  │
│  └────────────────────────────────┬────────────────────────────────────────┘  │
│                                   │ (internal Java calls)                      │
│  ┌────────────── orders / checkout ─────────┐   ┌──── shipping (FUTURE) ────┐ │
│  │ stores AddressSnapshot on each order      │──►│ reads snapshots only      │ │
│  └───────────────────────────────────────────┘   └───────────────────────────┘ │
└───────────────────────────────────────┬───────────────────────────────────────┘
                                        │ JDBC
┌───────────────────────────────────────▼───────────────────────────────────────┐
│  PostgreSQL + PostGIS (Docker image postgis/postgis)                           │
│  tz_wards · shop_locations · user_addresses · recent_locations                 │
└────────────────────────────────────────────────────────────────────────────────┘

   Ward data file (NOT in Git):  tz_wards_2022.geojson.gz  → mounted read-only into app container
```

### Who decides what

```
              📍 confirmed pin (lat, lng)
             /                          \
   NBS ward shapes (ours, backend)     Google (client only)
   region, district, ward, codes        "Mlimani City, Sinza Mori…"
        │                                    │
     STORED (truth)                    shown on pin screen only, never sent/stored
```

---

## 3. Ward data

### 3.1 Source
- National Bureau of Statistics (NBS) Tanzania — *2022 Population and Housing Census, Tanzania Wards (Shapefiles)*.
- Already converted for us to **GeoJSON, WGS84 lat/lng (EPSG:4326)**, 21 self-intersecting shapes repaired.
- File: **`tz_wards_2022.geojson.gz`** (~6.7 MB gzipped, ~30 MB raw). 4,344 features.
- Attribution required somewhere in the app's legal/about page: *"Ward boundaries © National Bureau of Statistics, Tanzania (2022 PHC)."*

### 3.2 File format (what the seeder reads)

```json
{
  "type": "FeatureCollection",
  "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
  "features": [
    {
      "type": "Feature",
      "properties": {
        "ward_id": 1, "nbs_code": "010111011",
        "reg_code": "01", "reg_name": "Dodoma",
        "dist_code": "01", "dist_name": "Kondoa",
        "counc_code": "11", "counc_name": "Halmashauri ya Wilaya ya Kondoa",
        "ward_code": "011", "ward_name": "Changaa"
      },
      "geometry": { "type": "Polygon", "coordinates": [[[35.6782, -4.736511], ...]] }
    }
  ]
}
```

### 3.3 Data facts the code must respect
| Fact | Consequence |
|---|---|
| Coordinates are **[longitude, latitude]** (GeoJSON order) | `ST_MakePoint(lng, lat)` — longitude first. Most common bug. |
| Geometry is a mix of `Polygon` and `MultiPolygon` | Always store with `ST_Multi(...)` into a `MultiPolygon` column. |
| `ward_code` alone is NOT unique; even `nbs_code` repeats for 5 wards (NBS data issue) | **`ward_id` (1..4344) is the primary key.** `nbs_code` is informational, not unique. |
| Shapes stop at shorelines (sea, Lake Victoria, etc.) | Pins on beaches/jetties may fall outside every ward → nearest-ward fallback (§6). |
| 31 regions, ~4,344 wards. Dar es Salaam has 5 councils (Ilala, Kinondoni, Temeke, Ubungo, Kigamboni) | Expected counts for sanity tests. |

### 3.4 District & region come from the ward rows — no separate files

There is **no separate district or region boundary table**. Every ward row already carries its
council, district and region names and codes, so a single `ST_Contains` lookup returns all levels
at once:

```
pin → ward (Kariakoo) → already has dist_name=Ilala, reg_name=Dar es Salaam, codes included
```

- **Coverage checks** ("shipper serves Dar es Salaam region") match `reg_code`/`dist_code` on the
  resolved ward — no shape needed.
- **Reports** group orders by the stored `reg_code`/`dist_code`.
- **Drawing** a district or region outline on a map (e.g. shipper coverage) is the only case that
  needs a shape. Build it on demand by dissolving wards — do NOT store a second dataset:

```sql
-- district outlines (view or materialized view, optional)
SELECT reg_code, reg_name, dist_code, dist_name, ST_Union(geom) AS geom
FROM tz_wards GROUP BY reg_code, reg_name, dist_code, dist_name;

-- region outlines
SELECT reg_code, reg_name, ST_Union(geom) AS geom
FROM tz_wards GROUP BY reg_code, reg_name;
```

Use `counc_code` as well if councils (halmashauri) matter for coverage; the ward rows carry it too.

---

## 4. Infrastructure

### 4.1 Docker
- Database image must include PostGIS: use **`postgis/postgis:<PG_MAJOR>-<POSTGIS>`** with the **same Postgres major
  version** currently used (e.g. `postgres:16` → `postgis/postgis:16-3.4`). Same major version keeps existing data volumes working.
- App container mounts the data folder read-only:

```yaml
services:
  db:
    image: postgis/postgis:16-3.4        # match current PG major version
  app:
    volumes:
      - /opt/nexgate/geo:/app/geo:ro      # host folder holding tz_wards_2022.geojson.gz
    environment:
      GEO_WARDS_FILE: /app/geo/tz_wards_2022.geojson.gz
```

- Local dev: put the file in `./data/geo/` and add `data/geo/` to **`.gitignore`**. Never commit the data file.

### 4.2 Config
```properties
# path to .geojson or .geojson.gz ; empty/missing → seeder logs WARN and skips
geo.wards-file=${GEO_WARDS_FILE:./data/geo/tz_wards_2022.geojson.gz}
```

### 4.3 No Flyway, no version tracking
Schema for this module is created by the **WardDataSeeder** at startup using idempotent SQL
(`IF NOT EXISTS`). It runs after Hibernate initialises (use `ApplicationRunner` or `ApplicationReadyEvent`).

- `tz_wards` has **no JPA entity** (read via `JdbcTemplate`/native SQL only), so Hibernate never touches it.
- `shop_locations`, `user_addresses`, `recent_locations` may be JPA entities following project conventions,
  with **plain `lat`/`lng` double columns** (no hibernate-spatial needed). Spatial work is done in native SQL
  using `ST_SetSRID(ST_MakePoint(lng, lat), 4326)`. The seeder adds the spatial expression indexes afterwards.

---

## 5. Database

### 5.1 Entity relationships

```mermaid
erDiagram
    TZ_WARDS ||--o{ SHOP_LOCATIONS : "resolved to"
    TZ_WARDS ||--o{ USER_ADDRESSES : "resolved to"
    TZ_WARDS ||--o{ RECENT_LOCATIONS : "resolved to"
    USERS ||--o{ USER_ADDRESSES : owns
    USERS ||--o{ RECENT_LOCATIONS : has
    SHOPS ||--|| SHOP_LOCATIONS : "has exactly one"
    ORDERS ||--|| ADDRESS_SNAPSHOT : "pickup (frozen copy)"
    ORDERS ||--|| ADDRESS_SNAPSHOT : "drop-off (frozen copy)"
```

Addresses store `ward_id` **and** copies of the names/codes (denormalised) so reads never need a join
and display stays stable.

### 5.2 `tz_wards` (created + filled by seeder)

```sql
CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE IF NOT EXISTS tz_wards (
    ward_id     INTEGER PRIMARY KEY,
    nbs_code    VARCHAR(16)  NOT NULL,
    reg_code    VARCHAR(4)   NOT NULL,
    reg_name    VARCHAR(100) NOT NULL,
    dist_code   VARCHAR(4)   NOT NULL,
    dist_name   VARCHAR(100) NOT NULL,
    counc_code  VARCHAR(4)   NOT NULL,
    counc_name  VARCHAR(200) NOT NULL,
    ward_code   VARCHAR(8)   NOT NULL,
    ward_name   VARCHAR(100) NOT NULL,
    geom        geometry(MultiPolygon, 4326) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tz_wards_geom ON tz_wards USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_tz_wards_names ON tz_wards (reg_name, dist_name, ward_name);
```

### 5.3 Location tables (logical schema — use project ID type & audit conventions)

**`shop_locations`** — exactly one per shop
| column | type | rule |
|---|---|---|
| id | project ID type | PK |
| shop_id | FK → shops | **unique** |
| lat, lng | double | required, rounded to 6 decimals |
| ward_id | int | resolved by backend |
| reg_name, dist_name, ward_name, nbs_code | text | copied from ward |
| match_type | enum `CONTAINED` / `NEAREST` | from resolver |
| landmark | text (≤ 300) | **required** ("Near Shekilango stop, above Vodashop, 1st floor") |
| building | text (≤ 100) | optional ("Shop 12") |
| pickup_phone | text | **required**, E.164 (+255…) |
| official_address | text (≤ 100) | optional (Anwani za Makazi) |
| photo_url | text | optional (shop front photo, uses existing media upload) |
| created_at, updated_at | timestamp | |

**`user_addresses`** — buyer's saved addresses (0..10 per user)
| column | type | rule |
|---|---|---|
| id | project ID type | PK |
| user_id | FK → users | |
| label_type | enum `HOME` / `WORK` / `OTHER` | HOME and WORK max **one each** per user |
| custom_label | text (≤ 40) | required when `OTHER` ("Mama's place") |
| lat, lng | double | required, 6 decimals |
| ward_id, reg_name, dist_name, ward_name, nbs_code, match_type | | resolved by backend |
| note | text (≤ 300) | optional note for rider ("Black gate, call on arrival") |
| receiver_name | text | defaults to user's name |
| receiver_phone | text | defaults to user's phone, E.164 |
| is_default | boolean | at most one `true` per user |
| last_used_at | timestamp | updated when used in an order |
| created_at, updated_at | timestamp | |

**`recent_locations`** — unsaved delivery pins (max 5 per user, expire after 60 days)
| column | type | rule |
|---|---|---|
| id | project ID type | PK |
| user_id | FK → users | |
| lat, lng, ward_id, reg_name, dist_name, ward_name, match_type | | as above |
| note, receiver_name, receiver_phone | | as used in that order |
| last_used_at | timestamp | |

Indexes created by seeder after Hibernate (idempotent):
```sql
CREATE UNIQUE INDEX IF NOT EXISTS ux_shop_locations_shop ON shop_locations (shop_id);
CREATE INDEX IF NOT EXISTS idx_shop_locations_geog ON shop_locations
    USING GIST ((ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography));   -- for future "near me"
CREATE INDEX IF NOT EXISTS idx_user_addresses_user ON user_addresses (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS ux_user_addresses_default ON user_addresses (user_id) WHERE is_default;
CREATE UNIQUE INDEX IF NOT EXISTS ux_user_addresses_home ON user_addresses (user_id) WHERE label_type = 'HOME';
CREATE UNIQUE INDEX IF NOT EXISTS ux_user_addresses_work ON user_addresses (user_id) WHERE label_type = 'WORK';
CREATE INDEX IF NOT EXISTS idx_recent_locations_user ON recent_locations (user_id, last_used_at DESC);
```
(If the project creates these tables itself via its own mechanism, keep the indexes in the seeder anyway.)

### 5.4 `AddressSnapshot` (frozen copy stored on orders)
Not a table owned by this module. The orders module stores it (e.g. a `jsonb` column `pickup_snapshot` /
`dropoff_snapshot`, or embedded columns — follow project convention). **Immutable once stored.**

```json
{
  "lat": -6.774512, "lng": 39.241871,
  "wardId": 1131, "nbsCode": "070113062",
  "regionName": "Dar es Salaam", "districtName": "Kinondoni", "wardName": "Kijitonyama",
  "matchType": "CONTAINED",
  "label": "Home",                         // null if unsaved
  "landmarkOrNote": "Black gate, call on arrival",
  "building": null,
  "contactName": "Kibuti", "contactPhone": "+2557XXXXXXXX",
  "sourceType": "USER_ADDRESS",            // USER_ADDRESS | NEW_PIN | SHOP_LOCATION
  "sourceId": "…",                         // id of saved address/shop location, null for NEW_PIN
  "capturedAt": "2026-10-08T12:30:00Z"
}
```

---

## 6. Pin resolution (core algorithm)

```
 input: lat, lng
   │
   ├─ basic checks: lat ∈ [-90,90], lng ∈ [-180,180], not (0,0) ── fail → 400 INVALID_COORDINATES
   │
   ├─ round to 6 decimals
   │
   ├─ Query A: ward whose shape CONTAINS the point
   │     found → matchType = CONTAINED, distanceM = 0  ───────────────────────► return ward
   │
   ├─ Query B: nearest ward within 500 m (shoreline, beach, jetty, border gaps)
   │     found → matchType = NEAREST, distanceM = n  ─────────────────────────► return ward
   │
   └─ nothing → OUTSIDE_COVERAGE (sea, lake, outside Tanzania)
```

```sql
-- Query A (uses GIST index)
SELECT ward_id, nbs_code, reg_name, dist_name, counc_name, ward_name
FROM tz_wards
WHERE ST_Contains(geom, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326))
LIMIT 1;

-- Query B (only if A returns nothing)
SELECT ward_id, nbs_code, reg_name, dist_name, counc_name, ward_name,
       ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) AS distance_m
FROM tz_wards
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, 500)
ORDER BY distance_m
LIMIT 1;
```

Point exactly on a border → whichever ward Query A returns is fine.
Constant: `NEAREST_WARD_MAX_METERS = 500`.

### 6.1 Validation rules
| Check | Applies to | Result |
|---|---|---|
| Invalid coordinates | all | **block** `INVALID_COORDINATES` |
| No ward within 500 m | all | **block** `OUTSIDE_COVERAGE` ("We don't deliver here yet") |
| Matched by NEAREST | all | allow, `matchType=NEAREST` (info only) |
| Pin > **1 km** from device position (client sends optional `deviceLat/deviceLng`) | **shop only** | allow, return warning `FAR_FROM_DEVICE` with distance; client asks "Your pin is 3.2 km from where you are. Is this your shop?" |
| Saved address within **50 m** of another saved address of same user | buyer save | `409 NEAR_DUPLICATE` with the existing address; client asks "This looks like 🏠 Home. Use it?"; client may resend with `allowNearDuplicate=true` |
| More than 10 saved addresses | buyer save | block `ADDRESS_LIMIT_REACHED` |
| Second HOME or WORK | buyer save/edit | block `LABEL_ALREADY_USED` |

Client-only checks (not backend): GPS accuracy circle, minimum zoom before confirm, satellite toggle.

---

## 7. Business rules

### 7.1 Shop location
- Exactly **one** location per shop. **Required to publish a shop** — expose `hasLocation(shopId)` for the shop module.
- Required fields: pin, landmark, pickup phone. Optional: building, official address, photo.
- Only the shop owner (and shop admins per existing roles) can set/edit it. No approval step.
- Editing affects **future** orders only (old orders keep snapshots).

### 7.2 Buyer addresses
- Saving is **optional** and the buyer's choice (checkbox "Save for next time").
- Max 10 saved. HOME/WORK at most once each; OTHER needs a custom label.
- First saved address becomes default automatically. Buyer can change default.
  If the default is deleted → no default (client pre-selects most recently used).
- Receiver name/phone default to the buyer's profile; buyer can change ("someone else").
- Edit = change pin (re-resolved), label, note, receiver. Delete = **hard delete**.

### 7.3 Recent locations
- When an order uses a **new pin that was not saved**, store it in `recent_locations`.
- Keep max **5** per user (delete oldest beyond 5 on insert). Ignore and delete entries older than **60 days**
  (on read/insert — no scheduler needed).
- If the new pin is within 50 m of an existing recent, update that one's `last_used_at` instead of inserting.
- Buyer can delete one or clear all. A recent can be promoted to a saved address (client posts a normal create).

### 7.4 Orders / snapshots
- Checkout passes either `addressId` (saved), `recentId`, or a **new pin** payload.
- Location module returns an `AddressSnapshot`; orders module stores it; snapshot never changes.
- Using a saved address updates its `last_used_at`.
- Pickup snapshot comes from the shop location (`sourceType=SHOP_LOCATION`).

### 7.5 Account deletion
Deletes all `user_addresses` and `recent_locations` of that user. Order snapshots remain (order records).

---

## 8. Privacy & visibility

| Data | Buyer | Seller | Rider / delivery provider | Third parties |
|---|---|---|---|---|
| Buyer saved addresses / recents | own only | ❌ | ❌ | ❌ |
| Order drop-off **area** (ward, district, region) | ✅ | ✅ | ✅ | ❌ |
| Order drop-off **exact pin + note + phone** | ✅ | only if seller delivers itself (`sellerDelivers=true`) | ✅ for that shipment only | ❌ |
| Shop location (pin, landmark, ward) | ✅ public | ✅ | ✅ | ❌ |
| Shop pickup phone | after order | ✅ | ✅ | ❌ |

Implement as two snapshot views: `AddressSnapshotPublicView` (area only) and `AddressSnapshotFullView`.
Analytics/reports aggregate by ward/district only — never per person.
Never log full coordinates together with user identifiers at INFO level.

---

## 9. REST API

> Paths shown as examples — adapt prefix/style/auth to the existing project.
> All endpoints require authentication except `GET /shops/{id}/location` (public view).

### 9.1 Resolve (used live by pin screen after the map stops moving — debounce ~400 ms client side)
`GET /api/v1/locations/resolve?lat=-6.8190&lng=39.2768`
```json
200 { "covered": true, "matchType": "CONTAINED", "distanceM": 0,
      "ward": { "wardId": 1146, "nbsCode": "070214112", "wardName": "Kariakoo",
                "districtName": "Ilala", "councilName": "Halmashauri ya Jiji la Dar es Salaam",
                "regionName": "Dar es Salaam" },
      "areaLabel": "Kariakoo, Ilala, Dar es Salaam" }
200 { "covered": false, "reason": "OUTSIDE_COVERAGE" }
```
Nothing is stored by this call.

### 9.2 Buyer addresses
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/me/addresses` | list, default first, then `last_used_at` desc |
| POST | `/api/v1/me/addresses` | create (body below) |
| PUT | `/api/v1/me/addresses/{id}` | update (same body) |
| DELETE | `/api/v1/me/addresses/{id}` | hard delete |
| POST | `/api/v1/me/addresses/{id}/default` | make default |
| GET | `/api/v1/me/recent-locations` | max 5, ≤ 60 days |
| DELETE | `/api/v1/me/recent-locations/{id}` | remove one |
| DELETE | `/api/v1/me/recent-locations` | clear all |

```json
POST /api/v1/me/addresses
{ "lat": -6.774512, "lng": 39.241871,
  "labelType": "OTHER", "customLabel": "Mama's place",
  "note": "Black gate, call on arrival",
  "receiverName": null, "receiverPhone": null,      // null → use profile
  "makeDefault": false, "allowNearDuplicate": false }
```

### 9.3 Shop location
| Method | Path | Notes |
|---|---|---|
| GET | `/api/v1/shops/{shopId}/location` | public view (no phone unless authorised) |
| PUT | `/api/v1/shops/{shopId}/location` | create or replace; owner only |

```json
PUT /api/v1/shops/{shopId}/location
{ "lat": -6.770901, "lng": 39.229410,
  "landmark": "Near Shekilango stop, above Vodashop, 1st floor",
  "building": "Shop 12", "pickupPhone": "+2557XXXXXXXX",
  "officialAddress": null, "photoUrl": null,
  "deviceLat": -6.7712, "deviceLng": 39.2291 }       // optional, for FAR_FROM_DEVICE warning
→ 200 { ...location..., "warnings": [ { "code": "FAR_FROM_DEVICE", "distanceM": 3200 } ] }
```

### 9.4 Internal Java API (for orders/shop modules — not REST)
```java
ResolveResult resolve(double lat, double lng);
boolean hasShopLocation(ShopId shopId);
AddressSnapshot snapshotFromShop(ShopId shopId);
AddressSnapshot snapshotFromAddress(UserId userId, AddressId addressId);   // also bumps last_used_at
AddressSnapshot snapshotFromRecent(UserId userId, RecentId recentId);
AddressSnapshot snapshotFromNewPin(UserId userId, NewPinRequest req);      // req.saveForLater → creates address, else records recent
```

### 9.5 Error codes
`INVALID_COORDINATES`, `OUTSIDE_COVERAGE`, `NEAR_DUPLICATE` (409, includes existing address),
`ADDRESS_LIMIT_REACHED`, `LABEL_ALREADY_USED`, `SHOP_LOCATION_REQUIRED` (thrown by shop publish),
`NOT_FOUND`, `FORBIDDEN`. Use the project's existing error response format.

---

## 10. WardDataSeeder (startup)

```
App starts (after Hibernate)
  │
  ├─ 1. CREATE EXTENSION IF NOT EXISTS postgis          (log clear error if not permitted)
  ├─ 2. CREATE TABLE IF NOT EXISTS tz_wards …  + indexes
  ├─ 3. CREATE INDEX IF NOT EXISTS … on location tables (§5.3)
  │
  ├─ 4. BEGIN; SELECT pg_advisory_xact_lock(<constant>)   ← only one container seeds
  │      SELECT count(*) FROM tz_wards
  │        ├─ > 0  → COMMIT, done (normal startup, instant)
  │        └─ = 0  → file exists?
  │                    ├─ no  → WARN "ward file not found at …; ward lookup disabled", COMMIT, continue startup
  │                    └─ yes → stream features (Jackson streaming parser, gzip if *.gz)
  │                             batch insert 200 rows:
  │                             INSERT INTO tz_wards (…, geom)
  │                             VALUES (…, ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(?), 4326)))
  │                             ON CONFLICT (ward_id) DO NOTHING
  │                             COMMIT; ANALYZE tz_wards
  │                             log "Loaded N wards in X s" (expect 4344)
  │
  └─ 5. If wards were just loaded AND user_addresses/shop_locations/recent_locations already have rows
         → re-resolve all of them (update ward_id + name copies + match_type). Order snapshots untouched.
```

Rules:
- Stream the file — never load all 30 MB into memory.
- Accept both `.geojson` and `.geojson.gz`.
- Seeder failure must not crash the app; resolve endpoint then returns `503 WARD_DATA_UNAVAILABLE`.
- **Updating to new boundaries later:** replace file on server, `TRUNCATE tz_wards;`, restart. Step 5 re-resolves saved locations.

---

## 11. Client behaviour (Android · iOS · Web)

### 11.1 Location permission
- **Never** ask on app open. Ask only when the user taps **"Use my location"** on the pin screen.
- Show our own explainer first; trigger the OS/browser prompt only if the user agrees.
- **Foreground / "While using the app" only.** Never background location for buyers or shops.
- Handle **approximate** location (Android 12+, iOS 14+): use it to centre the map, ask user to drag map to exact spot.
- iOS: purpose string e.g. *"NexGate uses your location to set your shop or delivery address faster."*
- Web: only after a click, HTTPS only. If denied, search + map always still work. Location is a shortcut, never a gate.

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆           📍                  ┆
   Use your location?
┆ We use it once to place your  ┆
   pin faster. You can also
┆ search or move the map.       ┆
  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆ ┆   Use my location         ┆ ┆   → OS permission prompt
  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
┆        Not now              ┆     → stay on map, no prompt
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

### 11.2 Pin screen (shared component — shop, buyer, add-address)
Behaviour:
1. **Fixed centre pin, the map moves** (Bolt/Uber pattern). Pin lifts while dragging, drops when the map stops.
2. Start position: accurate GPS → last used location → centre of user's city. Never whole-country view.
3. **Map / Satellite toggle** (satellite matters — many streets unnamed).
4. GPS **accuracy circle**; if accuracy > 100 m show "Location is approximate — move the map to your exact spot".
5. **Confirm disabled until zoom ≥ 17** (street level). Hint: "Zoom in to place exactly".
6. Search (Google Places) only **moves** the map: "Now move the map to the exact entrance".
7. When map stops (debounce ~400 ms): show Google label + call `/locations/resolve`.
   If `covered=false` → disable Confirm, show "We don't deliver here yet".

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ←  Shop location     Step 3/4
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ 🔍 Search place or area   ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆ ┆ [Map|Satellite]           ┆ ┆
  ┆                           ┆
┆ ┆        ( ◌ )  ← accuracy  ┆ ┆
  ┆          📍  fixed centre ┆
┆ ┆        move the map       ┆ ┆
  ┆                    [◎]    ┆   [◎] = use my location
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  ⚠ Zoom in to place exactly
┆   (shown until zoom ≥ 17)     ┆
  📍 Sinza, Ubungo, Dar es Salaam      ← Google label (display only)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆   Confirm location   →    ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

### 11.3 Shop flow (mandatory, step in shop creation)

```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ←  Shop location     Step 3/4
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ [mini map 📍]   Change    ┆
┆ ┆ Makurumla, Ubungo, Dar    ┆ ┆   ← from /resolve (NBS)
  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
┆ Directions / landmark *       ┆
  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆ ┆ Near Shekilango stop,     ┆ ┆
  ┆ above Vodashop, 1st floor ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  Building / shop no. (optional)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ Shop 12                   ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  Pickup contact phone *
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ +255 7XX XXX XXX          ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  Official address (optional)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ Anwani za Makazi          ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  📷 Add shop front photo (optional)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆   Save & continue    →    ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```
If response has `FAR_FROM_DEVICE` → dialog "Your pin is 3.2 km from where you are. Is this your shop?" [Yes, it's correct] [Adjust pin].

### 11.4 Buyer delivery flow (pin only, Bolt style)

**Checkout — choose where**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  Deliver to
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ ● 🏠 Home      (default)   ┆
┆ ┆   Mbezi Juu, Ubungo       ┆ ┆
  ┆ ○ 💼 Work                  ┆
┆ ┆   Kivukoni, Ilala         ┆ ┆
  ┆ ○ ⭐ Mama's place          ┆
┆ ┆   Tabata, Ilala           ┆ ┆
  ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
┆ ┆ Recent                    ┆ ┆
  ┆ ○ 🕘 Kijitonyama   [Save]  ┆
┆ ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ ┆
  ┆ ＋ New location (pin)      ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
         Manage addresses
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```
First-time buyer with nothing saved → goes straight to the pin screen.

**New location — full map + bottom sheet**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ←  Deliver to       [Sat]
┆                               ┆
          FULL MAP
┆           📍                  ┆
                         [◎]
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ 🔍 Search                 ┆
┆ ┆ 🏠 Home     Mbezi Juu      ┆ ┆   ← tap jumps the pin there
  ┆ 💼 Work     Kivukoni       ┆
┆ ┆ 🕘 Recent   Kijitonyama    ┆ ┆
  ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
┆ ┆ 📍 Mikocheni, Kinondoni    ┆ ┆
  ┆ [ Deliver here → ]        ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

**Quick confirm sheet**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  📍 Mikocheni, Kinondoni
┆                               ┆
  Note for rider (optional)
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ Black gate, call on arrival┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  Receiver: Me (+255 7XX…)  ✎        ← "Someone else" → name + phone
┆                               ┆
  [ ] Save as  🏠  💼  ✎             ← labels shown only when ticked
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆      Continue  →          ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

**Profile — manage addresses**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ←  My addresses
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆ 🏠 Home  ✓default     ⋮   ┆
┆ ┆ Mbezi Juu, Ubungo         ┆ ┆
  ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
┆ ┆ 💼 Work               ⋮   ┆ ┆
  ┆ Kivukoni, Ilala           ┆
┆ ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ ┆
  ┆ ⭐ Mama's place        ⋮   ┆
┆ ┆ Tabata, Ilala             ┆ ┆
  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
┆   ⋮ = Edit pin · Rename ·     ┆
    Set default · Delete
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆   ＋ Add address           ┆
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
  🕘 Clear recent locations
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

**Optional secondary onboarding (buyer)**
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┆           📦                  ┆
   Faster checkout?
┆  Add your home address now    ┆
   and skip it at checkout.
┆ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┆
  ┆      Add address          ┆      → pin screen → quick confirm (Save always on)
┆ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┆
           Maybe later
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```

Area lines shown after saving (lists, checkout) are built from **NBS names** (`ward, district`) plus the
user's label — never from Google text.

---

## 12. Flows

### 12.1 Shop sets location
```mermaid
sequenceDiagram
    actor Owner
    participant App
    participant Google as Google Maps/Places (client)
    participant API as Location module
    participant DB as PostGIS
    Owner->>App: Step 3 "Shop location"
    App->>Owner: Explainer → tap "Use my location"
    App->>App: OS permission (while in use), one GPS reading
    Owner->>App: Move map under fixed pin, zoom ≥ 17
    App->>Google: label for map centre (display only)
    App->>API: GET /locations/resolve?lat&lng
    API->>DB: ST_Contains / nearest ≤ 500 m
    DB-->>API: ward, district, region
    API-->>App: covered=true, areaLabel
    Owner->>App: Confirm → landmark, phone, (building, photo)
    App->>API: PUT /shops/{id}/location (+deviceLat/Lng)
    API->>DB: resolve again, upsert shop_locations
    API-->>App: saved (+ FAR_FROM_DEVICE warning if > 1 km)
```

### 12.2 Buyer checkout with a new pin
```mermaid
sequenceDiagram
    actor Buyer
    participant App
    participant Orders as Orders/Checkout
    participant Loc as Location module
    participant DB as PostGIS
    Buyer->>App: Checkout → "＋ New location"
    Buyer->>App: Place pin, note, receiver, [Save?]
    App->>Loc: GET /locations/resolve (live while moving)
    Buyer->>App: Continue / Pay
    App->>Orders: place order { newPin, saveForLater }
    Orders->>Loc: snapshotFromNewPin(user, req)
    Loc->>DB: resolve ward
    alt saveForLater = true
        Loc->>DB: insert user_addresses (rules §7.2)
    else
        Loc->>DB: upsert recent_locations (max 5, 60 days)
    end
    Loc-->>Orders: AddressSnapshot (frozen)
    Orders->>DB: store order + pickup & drop-off snapshots
```

### 12.3 Shop publish guard
```
Shop "Publish" → shopModule calls location.hasShopLocation(shopId)
   ├─ true  → publish
   └─ false → 400 SHOP_LOCATION_REQUIRED → app opens Shop location step
```

---

## 13. Out of scope (do NOT build now)
- Shipping, providers (Bolt etc.), quotes, tracking, recommendation engine
- Delivery zones and hubs (future: same pattern — shapes table + `ST_Contains`)
- Mtaa/street level (no public shapes; future table identical to `tz_wards` if NBS provides them)
- "Near me" feed (index on shop_locations is prepared; feature later — must use rough area only, not stored)
- Any backend call to Google; storing Google labels

---

## 14. Acceptance tests

### 14.1 Ward resolution (integration test against loaded data — these were verified against the file)
| Place | lat | lng | Expected ward | District | Region |
|---|---|---|---|---|---|
| Kariakoo market | -6.8190 | 39.2768 | Kariakoo | Ilala | Dar es Salaam |
| Ubungo bus terminal | -6.7889 | 39.2083 | Ubungo | Ubungo | Dar es Salaam |
| JNIA airport | -6.8781 | 39.2026 | Kipawa | Ilala | Dar es Salaam |
| Posta / city centre | -6.8160 | 39.2900 | Kivukoni | Ilala | Dar es Salaam |
| Stone Town | -6.1622 | 39.1887 | Shangani | Mjini | Mjini Magharibi |
| Arusha clock tower | -3.3700 | 36.6940 | Sekei | Arusha | Arusha |
| Mwanza centre | -2.5164 | 32.9006 | Nyamagana | Nyamagana | Mwanza |
| Mbeya town | -8.9094 | 33.4608 | Ruanda | Mbeya | Mbeya |
| Dodoma centre | -6.1722 | 35.7395 | Majengo | Dodoma | Dodoma |
| Indian Ocean off Dar | -6.80 | 39.40 | — `OUTSIDE_COVERAGE` | | |
| Nairobi | -1.2864 | 36.8172 | — `OUTSIDE_COVERAGE` | | |

Also: `SELECT count(*) FROM tz_wards` = **4344**; `SELECT count(DISTINCT reg_name)` = **31**.
Use Testcontainers with `postgis/postgis` image if the project already uses Testcontainers; otherwise follow project test setup.

### 14.2 Rules
- Seeder runs twice → still 4344 rows, second run instant.
- Two app instances start together → 4344 rows, no errors (advisory lock).
- Missing ward file → app starts, WARN logged, `/resolve` returns 503.
- Swapped lat/lng (39.2768, -6.8190) → `OUTSIDE_COVERAGE` (guards against order bug).
- 11th saved address → `ADDRESS_LIMIT_REACHED`. Second HOME → `LABEL_ALREADY_USED`.
- Save within 50 m of existing → `409 NEAR_DUPLICATE`; with `allowNearDuplicate=true` → saved.
- Delete default → no default remains. First saved address → becomes default.
- 6th unsaved pin → only 5 recents remain; entries > 60 days not returned.
- Edit saved address after an order → order snapshot unchanged.
- Shop without location cannot publish → `SHOP_LOCATION_REQUIRED`.
- Shop PUT with device 3 km away → saved + `FAR_FROM_DEVICE` warning.
- Seller view of order shows area only unless `sellerDelivers=true`.
- `TRUNCATE tz_wards` + restart → reload + saved addresses re-resolved.

---

## 15. Build order
1. Docker: switch DB to `postgis/postgis` (same PG major), mount `/opt/nexgate/geo`, add config, `.gitignore data/geo/`.
2. `WardDataSeeder` + `WardResolver` + `GET /locations/resolve` + tests §14.1. **← foundation**
3. Shop location (entity, PUT/GET, `hasShopLocation`, publish guard).
4. Buyer addresses + recents (+ rules tests).
5. `AddressSnapshot` + checkout integration + privacy views.
6. Clients: shared pin-screen component, shop step, checkout picker, manage addresses, optional onboarding.

# 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

1. **Two ways to get an order: PICKUP or DELIVERY**, chosen per shop. The shipping service owns both.
2. **One shop's order = one parcel.** A cart with three shops = three parcels, three fulfillments.
3. **User addresses are places only** (pin, label, directions). Order data — who receives, notes — lives on the
   order, never in the address book.
4. **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.
5. **Money sits in escrow** and moves on proof (codes), never on trust.
6. **Shippers describe their own coverage, prices and times.** NexGate never forces one model.
7. **No manual approval queues.** Trust comes from documents, codes, reviews and automatic rules.
8. **Orders are accepted 24/7; timing is honest.** Closed hours delay work, never the sale.
9. **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 |

Shared pieces used across: `opening_hours` (shops, businesses, stations), the area picker and frozen places
from `location_service`.

---

## 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 <company>, <station>" (no last-mile leg) and "🚪 Deliver to this exact pin" (if covered).

### 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).

```json
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
```json
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:
1. **Affects only me** → applied at once; the others are told.
2. **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).
3. **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_id` lives 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
1. **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.
2. **Trips:** `WITHIN_EACH` (both ends inside the same ticked area) or `WITHIN_AND_ACROSS` (both ends inside any
   ticked areas — crossing borders between them). **Both ends must always be inside the ticked areas.**
3. **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
1. **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.
2. **Station businesses hand over only at their own stations** (a bus company never drives to a door).
3. **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).
4. 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.
5. **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
1. **Fast filter by area codes** (no maps): both ends inside, trips rule, line rule.
2. **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)
```json
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**.

1. **Shipper reports at the shop, before pickup**: "Can't carry — too big / heavy" (+ optional photo). Not possible
   after the pickup code is used.
2. **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.
3. **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".
4. **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.
5. **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.
6. **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.
7. **Abuse**: a shipper claiming mismatches on products others carry fine → flagged for admins.
8. **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_PACKED` reports 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

1. **Where**: both places' ward / district / region.
2. **Who**: native coverage + integrated providers (skip a provider slower than ~2 s).
3. **Filter**: coverage, max distance, max value, bulky, closed / cut-off (ETA shifts, not dropped), paused / suspended.
4. **Cost + ETA** for each.
5. **Clean-up**: hide **dominated** options (dearer *and* slower) and **outliers** (> 3× the cheapest of the same type) — "See all" shows them.
6. **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.
7. **Order**: labelled first (⭐ 💰 ⚡), then grouped "To your door" / "Collect at a station" by cost; top 5 + "See all".
8. **Fairness**: new shippers get a neutral reliability + "New" badge; exact ties rotate.
9. Quotations hold their prices **5 days** (§4.1); the sheet pre-selects ⭐.

---

## 10. Job flow

1. **Seller accepts** within **24 opening hours** — else auto-cancel + full refund. Shipper gets a non-binding
   **heads-up** (switchable per shipper).
2. **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.**
3. **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.
4. **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).
5. **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.

1. **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**.
2. **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 …).
3. **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).
4. **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).
5. The new shipper gets the offer (normal response window) — and may reject too (back to step 1).
6. **Loop guard: 3 reassignments per order** (not per leg) → after that the buyer is always asked.
7. **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).
8. **Free shipping**: seller / campaign caps are recalculated for the new cost; the buyer pays only what's above them.
9. **The agreement keeps its history** ("leg 3: Mbeya Riders rejected 10:42, busy → Mbeya Express 2,500").
10. **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_part` key 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`, `orderId` filled, 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,500~~ **Free**; 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 | ~~Buyer cancellation rule~~ | **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 | ~~Auto-pause numbers~~ | **decided** — §14 |
| 8–12 | ~~Reassignment proposals~~ | **decided** — §10a items 4, 7a–7c; door shippers always come to the shop (§7.2a) |
| 6 | ~~Cart: unsaved new pin~~ | **decided** — §4.4 |
| 7 | ~~Cart: default for a shop newly added~~ | **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**
1. Rider **calls** the buyer and **waits ~10 min**, then marks **"attempt failed"** (time, location, optional photo).
   Buyer is notified: "We missed you — reschedule?"
2. **Up to 2 attempts**; the buyer picks a new time for attempt 2 in the app.
3. 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).
4. 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 |

# # NexGate Shipping Service — Build Plan Development

How we build `docs/shipping/SHIPPING_SERVICE_DESIGN.md` (the "design", § numbers below point to it).
Status: **plan agreed to start, nothing built yet.** Built on the Location Service (done: wards, shop locations,
user addresses, frozen places).

## Ground rules for every step

- **Talk first, then build** — each step starts with a short talk on its details, then code.
- **Code standard**: new top-level package `shipping_service/` (same level as `location_service/`), the project's
  controller / service / service/impl / payload / entity / repo shape, no boilerplate, comments only where needed.
- **Existing responses**: add fields; reshape only when agreed (dev phase) and always recorded.
- **Every change recorded** in `commerce_logistics_changes.md` — before / after, examples, app to-do.
- **Design doc kept true** — any decision that changes during a step is updated in the design.
- **Settings**: fixed rules in properties (`shipping.*`), per-shipper agreement settings in the database (§6.7.1).
- **Money**: every money move goes through the ledger; every step that moves money ends with the ledger balance check.
- **Tested live** locally after each step (like the location steps), using the worked scenarios (§17) as test cases.
- **Phase-0 hooks** (§15b) are built in from the start: attempt records, failure reason codes, the extra statuses,
  timers as data, reverse legs allowed — even though their rules come later.

## The order at a glance

| Step | Delivers | Real-world result after the step |
|---|---|---|
| **0** | Foundations | nothing visible; ground ready |
| **1** | Pickup, end to end (with codes and "✓ It's what I ordered") | **buyers can buy and collect at the shop** |
| **2** | Native shipper registration (KYC + Service Setup basics) | delivery businesses can sign up |
| **3** | Door coverage, pricing, ETA | businesses describe where / how much |
| **4** | Engine: estimation + quotation (single door leg) | buyers see real delivery costs |
| **5** | Cart / checkout / agreement, escrow per part | **buyers pay real delivery** |
| **6** | Job flow: offers, codes, statuses, release | **first real door-to-door deliveries** |
| **7** | Reassignment, switching, cancelling, bulky mismatch | unhappy-but-common cases handled |
| **8** | Reliability, reviews, auto-pause, upcoming orders | trust and planning |
| **9** | Stations (single leg) | intercity: collect at a station |
| **10** | Multi-shipper chains | shop → bus → door |
| **11** | Group purchase and installments | shipping inside group / installment buys |
| **12** | Free shipping (seller rules + campaigns) | "Free delivery" badges and filter |
| **13** | Bolt integration | boda on demand in town |
| **14** | Admin dashboard (screens) | ops tooling — **last** |
| later | Notifications set · unhappy paths (§15a/b) · staging deploy | after the build |

---

## Step 0 — Foundations
**Goal:** everything later steps stand on, with no behaviour change.

- Package `shipping_service/` with sub-packages from §2 (empty shells added as each step needs them).
- `shipping.*` properties with every value from §6.7.1 (read through one settings class).
- **Shared opening hours** (§6.3): weekly ranges, special days, temporarily closed, cut-off, EAT, overnight;
  "is open at", "next opening", "add opening hours" helpers. Attached to **shops** now (§6.4): shop hours endpoints,
  "Open now / Opens Sat 09:00" on shop responses; shop **pause modes** (keep selling / stop orders, `SHOP_CLOSED`).
- **Escrow table**: `part` (ITEMS / LEG), `legId`, `orderId` filled, **provider payee** (§12.1) — new columns only,
  current payments keep working (they are "ITEMS" parts).
- **Product `bulky`** field (§8.1) — default false; category pre-tick later.
- **File Thunder**: new upload types *business document* (private) and *business logo* (separate repo, small).
- **Codes service**: 4 random digits, hashed, never logged, 5 tries → locked, regenerate (§11). Replaces the old
  `DeliveryConfirmation` code generator (and its plain-code log line).

**Check:** existing checkout / orders / cart-escrow tests still pass; shop hours + pause visible; a code can be
generated, verified, locked after 5 wrong tries, and never appears in logs.

## Step 1 — Pickup, end to end
**Goal:** a buyer can buy, the seller prepares, the buyer collects with a code, money is released on confirmation.

- Move Step-4 `e_commerce/fulfillment` (JSON) into `shipping_service/fulfillment` as a **table**; orders link to it
  (§2, §3.5). Frozen places stay inside the record.
- **Shipping agreement entity** (§4.8): `shipping_agreement` + parties + changes; states DRAFT → PENDING → AGREED →
  DONE / CANCELLED. For pickup the only parties are the buyer and the seller. **Escrow parts move only on agreement
  events** from here on.
- Order flow: seller **accepts** (24 opening hours, else auto-cancel + refund) → **Ready** → buyer told → **pickup at
  the counter**: buyer's code, seller enters it → *collected* (§3.1, §10).
- **"✓ It's what I ordered"** releases **all** escrow parts in one transaction; **"⚠ Report a problem"** freezes them (§12.2).
- Account setting: **code or "received" button** (§11).
- Pickup statuses `placed → accepted → ready → collected → completed` (+ cancelled / expired); optional **pickup
  time** (shop opening hours, inside the window; reminder that morning).
- **14-day collection** expiry with reminders (§3.1). **Cancel before the seller accepts** (§3.4a). **Seller cancel** at once
  (pop-up "I have already informed the buyer"; pickup before collected, delivery before Ready).
- Recent pins recorded **after payment**, not at checkout (§4.4).
- Admin endpoints needed from now: manual **release / refund** of an escrow part (§6.7.2) — `SUPER_ADMIN`, reason, audit log.

**Check:** scenario **A** (§17) live: pay → accept → ready → code → collected → confirm → seller paid; 14-day expiry; seller cancel
refunds; cancel before accept refunds; problem report freezes; admin release works and is logged.

## Step 2 — Native shipper registration
**Goal:** a delivery business can sign up and get approved automatically.

- **KYC** (§6.2 part 1): name, contact phone, **office location** (pin), business licence (file + number + expiry,
  unique number), director's NIDA (file + number), LATRA (file + number + expiry) → auto-approved `KYC_APPROVED`.
- **Service Setup basics** (§6.2 part 2): profile (logo, description, WhatsApp, email, badge), service type
  (door / station), **hours** (shared piece, default Mon–Sat 08:00–18:00), limits (max value, bulky + extra,
  response window, heads-up). Setup checklist endpoint.
- Statuses `KYC_APPROVED → LIVE ⇄ PAUSED`, `SUSPENDED`; documents private (owner + admin).
- "Delivery business" mode in the app (owner only, §6.6) — endpoints for the owner's own business.
- Admin endpoints: **suspend / lift** (§6.7.2).

**Check:** register with all documents → auto-approved; duplicate licence number refused; documents not visible to
others; checklist shows what's missing; suspend / lift logged.

## Step 3 — Door coverage, pricing, ETA
**Goal:** a business says where it works, how much, how long — and goes LIVE.

- **Location helpers** (in `location_service`): regions → districts → wards lists + search; coverage outline for the map.
- **Coverage** (§7.1): ticked areas (region / district / ward), trips `WITHIN_EACH` / `WITHIN_AND_ACROSS`,
  "both ends inside", fast **area-code matching** (the spec's second core function, `areaMatch`).
- **Pricing** (§8.1): distance bands (price + time each, ×1.3, last band = max distance), flat, value add-on ladder
  + max value, bulky extra, rounding up to 100.
- Goes **LIVE automatically** when service type + coverage + pricing exist.
- Delivery commission fee type (0%) in the fee table + per-shipper override with start date (§6.7.1).

**Check:** the §7.1 crossing table (Sinza → Mikocheni with WITHIN_EACH vs ACROSS…) as tests; band and add-on maths
incl. rounding; business flips to LIVE when complete.

## Step 4 — Engine: estimation + quotation (single door leg)
**Goal:** buyers see real delivery options and costs.

- Engine (§9): where → who (coverage) → filter (distance, value, bulky, hours / cut-off, paused) → cost + ETA →
  clean-up (dominated, outliers 3×) → labels 💰 ⚡ ⭐ (⭐ = price + time at launch) → order, top 5, fairness.
- **ETA** (§8.2): from "shop ready", ranges, closed hours.
- **Estimation** `POST /api/v1/shipping/estimations` (§4.5): `cost {from,to}`, `eta {from,to}`, pickup area;
  Redis cache shop-area → buyer-area (1 h).
- **Quotation** (§4.1): live options for a shop's parcel and a destination (§4.2: saved / recent / pin; station
  recognition comes with step 9).
- Product detail: live quotation for the viewer's address.

**Check:** scenario **L** (cards) and the engine walk-throughs from the master guide (A, B, E) reproduce; cache hit
on the second call; closed shipper shifts ETA instead of disappearing.

## Step 5 — Cart, checkout, agreement, escrow per part
**Goal:** buyers pay the real delivery cost; money split into items + leg.

- Cart (§4.3, §4.4): one destination per cart, per-shop quotation **held 5 days**, add-to-cart `fulfillment` +
  `dropoff`, defaults for a new shop, unsaved pin on the cart, re-quote on changes.
- Checkout → **agreement** (§4.1, §4.8) with its **legs** (each leg escrow part points to one): editable on the checkout screen until paid (re-quote, wallet re-check), **locked
  once paid**. Buy-now uses the sheet's quotation.
- **Escrow per part** (§12.1): items part → seller, leg part → shipper owner; release all on confirm.
- **Seller fee on items + tax only**; delivery commission from the leg (§8.3).
- `shippingByShop` now real costs (replaces the 0 placeholder).
- What the app keeps vs the server (§4.6) honoured.

**Check:** scenario **B** up to payment: escrows created per part with the right payees and fees; quotation older
than 5 days refreshed; edit at checkout re-quotes; ledger balance check passes.

## Step 6 — Job flow, codes, delivery
**Goal:** the first real door-to-door delivery from offer to release.

- Seller accepts → **heads-up**; **Ready** → **offer** with response window (counted while open), **closed-shipper
  4 h rule** (§10).
- **Ready pop-up**: "I have packed it well" + "label / number is on the package" (recorded).
- **Parcel number** created when the seller accepts (QR label generated by the app, or written on the box); scanned / typed at pickup → "✓ yours / ✗ wrong parcel";
  shippers see the parcel, never the order (§11, §6.6).
- Shipper: accept / reject (reason), timeout; active job steps; **pickup code** (seller verifies) and **delivery
  code** (shipper submits) or the buyer's button (§11); optional handover photos.
- Shipper accept = **signs the agreement**; all signed → AGREED, parcel moves (§4.8).
- Statuses door ladder (§3.2) incl. **heading to the shop** + phase-0 statuses.
- Release on "✓ It's what I ordered": items → seller, leg → shipper (one transaction).
- Shipper app endpoints for offers / jobs / earnings (§6.6), live push.

**Check:** scenario **B** end to end; scenario **M** (a shipper's day) step by step; timeout counted; codes locked
after 5 tries.

## Step 7 — Reassignment, switching, cancelling, bulky mismatch
**Goal:** the common unhappy-but-normal cases.

- **Reassignment** (§10a): rejection / timeout → that leg only; tiers **1 km → same district → buyer**; same-or-cheaper
  and not slower = automatic (escrow re-pointed, difference refunded); dearer / none → buyer decides; **loop guard
  3 per order**; **buyer silent 24 h → pickup**; **chain safety net 24 h**; free-shipping caps recalculated; history kept.
- **Switching** (§3.4): pickup → delivery (fresh quotation, pay, offer at once if Ready); delivery → pickup (before any
  shipper accepted, refund).
- **Cancelling** (§3.4a): shipping-only until pickup — free before **heading to the shop**, wasted-trip fee (50% / Bolt's
  charge) after; whole order when the chain failed.
- **Agreement changes** (§4.8): propose → affected parties approve → default on silence; **destination change**
  (accept new cost / keep old price / decline; no withdraw after pickup).
- **Bulky mismatch** (§8.4): report before pickup, bulky-only reassignment, seller pays difference + 50% wasted trip,
  auto-flag after 2.
- **Badly packed** refusal (§8.5): before pickup, photo, seller repacks + Ready again, seller pays 50% wasted trip.
- Admin endpoints: force **reassign / cancel** (§6.7.2).

**Check:** scenarios **D, F, G, H, N, O, P**; loop guard; silent buyer → pickup after 24 h; ledger balanced after every
adjustment.

## Step 8 — Reliability, reviews, auto-pause, upcoming orders
- **Reliability counters** (acceptance, on time, failed, broke binding) and **auto-pause** levels with the §14 table,
  grace period 10 offers, reasons shown.
- **Reviews** like shops, verified only, owner replies, moderation (§14); shown in quotations.
- **Two-way private ratings** (shipper rates buyer / seller).
- **Upcoming orders** on the shipper dashboard, area-only until accepted (§6.6).
- Real delivery times recorded (for after-launch ETA).

**Check:** the §14 trigger table as tests (2 / 3 missed, 3rd pause in 7 days → suspended…); new shipper not paused
inside the grace period; upcoming card hides the exact pin until accepted.

## Step 9 — Stations (reached by a door leg)
- Stations (§7.2): pins with counter name, phone, hours; ≥ 1 per ticked area; **lines**; **reach 30 km**
  (per business override).
- **Station-pair table** (price + time, both ways, pre-filled pairs) (§8.1).
- Handover points (§7.2a): leg 1 always starts with a collection at the shop (option A) — either the station
  business **collects from shops** (areas + flat fee, 0 = free; one leg) or a door shipper brings it to the station
  (the **two-leg chain** door → station, built here); buyer collects at the destination station (station staff enter the
  buyer's code). Step 10 extends it to 3 legs, editing and re-confirms.
- Station ladder (§3.3); quotation **meeting-point recognition** ("collect at Mbeya Bus Stand", §4.2).

**Check:** a Mbeya → Morogoro trip as boda → bus (two legs) and as a bus company collecting free (one leg, ranked
cheapest); no door shipper at the shop → no station route; pin within 1 km of a station
offers "collect there"; empty pair = not offered.

## Step 10 — Multi-shipper chains
- Chain building (§4.7): up to 3 legs, meeting points within 1 km, always next to single-leg options.
- Quotation editing per leg: stop early, swap a leg's shipper, change a meeting point.
- **All legs offered at Ready, nothing moves until all accept**; **binding after accept**; **re-confirm** when an
  accepted leg's time moves > 4 h (§10a 7a).
- Shipper → shipper handover: "handed over" / "received" + that handover's code, optional photo (§11).
- One escrow part **per leg**; release all together (§12).

**Check:** scenarios **C, D, E** with three legs; one leg rejecting while others accepted; re-confirm triggered by a
> 4 h move.

## Step 11 — Group purchase and installments
- Group (§5): each member's own fulfillment and fee with their payment; refunds on fail / withdraw; moves with a
  transferred payment; price frozen at payment.
- Installments (§5): immediate = fee with the down payment; layaway = choose and pay after the last instalment
  (reuses switch-to-delivery).

**Check:** scenarios **J** and **K**.

## Step 12 — Free shipping
- Seller rules (areas, minimum, cap, product override) and NexGate campaigns (areas, dates, cap, audience, shops,
  budget reserved at payment, auto-stop) (§13).
- Stacking, the cheapest qualifying option free, faster pays the difference; campaign ledger account; seller share
  from the items part at release.
- Estimation `freeDelivery` + card badge + "Free delivery to my area" search filter.

**Check:** scenario **I**; campaign budget never overspent under parallel checkouts; refund returns budget.

## Step 13 — Bolt integration
- Adapter (§6.5): OAuth2 token cache, estimations (boda, non-bulky, in-city), book at Ready (re-estimate, held price
  = max + 2%), cancel, webhooks (3 IPs + token) and polling fallback, state mapping, tracking link + driver details.
- **Bolt ledger account**, monthly settlement record, receipt differences; **kill-switch** and **daily spend limit**
  (§6.7); seller pays a shop no-show.
- Built and tested in Bolt's **sandbox** first.

**Check:** sandbox flow (estimate → create → states → completed → receipt); NO_DRIVER_FOUND → reassignment; spend
limit blocks new Bolt quotations; kill-switch hides Bolt immediately.

## Step 14 — Admin dashboard (last)
Screens on top of the existing admin endpoints (§6.7.3): shippers + KYC documents, Bolt status and settlement,
operations board, campaigns, reports (by area), audit log, `STAFF_ADMIN` / `SUPER_ADMIN` split.

## After the build
- **New dedicated shipping notifications** (one set, after the shipping service).
- **Unhappy paths** (§15a / §15b): auto-confirm window, delivery attempts + office hold, returns, disputes.
- **Staging / production**: PostGIS image + `CREATE EXTENSION`, ward file, settings — at deployment.
- After-launch items (§16).

## Dependencies

```
0 ─► 1 ─► 2 ─► 3 ─► 4 ─► 5 ─► 6 ─► 7 ─► 8
                         │         ├─► 9 ─► 10
                         │         ├─► 11
                         │         ├─► 12
                         │         └─► 13
                         └─────────────────────► 14 (last)
```
Steps 9, 11, 12, 13 can be reordered after step 6 (each only needs the single-leg flow). 10 needs 9.