# Logistics Service

# 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 &amp; 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**

<table id="bkmrk-type-how-it-works-co"><thead><tr><th>Type</th><th>How it works</th><th>Coverage set by</th><th>Examples</th></tr></thead><tbody><tr><td>**🚪 Door-to-door**</td><td>Picks up at seller, delivers to buyer</td><td>Areas (ward/district/region, or nationwide) + optional max distance</td><td>Boda groups, Kibuti Express, **DHL / EMS** (wide area)</td></tr><tr><td>**🚌 Station**</td><td>Seller drops at a station, buyer collects at a station</td><td>Stations (pins) + lanes between them</td><td>Bus companies, truck offices</td></tr></tbody></table>

> "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**

<table id="bkmrk-native-%28nexgate-busi"><thead><tr><th></th><th>**Native** (NexGate business)</th><th>**Integrated** (external API)</th></tr></thead><tbody><tr><td>Set up by</td><td>The owner, in the app</td><td>NexGate, once, in code + admin</td></tr><tr><td>Pricing</td><td>Owner's tables</td><td>Live from their API</td></tr><tr><td>Jobs arrive</td><td>App notification + SMS/WhatsApp link</td><td>NexGate calls their API</td></tr><tr><td>Status</td><td>Owner taps buttons / link</td><td>Their webhooks or polling → mapped</td></tr><tr><td>Money</td><td>Into owner's NexGate wallet</td><td>NexGate pays them; they invoice NexGate</td></tr><tr><td>New one</td><td>Zero code (user signs up)</td><td>One adapter per company</td></tr></tbody></table>

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

<table id="bkmrk-party-at-setup-%28once"><thead><tr><th>Party</th><th>At setup (once)</th><th>Per order</th></tr></thead><tbody><tr><td>**Seller**</td><td>Shop location, landmark, phone</td><td>`[ ] Bulky` (usually none); tap "Ready for pickup"</td></tr><tr><td>**Shipper**</td><td>Coverage + one pricing method + limits + bulky</td><td>Tap link; update status</td></tr><tr><td>**Buyer**</td><td>—</td><td>Pick a delivery pin; tap one option (Cheapest/Fastest/Recommended)</td></tr></tbody></table>

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):**

<table id="bkmrk-step-fields-feeds-th"><thead><tr><th>Step</th><th>Fields</th><th>Feeds the engine as</th></tr></thead><tbody><tr><td>1 Basics</td><td>name · service type (DOOR/STATION) · booking phone · WhatsApp? · logo</td><td>identity, notify, branches next step</td></tr><tr><td>2 Coverage</td><td>DOOR: areas (region/district/ward) or radius-from-base · STATION: stations + routes</td><td>**coverage check**</td></tr><tr><td>3 Pricing</td><td>method (flat/distance/value/dist+value/grid; route segment or table) · bulky yes/no (+extra)</td><td>**quote**</td></tr><tr><td>4 Limits &amp; alerts</td><td>max value · max distance · won't-carry list · response window (default 30 min) · advance heads-up (default ON)</td><td>**filters**, job alerts (§4.3a)</td></tr><tr><td>5 Hours &amp; money</td><td>opening hours + cut-off · settlement (wallet / pay-at-station) · payout account</td><td>**ETA/availability**, money routing</td></tr><tr><td>6 Verify</td><td>phone OTP · agree shipper terms (liability up to declared value)</td><td>go live</td></tr></tbody></table>

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 &amp; compliance — collect now, confirm mandatory ones with a professional later.**Store each as *document type + file + expiry*. Mark required/optional per shipper type &amp; tier once advised. Non-blocking at launch; uploading the real ones earns a **"Verified business"** badge.

<table id="bkmrk-group-documents-%28col"><thead><tr><th>Group</th><th>Documents (collect if the shipper has them)</th></tr></thead><tbody><tr><td>Identity &amp; business</td><td>NIDA · TIN (TRA) · BRELA (business name / company) · local-govt **business licence** · VAT (VRN) if over threshold</td></tr><tr><td>Transport / courier</td><td>**LATRA** licence (road carriage of goods/passengers — replaced SUMATRA) · **TCRA** courier/postal licence (door-to-door couriers) · TASAC (if freight-forwarding)</td></tr><tr><td>Vehicle &amp; driver (own fleet)</td><td>vehicle registration · commercial/PSV licence · motor insurance (+ goods-in-transit) · driving licence · inspection/fitness</td></tr><tr><td>Employees (if any)</td><td>NSSF · WCF · OSHA</td></tr><tr><td>Money / insurance</td><td>mobile-money/bank payout · goods-in-transit / cargo insurance</td></tr></tbody></table>

> ⚖️ **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 &amp; 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 &amp; orders outside hours

### 6.1 Hours apply to three things

<table id="bkmrk-who-why-shops-pickup"><thead><tr><th>Who</th><th>Why</th></tr></thead><tbody><tr><td>**Shops**</td><td>Pickup availability; when a shipper can collect</td></tr><tr><td>**Delivery businesses**</td><td>Offered as "today" only when working; else ETA shifts</td></tr><tr><td>**Stations**</td><td>Where buyers collect parcels (often different hours from the company)</td></tr></tbody></table>

### 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)

<table id="bkmrk-topic-jumia-%2F-glovo-"><thead><tr><th>Topic</th><th>Jumia / Glovo / Uber / Bolt do…</th><th>NexGate decision</th></tr></thead><tbody><tr><td>Ordering when closed</td><td>Pre-order / scheduled</td><td>✅ Accept 24/7, show real time</td></tr><tr><td>Delivery choice</td><td>Door vs pickup station, options with price+ETA</td><td>✅ Per shop, Cheapest/Fastest/Recommended</td></tr><tr><td>Money safety</td><td>Escrow + 4-digit code</td><td>✅ Have it</td></tr><tr><td>Seller onboarding</td><td>Heavy docs + approval queue</td><td>❌ Avoid — phone verify + auto-limits</td></tr><tr><td>Surge pricing</td><td>Uber/Bolt dynamic</td><td>❌ Avoid — shipper ladders are clearer</td></tr><tr><td>Separate rider apps</td><td>Yes</td><td>❌ Avoid — NexGate deals with the business, not riders</td></tr></tbody></table>

**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

<table id="bkmrk-%23-decision-suggested"><thead><tr><th>\#</th><th>Decision</th><th>Suggested default</th></tr></thead><tbody><tr><td>1</td><td>Buyer unreachable / forgets delivery code</td><td>Shipper marks "Delivered, no code" + photo → buyer 48h to dispute → else auto-release</td></tr><tr><td>2</td><td>Item wrong/damaged after code</td><td>Short report window (24h) before release, or code = final</td></tr><tr><td>3</td><td>Lost / damaged parcel</td><td>Shipper liable up to declared value; refund from shipper wallet; pickup code shows who held it</td></tr><tr><td>4</td><td>Cash on delivery (COD)</td><td>**Prepaid only at launch**; add "accepts COD" per shipper later</td></tr><tr><td>5</td><td>Escrow legality</td><td>**Check with a lawyer / payment partner** (BoT, National Payment Systems Act) — *not legal advice*</td></tr><tr><td>6</td><td>Cancellation cost</td><td>Free before pickup; buyer bears shipper's cost after pickup</td></tr><tr><td>7</td><td>Bolt for parcels</td><td>**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).</td></tr><tr><td>8</td><td>Mandatory licences &amp; platform liability</td><td>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)</td></tr><tr><td>9</td><td>KYC / AML level</td><td>How much identity NexGate must verify &amp; keep for shippers and sellers holding escrowed money — confirm with payment partner / lawyer</td></tr></tbody></table>

---

## 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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

<table id="bkmrk-fact-consequence-coo"><thead><tr><th>Fact</th><th>Consequence</th></tr></thead><tbody><tr><td>Coordinates are **\[longitude, latitude\]** (GeoJSON order)</td><td>`ST_MakePoint(lng, lat)` — longitude first. Most common bug.</td></tr><tr><td>Geometry is a mix of `Polygon` and `MultiPolygon`</td><td>Always store with `ST_Multi(...)` into a `MultiPolygon` column.</td></tr><tr><td>`ward_code` alone is NOT unique; even `nbs_code` repeats for 5 wards (NBS data issue)</td><td>**`ward_id` (1..4344) is the primary key.** `nbs_code` is informational, not unique.</td></tr><tr><td>Shapes stop at shorelines (sea, Lake Victoria, etc.)</td><td>Pins on beaches/jetties may fall outside every ward → nearest-ward fallback (§6).</td></tr><tr><td>31 regions, ~4,344 wards. Dar es Salaam has 5 councils (Ilala, Kinondoni, Temeke, Ubungo, Kigamboni)</td><td>Expected counts for sanity tests.</td></tr></tbody></table>

### 3.4 District &amp; 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 &amp; audit conventions)

**`shop_locations`** — exactly one per shop

<table id="bkmrk-column-type-rule-id-"><thead><tr><th>column</th><th>type</th><th>rule</th></tr></thead><tbody><tr><td>id</td><td>project ID type</td><td>PK</td></tr><tr><td>shop\_id</td><td>FK → shops</td><td>**unique**</td></tr><tr><td>lat, lng</td><td>double</td><td>required, rounded to 6 decimals</td></tr><tr><td>ward\_id</td><td>int</td><td>resolved by backend</td></tr><tr><td>reg\_name, dist\_name, ward\_name, nbs\_code</td><td>text</td><td>copied from ward</td></tr><tr><td>match\_type</td><td>enum `CONTAINED` / `NEAREST`</td><td>from resolver</td></tr><tr><td>landmark</td><td>text (≤ 300)</td><td>**required** ("Near Shekilango stop, above Vodashop, 1st floor")</td></tr><tr><td>building</td><td>text (≤ 100)</td><td>optional ("Shop 12")</td></tr><tr><td>pickup\_phone</td><td>text</td><td>**required**, E.164 (+255…)</td></tr><tr><td>official\_address</td><td>text (≤ 100)</td><td>optional (Anwani za Makazi)</td></tr><tr><td>photo\_url</td><td>text</td><td>optional (shop front photo, uses existing media upload)</td></tr><tr><td>created\_at, updated\_at</td><td>timestamp</td><td></td></tr></tbody></table>

**`user_addresses`** — buyer's saved addresses (0..10 per user)

<table id="bkmrk-column-type-rule-id--1"><thead><tr><th>column</th><th>type</th><th>rule</th></tr></thead><tbody><tr><td>id</td><td>project ID type</td><td>PK</td></tr><tr><td>user\_id</td><td>FK → users</td><td></td></tr><tr><td>label\_type</td><td>enum `HOME` / `WORK` / `OTHER`</td><td>HOME and WORK max **one each** per user</td></tr><tr><td>custom\_label</td><td>text (≤ 40)</td><td>required when `OTHER` ("Mama's place")</td></tr><tr><td>lat, lng</td><td>double</td><td>required, 6 decimals</td></tr><tr><td>ward\_id, reg\_name, dist\_name, ward\_name, nbs\_code, match\_type</td><td></td><td>resolved by backend</td></tr><tr><td>note</td><td>text (≤ 300)</td><td>optional note for rider ("Black gate, call on arrival")</td></tr><tr><td>receiver\_name</td><td>text</td><td>defaults to user's name</td></tr><tr><td>receiver\_phone</td><td>text</td><td>defaults to user's phone, E.164</td></tr><tr><td>is\_default</td><td>boolean</td><td>at most one `true` per user</td></tr><tr><td>last\_used\_at</td><td>timestamp</td><td>updated when used in an order</td></tr><tr><td>created\_at, updated\_at</td><td>timestamp</td><td></td></tr></tbody></table>

**`recent_locations`** — unsaved delivery pins (max 5 per user, expire after 60 days)

<table id="bkmrk-column-type-rule-id--2"><thead><tr><th>column</th><th>type</th><th>rule</th></tr></thead><tbody><tr><td>id</td><td>project ID type</td><td>PK</td></tr><tr><td>user\_id</td><td>FK → users</td><td></td></tr><tr><td>lat, lng, ward\_id, reg\_name, dist\_name, ward\_name, match\_type</td><td></td><td>as above</td></tr><tr><td>note, receiver\_name, receiver\_phone</td><td></td><td>as used in that order</td></tr><tr><td>last\_used\_at</td><td>timestamp</td><td></td></tr></tbody></table>

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

<table id="bkmrk-check-applies-to-res"><thead><tr><th>Check</th><th>Applies to</th><th>Result</th></tr></thead><tbody><tr><td>Invalid coordinates</td><td>all</td><td>**block** `INVALID_COORDINATES`</td></tr><tr><td>No ward within 500 m</td><td>all</td><td>**block** `OUTSIDE_COVERAGE` ("We don't deliver here yet")</td></tr><tr><td>Matched by NEAREST</td><td>all</td><td>allow, `matchType=NEAREST` (info only)</td></tr><tr><td>Pin &gt; **1 km** from device position (client sends optional `deviceLat/deviceLng`)</td><td>**shop only**</td><td>allow, return warning `FAR_FROM_DEVICE` with distance; client asks "Your pin is 3.2 km from where you are. Is this your shop?"</td></tr><tr><td>Saved address within **50 m** of another saved address of same user</td><td>buyer save</td><td>`409 NEAR_DUPLICATE` with the existing address; client asks "This looks like 🏠 Home. Use it?"; client may resend with `allowNearDuplicate=true`</td></tr><tr><td>More than 10 saved addresses</td><td>buyer save</td><td>block `ADDRESS_LIMIT_REACHED`</td></tr><tr><td>Second HOME or WORK</td><td>buyer save/edit</td><td>block `LABEL_ALREADY_USED`</td></tr></tbody></table>

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 &amp; visibility

<table id="bkmrk-data-buyer-seller-ri"><thead><tr><th>Data</th><th>Buyer</th><th>Seller</th><th>Rider / delivery provider</th><th>Third parties</th></tr></thead><tbody><tr><td>Buyer saved addresses / recents</td><td>own only</td><td>❌</td><td>❌</td><td>❌</td></tr><tr><td>Order drop-off **area** (ward, district, region)</td><td>✅</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Order drop-off **exact pin + note + phone**</td><td>✅</td><td>only if seller delivers itself (`sellerDelivers=true`)</td><td>✅ for that shipment only</td><td>❌</td></tr><tr><td>Shop location (pin, landmark, ward)</td><td>✅ public</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Shop pickup phone</td><td>after order</td><td>✅</td><td>✅</td><td>❌</td></tr></tbody></table>

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

<table id="bkmrk-method-path-notes-ge"><thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead><tbody><tr><td>GET</td><td>`/api/v1/me/addresses`</td><td>list, default first, then `last_used_at` desc</td></tr><tr><td>POST</td><td>`/api/v1/me/addresses`</td><td>create (body below)</td></tr><tr><td>PUT</td><td>`/api/v1/me/addresses/{id}`</td><td>update (same body)</td></tr><tr><td>DELETE</td><td>`/api/v1/me/addresses/{id}`</td><td>hard delete</td></tr><tr><td>POST</td><td>`/api/v1/me/addresses/{id}/default`</td><td>make default</td></tr><tr><td>GET</td><td>`/api/v1/me/recent-locations`</td><td>max 5, ≤ 60 days</td></tr><tr><td>DELETE</td><td>`/api/v1/me/recent-locations/{id}`</td><td>remove one</td></tr><tr><td>DELETE</td><td>`/api/v1/me/recent-locations`</td><td>clear all</td></tr></tbody></table>

```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

<table id="bkmrk-method-path-notes-ge-1"><thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead><tbody><tr><td>GET</td><td>`/api/v1/shops/{shopId}/location`</td><td>public view (no phone unless authorised)</td></tr><tr><td>PUT</td><td>`/api/v1/shops/{shopId}/location`</td><td>create or replace; owner only</td></tr></tbody></table>

```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 &gt; 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)

<table id="bkmrk-place-lat-lng-expect"><thead><tr><th>Place</th><th>lat</th><th>lng</th><th>Expected ward</th><th>District</th><th>Region</th></tr></thead><tbody><tr><td>Kariakoo market</td><td>-6.8190</td><td>39.2768</td><td>Kariakoo</td><td>Ilala</td><td>Dar es Salaam</td></tr><tr><td>Ubungo bus terminal</td><td>-6.7889</td><td>39.2083</td><td>Ubungo</td><td>Ubungo</td><td>Dar es Salaam</td></tr><tr><td>JNIA airport</td><td>-6.8781</td><td>39.2026</td><td>Kipawa</td><td>Ilala</td><td>Dar es Salaam</td></tr><tr><td>Posta / city centre</td><td>-6.8160</td><td>39.2900</td><td>Kivukoni</td><td>Ilala</td><td>Dar es Salaam</td></tr><tr><td>Stone Town</td><td>-6.1622</td><td>39.1887</td><td>Shangani</td><td>Mjini</td><td>Mjini Magharibi</td></tr><tr><td>Arusha clock tower</td><td>-3.3700</td><td>36.6940</td><td>Sekei</td><td>Arusha</td><td>Arusha</td></tr><tr><td>Mwanza centre</td><td>-2.5164</td><td>32.9006</td><td>Nyamagana</td><td>Nyamagana</td><td>Mwanza</td></tr><tr><td>Mbeya town</td><td>-8.9094</td><td>33.4608</td><td>Ruanda</td><td>Mbeya</td><td>Mbeya</td></tr><tr><td>Dodoma centre</td><td>-6.1722</td><td>35.7395</td><td>Majengo</td><td>Dodoma</td><td>Dodoma</td></tr><tr><td>Indian Ocean off Dar</td><td>-6.80</td><td>39.40</td><td>— `OUTSIDE_COVERAGE`</td><td></td><td></td></tr><tr><td>Nairobi</td><td>-1.2864</td><td>36.8172</td><td>— `OUTSIDE_COVERAGE`</td><td></td><td></td></tr></tbody></table>

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 &gt; 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.