# App Fixes — Change Log

**Author**: Josh S. Sakweli, Backend Lead Team
**Last Updated**: 2026-10-25
**Version**: v1.0
**Backend version / branch**: 1.2.4 · `fet/location_service` (+ notification server `fet/location_service`, File Thunder `fet/shipping`)
**Deployed to**: not yet — staging first
**Compared with**: the `staging` branch as deployed (`448f06a0`, 2026-10-06) — "Before" means what the app sees on staging today

**Base URL**: `https://api.nexgate.co/api/v1` (staging: `https://dev.api.nexgate.co/api/v1`)

**Short Description**: Shops now have a real place on the map, buyers keep an address book, and every shop in a checkout is
either **pickup at the shop** or **delivery by a shipper** with a real price. This changes existing shop, product, cart,
checkout and order endpoints — the old fake shipping fields (`shippingAddressId`, `shippingMethodId`, `trackingNumber`,
`carrier`) are gone. The release also carries app fixes: digital downloads, chat avatars, profile cover, donation target,
the sign-in alert. Android, iOS and web must update before this goes to production; the admin dashboard has a few changes.

**Audience**: mobile team · web team · admin dashboard

**Related docs** — the **new** endpoints are not repeated here, they live in their own docs:
- `docs/location/location_api_doc.md` — pin → area, area lists, shop location, my addresses, recent pins
- `docs/shipping/shipping_api_doc.md` — delivery options, cart delivery, every pickup / delivery order action, shippers, admin
- `docs/shipping/campaigns/campaigns_api_doc.md` — delivery discounts run by shops, shippers and NexGate
- `notifications_inapp_bell_api_doc.md` — the bell list, `targetType` / `targetId`

Also new since staging, each with its own doc (already shared, not repeated here):
- `event_group_tickets_api_doc.md` — event tickets "buy together": `groupBuying` on tickets, `group` on `POST /e-events/checkout`,
  ticket groups (open, by code, mine, transfer, withdraw), 4 notifications
- `product_group_purchase_api_doc.md` — product group buys: whole-payment transfer, withdraw, orders always created,
  true savings, 15-minute expiry

This log covers **what changed in endpoints, fields, rules and notifications that already existed**, plus the few new
endpoints that sit inside an existing service and have no other doc.

---

## How to read this

### Change types
| Badge | Means | App must act? |
|---|---|---|
| <span style="background-color: #6f42c1; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">NEW</span> | a new endpoint, field or notification | only to use the new feature |
| <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> | an existing endpoint gained fields or behaves differently; old calls still work | should update |
| <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> | old calls fail or give a different meaning — a field removed or renamed, a rule tightened | **must update before the deploy date** |
| <span style="background-color: #6c757d; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">REMOVED</span> | the endpoint / field / notification is gone | **must stop using it** |
| <span style="background-color: #20c997; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">FIX</span> | a bug fixed; same shape, now correct values | check screens that relied on the wrong value |

### HTTP methods
- **GET** - <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span>
- **POST** - <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span>
- **PUT** - <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span>
- **PATCH** - <span style="background-color: #17a2b8; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span>
- **DELETE** - <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span>

### Rules this log follows
- Grouped **by service**. Inside a service: what changed, the flow (when the order of steps changed), the endpoint table,
  then one block per endpoint.
- Every changed endpoint shows **Before** and **After** for the request and the response — only the parts that changed.
- Samples are short: the changed fields and one or two around them.
- Coded refusals are HTTP **409** with `data.code` (and now, sometimes, `data.details`). New codes are listed per endpoint
  and again in the index (§16).

---

## 0. At a glance

| # | Service | New | Changed | Breaking | Removed | App work |
|---|---|---|---|---|---|---|
| 1 | Shops | 4 | 6 | 2 | 6 fields | location step after create; opening hours + pause screens; read `location`, `availability`; show review replies |
| 2 | Products | — | 3 | — | — | Directions on the product page; "Bulky" switch |
| 3 | Cart | 1 | 4 | — | — | group by shop, delivery per shop, ticks, `selectedTotal` |
| 4 | Checkout & payment | — | 4 | 1 | 2 request + 2 response fields | send `fulfillment`; handle price-changed codes |
| 5 | Orders | — | 6 | 2 | 3 fields | read `fulfillment`; pickup orders use the new order actions |
| 6 | Installments | — | 3 | — | 1 field | delivery for "ships now" plans; cancel via the agreement |
| 7 | Group purchase | — | 1 | — | — | pickup / delivery in the group sheet |
| 8 | Wallet & transactions | — | 1 | — | — | new history titles |
| 9 | Events | — | 4 | 1 | — | venue pin required to publish; donation progress |
| 10 | Profile & account | — | 2 | — | — | cover photo |
| 11 | Interests | — | 1 | — | — | (admin) only admins edit categories |
| 12 | Chat | — | 2 | — | — | avatars are now real links |
| 13 | Digital downloads | — | 1 | — | — | save the file as received |
| 14 | Admin (existing screens) | 1 | 2 | — | — | review moderation, fee type, reconciliation lines |
| 15 | Notifications | 26 | 9 | — | — | handle new types and 4 new targets |

## 0.1 Breaking changes — do these first

| Service | Endpoint / item | What breaks | Do this | Section |
|---|---|---|---|---|
| Shops | create / update shop | `city`, `region`, `streetAddress`, `landmark`, `latitude`, `longitude` no longer exist | stop sending them; add the **Shop location** step | §1.2 |
| Shops | every shop response, shop search | the same fields are gone from responses | read `hasLocation` + `location` | §1.3, §1.4 |
| Checkout | `POST /e-commerce/checkout-sessions`, `PATCH …/{id}` | `shippingAddressId` / `shippingMethodId` removed; a session with physical items needs `fulfillment` | send `fulfillment` | §4.2 |
| Checkout | session response | `shippingAddress` / `shippingMethod` removed | read `fulfillment` | §4.3 |
| Orders | order detail | `deliveryAddress`, `trackingNumber`, `carrier` removed | read `fulfillment` | §5.2 |
| Orders | `/orders/{id}/ship`, `/confirm-delivery`, `/regenerate-code` | refused for pickup orders and for orders a shipper delivers | use the shipping order actions | §5.4 |
| Orders | delivery code | 6 digits → **4 digits**, expires after 24 h, wrong code is a coded 409 | 4-digit input, handle the codes | §5.5 |
| Events | publish / reveal-location | an in-person or hybrid event needs a venue **pin** | pin screen on the venue step | §9.2 |

## 0.2 Removed

| Service | Endpoint / field / notification | Use instead | Section |
|---|---|---|---|
| Shops | request + response fields `city`, `region`, `streetAddress`, `landmark`, `latitude`, `longitude` | `location` (shop location) | §1.2–§1.4 |
| Checkout | `shippingAddressId`, `shippingMethodId` (request) · `shippingAddress`, `shippingMethod` (response) | `fulfillment` | §4.2, §4.3 |
| Orders | `deliveryAddress`, `trackingNumber`, `carrier` (detail) · `trackingNumber` on `/ship` | `fulfillment` | §5.2 |
| Installments | `shippingAddress` on the agreement | `fulfillment` | §6.1 |

---

# 1. Shops

**What changed in this service**: a shop's place is no longer typed text (city, region, landmark). It is a **pin on the
map**, turned by the backend into ward / district / region, set with the shop location endpoint (location doc). Every shop
response now carries that `location`, and an `availability` block (open now, paused, taking orders). Paused shops stay
visible. Shop reviews finally show the owner's reply.

## 1.0 Flow — before and after

**Before**
```mermaid
flowchart LR
    A[Create shop<br/>type city, region, street, landmark, lat/lng] --> B[Shop live]
```

**After**
```mermaid
flowchart LR
    A[Create shop<br/>no location fields] --> B[Shop location step<br/>drop a pin: PUT /shops/id/location] --> C[Shop can sell physical items]
    A -. no location yet .-> D[Physical items refused at checkout:<br/>SHOP_LOCATION_REQUIRED]
```

**What the user notices**: the seller drops a pin instead of typing an address; buyers see "Kariakoo, Ilala, Dar es
Salaam" with a Directions and a Call button; shops show "Open now · closes 18:00" or "Paused until …".

## 1.1 Endpoints in this service

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| BREAKING | POST | `/e-commerce/shops` | location fields removed | must update |
| BREAKING | PUT | `/e-commerce/shops/{shopId}` | location fields removed | must update |
| BREAKING | GET | every shop response (profile, detailed, lists, create / update / approve) | old location fields out, `hasLocation` + `location` + `availability` in | must update |
| BREAKING | GET | `/e-commerce/shops/search` | `city` → `location` | must update |
| CHANGED | GET | `/e-commerce/search` (global) | `shop.location` is now the area name | check display |
| CHANGED | — | product pages, search, subscribe, event / post / clip shop links | paused shops are accepted, not 404 | no change |
| CHANGED | GET | shop reviews (list, paged, my-review, create / update) | gain `replyText`, `repliedAt` | should update |
| FIX | GET | shop rating, `/summary` | counts only ACTIVE reviews | check |
| NEW | PUT | `/e-commerce/shops/{shopId}/hours` | set the shop's opening hours | to use |
| NEW | GET | `/e-commerce/shops/{shopId}/hours` | read them | to use |
| NEW | PUT | `/e-commerce/shops/{shopId}/pause` | pause the shop | to use |
| NEW | DELETE | `/e-commerce/shops/{shopId}/pause` | reopen it | to use |

> New, documented elsewhere: shop location `PUT/GET /e-commerce/shops/{shopId}/location` → location doc §6–§7.

---

## 1.2 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Create / update shop — location fields removed

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-commerce/shops` · <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-commerce/shops/{shopId}`

| | |
|---|---|
| **Change** | BREAKING in meaning — old bodies are still accepted (the fields are ignored) but nothing is saved from them |
| **Impact** | Must update |
| **Reference** | `commerce_logistics_changes.md` §5 |

**In one line**: the shop's place is no longer part of create / update; set it with the shop location step.

### Request
| Field | Before | After | Note |
|---|---|---|---|
| `city`, `region` | **required** on create | **removed** | |
| `streetAddress`, `landmark`, `latitude`, `longitude` | optional | **removed** | landmark + pin now live in the shop location |
| `phoneNumber`, `countryCode`, the rest | unchanged | unchanged | `phoneNumber` is also the shop location's phone |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{
  "shopName": "Mama Yoyo Shop",
  "phoneNumber": "+255744742944",
  "city": "Dar es Salaam",
  "region": "Dar es Salaam",
  "landmark": "Opposite the clock tower"
}
```

</td><td>

```json
{
  "shopName": "Mama Yoyo Shop",
  "phoneNumber": "+255744742944"
}
```

</td></tr>
</table>

### App to do
- [ ] Remove city / region / street / landmark / lat / lng inputs from the shop form.
- [ ] After create, open the **Shop location** step (`PUT …/location`, location doc §6); editing uses the same screen.

---

## 1.3 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Shop responses — `location`, `hasLocation`, `availability`

**Endpoint**: every shop response — `GET /e-commerce/shops/{shopId}`, `/{shopId}/detailed`, `/shops/all`, `/featured`,
`/my-shops` (and paged / category forms), create / update / approve.

| | |
|---|---|
| **Change** | BREAKING — fields removed; new blocks added |
| **Impact** | Must update |
| **Reference** | §6, §21 |

### Response
| Field | Before | After | Note |
|---|---|---|---|
| `streetAddress`, `city`, `region` | present | **removed** | |
| `latitude`, `longitude`, `landmark` | present | **removed** | |
| `hasLocation` | — | **added** | `true` / `false` |
| `location` | — | **added** | same object as `GET …/location` (location doc §7), or `null` |
| `availability` | — | **added** | open now / paused / taking orders |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{
  "shopName": "Mama Yoyo Shop",
  "city": "Dar es Salaam",
  "region": "Dar es Salaam",
  "landmark": "Opposite the clock tower",
  "latitude": -6.819, "longitude": 39.2768
}
```

</td><td>

```json
{
  "shopName": "Mama Yoyo Shop",
  "hasLocation": true,
  "location": { "lat": -6.819, "lng": 39.2768,
    "areaLabel": "Kariakoo, Ilala, Dar es Salaam",
    "landmark": "Opposite the clock tower",
    "phone": "+255744742944", "…": "…" },
  "availability": { "status": "ACTIVE", "acceptingOrders": true,
    "paused": false, "pauseMode": null, "pausedUntil": null, "pauseReason": null,
    "hours": { "hasHours": true, "openNow": true,
               "closesAt": "2026-10-25T18:00:00+03:00", "nextOpensAt": null } }
}
```

</td></tr>
</table>

### Behaviour
- `availability.acceptingOrders` is `false` when the shop is paused with `STOP_ORDERS`, suspended or closed → Buy is off.
- `pauseMode`: `KEEP_SELLING` (shows "paused" but orders still come in, accepted later) or `STOP_ORDERS`.
- `hours.openNow` is `null` when the shop has set no hours. All times are East Africa Time with the offset.
- **Paused shops stay visible.** Product pages, product and shop search, subscribe, profile shop counts, linking a shop to
  an event and shop tags on posts / clips now accept a paused (`TEMPORARILY_OFFLINE`) shop — before, its products 404'd.

### App to do
- [ ] Show area / map / Directions (`lat,lng` → Google / Apple Maps) / Call (`phone`) from `location`.
- [ ] Owner: when `hasLocation` is false show "Add your shop location to start receiving orders".
- [ ] Shop page: "Open now · closes 18:00" / "Opens Mon 08:00" / "Paused until …"; Buy off when `acceptingOrders` is false.

---

## 1.4 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Shop search and global search

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-commerce/shops/search` · <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-commerce/search?query=…`

| Response | Field | Before | After |
|---|---|---|---|
| shop search items | `city` | owner-typed text | **removed** |
| shop search items | `location` | — | **added** (location object or `null`) |
| global search, `type: "SHOP"` | `shop.location` (string) | owner-typed landmark | the area name, e.g. `"Ubungo, Ubungo, Dar es Salaam"`, or `null` |

**Reference**: §7, §8

---

## 1.5 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Shop reviews — the owner's reply is returned

**Endpoint**: shop review list, `/paged`, `/my-review`, create / update responses.

| | |
|---|---|
| **Change** | CHANGED (+ FIX on the rating) |
| **Impact** | Should update |
| **Reference** | §31 |

**In one line**: the reply the owner saved with `POST /shops/reviews/{shopId}/{reviewId}/reply` was never returned; now it is.

| Field | Before | After |
|---|---|---|
| `replyText`, `repliedAt` | — | **added** (null when no reply) |

**FIX**: the shop's average, rating count and the 5★…1★ breakdown (`/summary`, and the rating on shop responses) counted
every review whatever its status; now only **ACTIVE** reviews count, so a hidden review leaves the stars at once.

### App to do
- [ ] Show the shop's reply under a review when `replyText` is set.

---

## 1.6 <span style="background-color: #6f42c1; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">NEW</span> Shop opening hours

**Purpose**: the hours that drive `availability.hours` ("Open now · closes 18:00"), the seller's 24-opening-hour accept
window and the buyer's pickup times.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-commerce/shops/{shopId}/hours` · <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-commerce/shops/{shopId}/hours`

**Access Level**: 🔒 PUT: shop owner, or a super / staff admin · GET: any logged-in user

**Authentication**: Bearer token

**Request JSON Sample** (PUT — send the whole week every time; it replaces what was there):
```json
{
  "weekly": [
    { "day": "MONDAY", "open": "08:00", "close": "18:00" },
    { "day": "SATURDAY", "open": "09:00", "close": "13:00" },
    { "day": "SATURDAY", "open": "14:00", "close": "17:00" }
  ],
  "specialDays": [
    { "date": "2026-12-25", "closed": true, "ranges": [], "note": "Christmas" },
    { "date": "2026-12-31", "closed": false, "ranges": [ { "open": "08:00", "close": "12:00" } ] }
  ]
}
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| weekly | array | Yes | `{ day, open, close }`; several ranges per day allowed; a day not listed = closed | max 21; `day` `MONDAY`…`SUNDAY`; times `HH:mm` |
| weekly[].close | time | Yes | earlier than `open` = runs past midnight (e.g. 20:00 → 02:00) | |
| specialDays | array | No | one date that differs from the week: `closed: true`, or its own `ranges` | max 60; each date once; not closed → at least one range (max 3) |
| specialDays[].note | string | No | e.g. "Christmas" | max 100 |
| sameDayCutoff | time | No | not used for shops (shippers only) | |

**Success Response JSON Sample** (PUT and GET):
```json
{ "success": true, "httpStatus": "OK", "message": "Opening hours saved",
  "data": { "weekly": [ { "day": "MONDAY", "open": "08:00:00", "close": "18:00:00" } ],
            "specialDays": [ { "date": "2026-12-25", "closed": true, "ranges": [], "note": "Christmas" } ],
            "sameDayCutoff": null, "timeZone": "Africa/Dar_es_Salaam", "updatedAt": "2026-10-25T10:12:00" } }
```

**Success Response Fields**:
| Field | Description |
|-------|-------------|
| weekly, specialDays | as saved, weekly sorted by day then open time |
| timeZone | always East Africa Time |

**Errors**:
| Code / message | Status | When |
|---|---|---|
| `HOURS_INVALID` | 409 | a special day listed twice, or not closed and with no hours |
| "Only the shop owner can change the shop's hours or pause it" | 403 | not the owner |
| "This shop has not set its opening hours yet" | 404 | GET before any hours were saved |

**Rules**: a shop with no hours counts as always open (`availability.hours.hasHours: false`, `openNow: null`). The status
on shop responses updates at once after a save.

**App to do**:
- [ ] Seller: "Opening hours" screen (a row per day, add a second range, special days) → PUT the whole thing.

---

## 1.7 <span style="background-color: #6f42c1; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">NEW</span> Pause / reopen the shop

**Purpose**: the seller takes a break — with or without still taking orders.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-commerce/shops/{shopId}/pause` · <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/e-commerce/shops/{shopId}/pause`

**Access Level**: 🔒 shop owner, or a super / staff admin

**Authentication**: Bearer token

**Request JSON Sample** (PUT):
```json
{ "mode": "STOP_ORDERS", "until": "2026-10-28T09:00:00+03:00", "reason": "Stock taking" }
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| mode | enum | Yes | `STOP_ORDERS` — checkout refused with `SHOP_CLOSED`; `KEEP_SELLING` — shows "paused" but orders still come in and wait | |
| until | datetime with offset | No | reopens by itself at that time (checked every 5 minutes); leave out = until reopened by hand | must be in the future |
| reason | string | No | shown to buyers ("Paused: Stock taking") | max 255 |

**Success Response JSON Sample** (PUT "Shop paused", DELETE "Shop reopened") — the `availability` block (§1.3):
```json
{ "success": true, "httpStatus": "OK", "message": "Shop paused",
  "data": { "status": "TEMPORARILY_OFFLINE", "acceptingOrders": false, "paused": true,
            "pauseMode": "STOP_ORDERS", "pausedUntil": "2026-10-28T09:00:00+03:00", "pauseReason": "Stock taking",
            "hours": { "hasHours": true, "openNow": true, "closesAt": "2026-10-25T18:00:00+03:00", "nextOpensAt": null } } }
```

**Errors**:
| Code / message | Status | When |
|---|---|---|
| `SHOP_NOT_PAUSABLE` | 409 | the shop is neither ACTIVE nor already paused (e.g. SUSPENDED, PENDING, closed) |
| "Only the shop owner can change the shop's hours or pause it" | 403 | not the owner |

**Rules**:
- Pausing a paused shop replaces its mode / until / reason. DELETE on a shop that is not paused changes nothing and returns
  its availability.
- A paused shop stays visible everywhere (§1.3).
- `KEEP_SELLING`: orders are not auto-cancelled while paused; their 24-opening-hour accept deadline restarts when the
  pause ends.

**App to do**:
- [ ] Seller: "Pause shop" sheet (stop orders / keep selling, optional until, optional reason) and a "Reopen" button.
- [ ] Buyer: "Paused until …" on the shop page from `availability`.

---

# 2. Products

**What changed in this service**: product responses carry the shop's location (for "Pickup at …" and Directions), a new
`bulky` flag tells shippers a parcel is big or heavy, and a refused save no longer half-saves.

## 2.1 Endpoints in this service

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| CHANGED | GET | product detail, public view, by slug, shop product lists / filter / search | gain `shopLocation` / `shop.location` | should update |
| CHANGED | POST / PUT / PATCH | create / update product, every product response | new `bulky` | should update (seller form) |
| FIX | POST / PUT / PATCH | create / update / publish / delete / restore | a refused request changes nothing | no change |
| FIX | DELETE | `/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}` | readable refusal for a used plan | no change |

---

## 2.2 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Product responses — shop location

| Response | Field added |
|---|---|
| `GET …/products/{productId}/detailed` (owner) | `shopLocation` |
| `GET …/products/{productId}`, by slug (public) | `shopLocation` |
| shop product lists / filter / search (`shop` summary block) | `shop.location` |

Same location object as the shop's, or `null` when the shop has none. **Reference**: §9

```json
"shopLocation": { "lat": -6.7889, "lng": 39.2083, "areaLabel": "Ubungo, Ubungo, Dar es Salaam", "phone": "+255700111222", "…": "…" }
```

### App to do
- [ ] Product page: "Pickup at Ubungo, Ubungo" + Directions; optionally disable Buy for a physical product when `shopLocation` is `null`.

---

## 2.3 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Products — `bulky`

| | Before | After |
|---|---|---|
| Create (`POST`) | — | optional `"bulky": true` (default `false`) |
| Update (`PUT` / `PATCH`) | — | `"bulky"` changes it; leaving it out keeps it |
| Detailed, summary, public responses | — | `"bulky": false \| true` (next to `condition`) |

Shippers that do not carry bulky parcels are left out of its delivery options. The backend also ticks it by itself after
two different shippers report the product too big. **Reference**: §19, §45

### App to do
- [ ] Seller product form: "Bulky / heavy item" switch.

---

## 2.4 <span style="background-color: #20c997; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">FIX</span> Refused product saves / used installment plans

| Before (wrong) | After (correct) |
|---|---|
| update + publish with no image → refused "At least one product image is required", **but the new name / price were saved** | a refused create / update / publish / delete / restore changes nothing |
| deleting an installment plan buyers used → raw database error | "Buyers have already used this plan, so it can't be deleted. Deactivate it instead." |

**Reference**: §27, §71

---

# 3. Cart

**What changed in this service**: the cart now knows about delivery. It keeps **one delivery address** and, per shop with
physical items, **pickup or delivery** with a held price. Each item has a **tick**: checkout buys only ticked items.
Adding, changing or removing items answers with what that did to delivery.

## 3.0 Flow — before and after

**Before**
```mermaid
flowchart LR
    A[Add to cart] --> B[Cart: items only] --> C[Checkout: pick address + DHL placeholder] --> D[Pay the whole cart]
```

**After**
```mermaid
flowchart LR
    A[Add to cart<br/>+ pickup / delivery for that shop] --> B[Cart: one address,<br/>a choice per shop, held prices, ticks]
    B --> C[Checkout: takes the cart's choices,<br/>ticked items only] --> D[Pay: items + delivery]
    D --> E[Bought items leave the cart;<br/>unticked ones stay]
```

**What the user notices**: the cart is grouped by shop, each shop shows "Pickup at the shop" or "Delivery by Dar Cargo ·
TZS 3,500 · Tue–Wed", the total includes delivery, and a change of address re-prices every shop and says what changed.

## 3.1 Endpoints in this service

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| CHANGED | POST | `/e-commerce/cart/add` | optional `fulfillment` + `dropoff`; answers with the delivery change | should update |
| CHANGED | PUT | `/e-commerce/cart/items/{itemId}` | answers with the delivery change | should update |
| CHANGED | DELETE | `/e-commerce/cart/items/{itemId}` | answers with the delivery change | should update |
| CHANGED | GET | `/e-commerce/cart` | gains `destination`, `shops`, ticks, delivery totals | should update |
| CHANGED | DELETE | `/e-commerce/cart/clear` | keeps the delivery address | no change |
| NEW | PUT | `/e-commerce/cart/selection` | tick / untick items | to use |
| FIX | POST | `/e-commerce/cart/add` | two taps at once no longer fail one of them | no change |
| FIX | — | after a paid cart checkout | the bought items now leave the cart | re-read the cart |

> New, documented elsewhere: `PUT /e-commerce/cart/destination`, `POST /e-commerce/cart/destination/change-back`,
> `PUT /e-commerce/cart/shops/{shopId}/fulfillment` → shipping doc §B.

---

## 3.2 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Add to cart / change / remove an item

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-commerce/cart/add` · <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-commerce/cart/items/{itemId}` · <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/e-commerce/cart/items/{itemId}`

| | |
|---|---|
| **Change** | CHANGED — old bodies still work |
| **Impact** | Should update |
| **Reference** | §32 |

**In one line**: the product sheet can send the shop's pickup / delivery choice, and every cart change says what it did to delivery.

### Request (add only)
| Field | Before | After | Note |
|---|---|---|---|
| `productId`, `quantity` | required | unchanged | |
| `fulfillment` | — | **added**, optional: `{ type: PICKUP \| DELIVERY, quotationId, optionId }` | the choice from the product sheet |
| `dropoff` | — | **added**, optional | same shape as checkout; only when the buyer changed the address in the sheet — it becomes the cart's address |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{
  "productId": "5f0e…",
  "quantity": 2
}
```

</td><td>

```json
{
  "productId": "5f0e…",
  "quantity": 2,
  "fulfillment": { "type": "DELIVERY", "quotationId": "3c1f…", "optionId": "o-1" },
  "dropoff": { "addressId": "9b1d…" }
}
```

</td></tr>
</table>

### Response (add, change, remove)
| Field | Before | After | Note |
|---|---|---|---|
| `data` | `null` | **the delivery change** | `{ deliveryBefore, deliveryAfter, switchedToPickup, canChangeBack, message, changes }` |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{ "success": true,
  "message": "Product added to cart successfully",
  "data": null }
```

</td><td>

```json
{ "success": true,
  "message": "Product added to cart successfully",
  "data": { "deliveryBefore": 10500, "deliveryAfter": 13000,
            "switchedToPickup": 1, "canChangeBack": false,
            "message": "Delivery 10,500 → 13,000, 1 shop switched to pickup",
            "changes": [ { "shopId": "cfb6…", "shopName": "My Shop",
                           "before": { "type": "DELIVERY", "businessName": "Dar Cargo", "total": 3500 },
                           "after":  { "type": "PICKUP", "businessName": null, "total": 0 },
                           "message": "…" } ] } }
```

</td></tr>
</table>

### Behaviour
- A sheet quotation that no longer fits (other items, other address, expired) is **not an error**: the item is added and
  the shop falls back to the cart rules.
- **FIX**: two add-to-cart taps at the same moment for a new product used to fail one of them; now both succeed.

### App to do
- [ ] Send `fulfillment` (and `dropoff` when changed) from the product sheet.
- [ ] Show `data.message` after add / change / remove.

---

## 3.3 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Get the cart

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-commerce/cart`

| | |
|---|---|
| **Change** | CHANGED — fields added; `cartItems` and `totalAmount` unchanged (items only) |
| **Impact** | Should update |
| **Reference** | §32, §48, §49, §65, §68 |

### Response
| Field | Before | After | Note |
|---|---|---|---|
| `destination` | — | **added** | the cart's delivery place (pin, `areaLabel`, label) or `null` |
| `shops[]` | — | **added** | one per shop with **ticked** physical items (below) |
| `cartItems[].selected` | — | **added** | the tick |
| `cartSummary.deliveryTotal` | — | **added** | delivery of ticked items, **what the buyer pays** |
| `cartSummary.totalWithDelivery` | — | **added** | |
| `cartSummary.selectedItems`, `selectedQuantity`, `selectedAmount` | — | **added** | ticked items only |
| `cartSummary.selectedTotal` | — | **added** | ticked items + their delivery — **the amount to pay** |

`shops[]`:
```json
{ "shopId": "cfb6…", "shopName": "My Shop", "itemIds": ["…"],
  "type": "DELIVERY", "chosenBy": "BUYER",
  "quotationId": "3c1f…",
  "selected": { "optionId": "o-1", "businessId": "c4bc…", "name": "Dar Cargo", "logoUrl": "…",
                "reason": "BUYER_CHOICE", "cost": { "total": 7000 }, "eta": { "…": "…" },
                "offer": { "listPrice": 7000, "price": 7000, "buyerPays": 3500, "shares": [ … ] } },
  "pricesHeldUntil": "2026-10-25T14:30:00+03:00",
  "notice": { "code": "NO_ADDRESS", "message": "Add a delivery address to see delivery prices" },
  "nudge": { "campaignId": "…", "funder": "SELLER", "kind": "FREE", "value": null, "addMore": 4995.05,
             "message": "Add 4,995 more from My Shop for free delivery" } }
```

### Behaviour
- Reading the cart refreshes expired or outdated quotations.
- No address → pickup + "Add a delivery address…"; an address nobody covers → pickup + "Delivery is not available to X yet".
- A pickup the **buyer** chose never changes by itself; a pickup set by default turns into delivery once a shipper covers.
- `chosenBy: "BUYER"` ↔ `selected.reason: "BUYER_CHOICE"` now always agree. A shipper picked on an earlier cart shows with
  the option label **`USED_BEFORE`** (not selected). (§49)
- `selected.offer` (delivery campaigns, campaigns doc): show `buyerPays`, not `cost.total`. `deliveryTotal` and the change
  messages count what the buyer pays.
- `nudge` appears only while a campaign's minimum order is not reached; it follows the ticked items.
- New items come in ticked; adding more of an unticked item ticks it again.
- `DELETE /cart/clear` keeps `destination`.
- **FIX**: after a paid cart checkout the bought quantities now leave the cart (before: nothing was removed). Units added
  after the checkout stay; unticked items stay.

### App to do
- [ ] Group the cart by `shops`; show each shop's choice, `notice`, `nudge`, and "Used before" for `USED_BEFORE`.
- [ ] A checkbox per item, per shop and "select all" (§3.4); the pay button shows `cartSummary.selectedTotal`.
- [ ] After a cart payment, re-read the cart instead of clearing it locally.

---

## 3.4 <span style="background-color: #6f42c1; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">NEW</span> Tick / untick cart items

**Purpose**: choose which cart items the next checkout buys.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-commerce/cart/selection`

**Access Level**: 🔒 Protected (cart owner)

**Authentication**: Bearer token

**Request JSON Sample**:
```json
{ "itemIds": ["a1b2…", "c3d4…"], "selected": false }
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| itemIds | array of uuid | Yes | one item, a whole shop's items, or every item | items of my cart |
| selected | boolean | Yes | tick (`true`) or untick (`false`) | |

**Success Response**: the same body as `GET /e-commerce/cart` (§3.3).

**Errors**: unknown item → 404 "Cart item not found".

**Rules**: cart checkout (`REGULAR_CART`) takes ticked items only; nothing ticked → "Tick at least one item to check out."
A shop with nothing ticked leaves `shops` and keeps its pickup / delivery choice for when it is ticked again. **Reference**: §48

**App to do**:
- [ ] Checkbox per item, per shop, "select all" → one call each.

---

# 4. Checkout & payment

**What changed in this service**: every shop with physical items in a checkout is **PICKUP** or **DELIVERY**. The fake
address and DHL method are gone; `fulfillment` replaces them. Delivery has a real price from a real shipper, held from the
quotation; if the price ran out and changed, checkout refuses instead of charging a new price silently. Group purchases and
"ships now" installments can now be delivered. The payment response finally separates the seller's money from delivery.

## 4.0 Flow — before and after

**Before**
```mermaid
flowchart LR
    A[Create session<br/>shippingAddressId + shippingMethodId: fake] --> B[Shipping = 0] --> C[Pay: one amount]
```

**After**
```mermaid
flowchart LR
    A[Create session<br/>fulfillment: per shop PICKUP / DELIVERY<br/>+ dropoff + chosen option] --> B{Held price still valid?}
    B -- yes --> C[Pay: items part + delivery part per shop]
    B -- changed / gone / campaign ended --> D[409 with before → after<br/>re-quote, PATCH, pay]
    D --> C
```

**What the user notices**: the buyer picks pickup or delivery per shop, an address, who receives, a note; the total
includes delivery; if a price moved they see "was X, now Y" and confirm.

## 4.1 Endpoints in this service

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| BREAKING | POST | `/e-commerce/checkout-sessions` | `fulfillment` replaces `shippingAddressId` / `shippingMethodId` | must update |
| BREAKING | PATCH | `/e-commerce/checkout-sessions/{id}` | same | must update |
| BREAKING | GET | checkout session response | `fulfillment` replaces `shippingAddress` / `shippingMethod`; real delivery in `pricing` | must update |
| CHANGED | POST | `/e-commerce/checkout-sessions/{id}/process-payment`, `/retry-payment` | new refusals; `deliveryAmount` + `parts` | should update |
| FIX | POST | same | `sellerAmount` no longer includes the shipper's money | check |

---

## 4.2 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Create / update a checkout session — `fulfillment`

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-commerce/checkout-sessions` · <span style="background-color: #17a2b8; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `{base_url}/e-commerce/checkout-sessions/{id}`

| | |
|---|---|
| **Change** | BREAKING — two fields removed; `fulfillment` required when the session has physical items |
| **Impact** | Must update before the deploy |
| **Reference** | §11c, §33, §55, §56 |

**In one line**: tell the backend, per shop, pickup or delivery — and where, who receives, and which shipper option.

### Request
| Field | Before | After | Note |
|---|---|---|---|
| `shippingAddressId` | fake: any uuid gave "John Doe, 123 Main Street" | **removed** | |
| `shippingMethodId` | fake: always "DHL, Standard Shipping, 0" | **removed** | |
| `fulfillment.byShop` | — | **added** | `shopId → PICKUP \| DELIVERY`, one per shop with physical items |
| `fulfillment.deliveries` | — | **added** | `shopId → { quotationId, optionId }` — the option from the sheet / cart; missing → today's default shipper |
| `fulfillment.dropoff` | — | **added** | required if any shop is DELIVERY — exactly one of `addressId` / `recentId` / `lat`+`lng` (+ `saveAs`, `customLabel`, `directions`) |
| `fulfillment.receiver` | — | **added**, optional | `{ name, phone }`; omit = me |
| `fulfillment.note` | — | **added**, optional | this order only, max 300 |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{
  "sessionType": "REGULAR_DIRECTLY",
  "items": [ { "productId": "5f0e…", "quantity": 1 } ],
  "shippingAddressId": "1111…",
  "shippingMethodId": "2222…"
}
```

</td><td>

```json
{
  "sessionType": "REGULAR_DIRECTLY",
  "items": [ { "productId": "5f0e…", "quantity": 1 } ],
  "fulfillment": {
    "byShop": { "cfb6…": "DELIVERY" },
    "deliveries": { "cfb6…": { "quotationId": "3c1f…", "optionId": "o-1" } },
    "dropoff": { "addressId": "9b1d…", "directions": "Blue gate" },
    "receiver": { "name": "Mama Json", "phone": "0712345678" },
    "note": "Call when you arrive"
  }
}
```

</td></tr>
</table>

### Behaviour
- Digital-only checkouts need no `fulfillment`.
- **Cart checkout** (`REGULAR_CART`) may send **no** `fulfillment`: pickup / delivery, the address and the held shipper come
  from the cart. Sending `fulfillment.dropoff` overrides the cart's address. Only ticked items are bought.
- **Group purchase** accepts DELIVERY (same shape). The group's member totals count items + tax only; each member's delivery
  is on their own session / order. (§55)
- **Installment**: a plan that ships after the down payment (`IMMEDIATE`) accepts DELIVERY — `pricing` = down payment + the
  whole delivery. A layaway plan (`AFTER_PAYMENT`) refuses delivery at checkout: `LAYAWAY_DELIVERY_LATER`. (§56)
- A new pin becomes a recent place (and a saved / recent one is marked used) only **after payment**; `saveAs` saves the
  address at once.
- The fulfillment is **frozen** when the session is created — later edits to the shop location or the address never change it.

### Errors
| Code | Status | When | Before / After |
|---|---|---|---|
| `FULFILLMENT_REQUIRED` | 409 | a shop with physical items has no PICKUP / DELIVERY choice | NEW |
| `DROPOFF_REQUIRED` | 409 | a shop is DELIVERY but no `dropoff` | NEW |
| `DROPOFF_INVALID` | 409 | not exactly one of addressId / recentId / pin, or it isn't yours | NEW |
| `RECEIVER_INCOMPLETE` | 409 | receiver name without phone, or the reverse, or a bad phone | NEW |
| `DELIVERY_NOT_AVAILABLE` | 409 | DELIVERY to an address no shipper covers | NEW |
| `SHOP_LOCATION_REQUIRED` | 409 | a physical product from a shop with no location (any session type) | NEW |
| `SHOP_CLOSED` | 409 | an item from a shop paused with STOP_ORDERS, suspended or closed; `details` `{ shopId, shopName, reopensAt }` | NEW — before, checkout never checked the shop |
| `DELIVERY_PRICE_CHANGED` / `DELIVERY_OPTION_GONE` | 409 | the held price ran out and changed / the shipper can't take it now; `details` `{ shopId, businessId, businessName, before, after }` | NEW |
| `LAYAWAY_DELIVERY_LATER` | 409 | DELIVERY on a layaway installment | NEW |
| `OUTSIDE_COVERAGE`, `LABEL_ALREADY_USED`, `ADDRESS_LIMIT_REACHED` | 409 | from a `saveAs` pin | NEW |

### App to do
- [ ] Per shop "Pickup at shop / Delivery"; when any delivery: saved address / recent / new pin (+ "Save as"), receiver
  "Me / Someone else", optional note.
- [ ] Send `fulfillment` (with `deliveries` from the sheet / cart); for cart checkout it can be left out.
- [ ] Stop sending `shippingAddressId` / `shippingMethodId`.
- [ ] On `DELIVERY_PRICE_CHANGED` / `DELIVERY_OPTION_GONE`: show before → after, get a fresh quotation (sheet: ask again;
  cart: re-read the cart), retry.

---

## 4.3 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Checkout session response — `fulfillment` and real delivery prices

**Endpoint**: the checkout session (create, get, patch).

| | |
|---|---|
| **Change** | BREAKING — two fake blocks removed |
| **Impact** | Must update |
| **Reference** | §1, §11c, §33, §53, §65 |

### Response
| Field | Before | After | Note |
|---|---|---|---|
| `shippingAddress` | fake John Doe block | **removed** | |
| `shippingMethod` | fake DHL block | **removed** | |
| `fulfillment` | — | **added** | `shopId → that shop's fulfillment` (below) |
| `pricing.shippingByShop` | — | **added** | `shopId → delivery`, DELIVERY shops only, **what the buyer pays** |
| `pricing.shippingCost`, `total` | shipping always 0 | real | sum of `shippingByShop` |

The fulfillment object (same on the session, the order and the installment agreement):
```json
{ "type": "DELIVERY",
  "pickup":  { "source": "SHOP_LOCATION", "label": "Yoyo Test Shop", "lat": -6.7889, "lng": 39.2083,
               "areaLabel": "Ubungo, Ubungo, Dar es Salaam", "phone": "+255700111222", "…": "…" },
  "dropoff": { "source": "SAVED_ADDRESS", "label": "Home", "lat": -6.77, "lng": 39.25,
               "areaLabel": "Mikocheni, Kinondoni, Dar es Salaam", "directions": "Blue gate", "…": "…" },
  "receiver": { "name": "Mama Json", "phone": "+255712345678" },
  "note": "Call when you arrive",
  "delivery": { "quotationId": "3c1f…", "optionId": "o-1", "businessId": "c4bc…", "businessName": "Dar Cargo",
                "total": 7000, "minMinutes": 120, "maxMinutes": 240, "arrivalFrom": "…", "arrivalTo": "…",
                "pricesHeldUntil": "…", "route": null,
                "offer": { "listPrice": 7000, "price": 7000, "buyerPays": 3500, "shares": [ … ] } } }
```
- `dropoff` and `delivery` are absent for PICKUP. `delivery.total` is what the **shipper** is paid; `offer` shows who paid
  the rest (campaigns). `route` is set for station routes (shipping doc §1.8).

---

## 4.4 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Process / retry payment

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-commerce/checkout-sessions/{id}/process-payment` · `/retry-payment`

| | |
|---|---|
| **Change** | CHANGED + FIX |
| **Impact** | Should update |
| **Reference** | §33, §34, §47, §65, §67 |

### Response
| Field | Before | After | Note |
|---|---|---|---|
| `sellerAmount` | every held part added together (seller + shipper) | **the seller's share only** | FIX |
| `deliveryAmount` | — | **added** | the delivery parts' full value |
| `parts` | — | **added** | `[{ escrowId, escrowNumber, part: ITEMS \| LEG, orderId, amount }]` — one per held part, every shop's order |
| `escrowId`, `escrowNumber`, `orderId`, `amountPaid` | | unchanged | |

<table>
<tr><th>Before (wrong)</th><th>After (correct)</th></tr>
<tr><td>

```json
{ "escrowId": "e1…",
  "amountPaid": 16000.99,
  "sellerAmount": 16000.99 }
```

</td><td>

```json
{ "escrowId": "e1…",
  "amountPaid": 16000.99,
  "sellerAmount": 1000.99,
  "deliveryAmount": 15000,
  "parts": [ { "part": "ITEMS", "amount": 1000.99, "orderId": "…", "…": "…" },
             { "part": "LEG",   "amount": 15000,   "orderId": "…", "…": "…" } ] }
```

</td></tr>
</table>

### Behaviour
- The money is held per shop in parts: **ITEMS** (the seller; platform fee on items + tax only) and **LEG** (the shipper,
  one per leg). A free delivery has no LEG part.
- Buyer transaction history for a split payment reads "N parts" instead of "N sellers".

### Errors
| Code | Status | When | Before / After |
|---|---|---|---|
| `DELIVERY_OPTION_GONE` | 409 | the chosen shipper stopped taking jobs after the session was made — the session stays open: change the shipper with `PATCH`, pay | NEW |
| `CAMPAIGN_ENDED` | 409 | a delivery campaign ended / ran out since checkout; `details` `{ shopId, businessId, businessName, before, after }`. Nothing charged: `PATCH` the session (same pick) for today's price, then pay | NEW |
| `DELIVERY_PRICE_CHANGED` | 409 | now also when only the buyer's part changed | CHANGED |

### App to do
- [ ] Show items from `sellerAmount` and delivery from `deliveryAmount`; list each shop's order from `parts`.
- [ ] Handle `CAMPAIGN_ENDED` like `DELIVERY_PRICE_CHANGED` (before → after, PATCH, pay).

---

# 5. Orders

**What changed in this service**: every physical order has a **fulfillment** — pickup or delivery — with its own status
ladder, steps and times. Pickup orders and orders a shipper delivers no longer use the old ship / confirm-delivery /
regenerate-code endpoints: they use the shipping order actions (shipping doc §C). The delivery code is 4 digits, comes by
its own SMS and lasts 24 h. A buyer's cancel follows clearer rules.

## 5.0 Flow — before and after

**Before**
```mermaid
flowchart LR
    A[Paid] --> B[Seller: /ship<br/>fake tracking number] --> C[Buyer types 6-digit code:<br/>/confirm-delivery] --> D[Money to seller]
```

**After — pickup**
```mermaid
flowchart LR
    A[PLACED] --> B[Seller accepts<br/>ACCEPTED] --> C[Packed & ready<br/>READY + pickup code to buyer] --> D[Seller enters buyer's code<br/>COLLECTED] --> E[Buyer: It's what I ordered<br/>COMPLETED, money released]
```

**After — delivery**
```mermaid
flowchart LR
    A[PLACED] --> B[ACCEPTED] --> C[READY<br/>job offer to shipper] --> D[SHIPPER_ACCEPTED] --> E[HEADING_TO_SHOP] --> F[Hand-over at the shop<br/>ON_THE_WAY, code to receiver] --> G[Rider enters code<br/>DELIVERED] --> H[Buyer confirms<br/>COMPLETED: seller + shipper paid]
```

**What the user notices**: order screens show where the parcel is, the shipper's name and phone, "Code sent by SMS · valid
until …", and buttons that depend on the step (Accept, Ready, Hand over, Collect, "It's what I ordered", Report a problem).

## 5.1 Endpoints in this service

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| BREAKING | GET | `/e-commerce/orders/{orderId}` (buyer + seller) | `deliveryAddress` / `trackingNumber` / `carrier` → `fulfillment` | must update |
| CHANGED | GET | order lists (buyer `/my-orders…`, shop `/shop/{shopId}/orders…`) | gain `fulfillmentType`, `fulfillmentStatus` | should update |
| BREAKING | POST | `/orders/{orderId}/ship`, `/confirm-delivery`, `/regenerate-code` | refused for pickup orders and shipper orders; 4-digit code | must update |
| CHANGED | POST | `/orders/{orderId}/regenerate-code` | "I lost my code": new fields, code shown once | should update |
| CHANGED | POST | `/orders/{orderId}/cancel` | pickup / shipper orders follow the new cancel rules; installments refused | should update |
| CHANGED | — | `shippingFee`, `totalAmount` on an order | can change after payment | check |

> New, documented elsewhere: every pickup / delivery order action under `/shipping/orders/{orderId}/…` (accept, ready,
> pickup-time, code, hand-over, hand-over-rider, collect, confirm, report-problem, delivery-options, switch-to-delivery,
> change-destination, switch-to-pickup, delivery-decision, cancel) → shipping doc §C.

---

## 5.2 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Order detail and lists — `fulfillment`

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-commerce/orders/{orderId}` and the order lists

| | |
|---|---|
| **Change** | BREAKING — three fields removed |
| **Impact** | Must update |
| **Reference** | §11c, §24, §26, §37–§41, §45, §53, §59, §63 |

### Response
| Field | Before | After | Note |
|---|---|---|---|
| `deliveryAddress`, `trackingNumber`, `carrier` | text / fake | **removed** | |
| `fulfillment` | — | **added** (detail) | the object from §4.3 plus status, steps and times (below) |
| `fulfillmentType` | — | **added** (lists) | `PICKUP` / `DELIVERY`, null for digital |
| `fulfillmentStatus` | — | **added** (lists) | |
| `productOrderStatus` | | kept | still works; for pickup / shipper orders it follows the fulfillment steps |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{ "orderNumber": "ORD-2026-94070",
  "productOrderStatus": "SHIPPED",
  "deliveryAddress": "123 Main Street",
  "trackingNumber": "TRACK-8F2A",
  "carrier": "DHL" }
```

</td><td>

```json
{ "orderNumber": "ORD-2026-94070",
  "productOrderStatus": "SHIPPED",
  "fulfillment": {
    "type": "DELIVERY", "status": "ON_THE_WAY",
    "parcelNumber": "NX-48213",
    "placedAt": "…", "acceptedAt": "…", "readyAt": "…", "pickedUpAt": "…",
    "code": { "status": "ACTIVE", "expiresAt": "2026-10-26T09:12:00" },
    "shippers": [ { "leg": 1, "name": "Dar Cargo", "phone": "+255745051250" } ],
    "delivery": { "businessName": "Dar Cargo", "total": 7000, "…": "…" },
    "decision": null, "needsShipperSince": null, "…": "…" } }
```

</td></tr>
</table>

### `fulfillment` keys added beyond §4.3
| Key | Meaning |
|---|---|
| `status` | pickup: `PLACED → ACCEPTED → READY → COLLECTED → COMPLETED`; delivery: `PLACED → ACCEPTED → READY → SHIPPER_ACCEPTED → HEADING_TO_SHOP → ON_THE_WAY → (AT_STATION) → DELIVERED → COMPLETED`; or `CANCELLED` / `EXPIRED` |
| `parcelNumber`, `pickupTime` | parcel number from Accept; buyer's planned pickup time |
| `placedAt`, `acceptedAt`, `readyAt`, `collectedAt`, `headingAt`, `pickedUpAt`, `deliveredAt`, `completedAt`, `closedAt` | step times, absent until set |
| `acceptBy`, `collectBy` | deadlines (24 opening hours to accept; 14 days to collect) |
| `closeReason` | e.g. `NOT_ACCEPTED_IN_TIME`, `NOT_COLLECTED_IN_TIME`, `ADMIN_REFUNDED`, `ADMIN_CANCELLED` |
| `code` | `{ status: ACTIVE \| USED \| LOCKED \| EXPIRED \| REPLACED, expiresAt }` — detail only, never the code itself |
| `packedConfirmedAt`, `labelConfirmedAt` | the seller's Ready ticks |
| `needsShipperSince` | the shipper said no and a new one is being found |
| `decision` | the buyer must choose (new shipper costs more, nobody found, …) — shipping doc §1.11 |
| `refusal` | the latest shipper refusal at the shop (`TOO_BIG` / `BADLY_PACKED`) |
| `shippers` | one per leg whose shipper accepted: `{ leg, businessId, name, phone }` (not for Bolt) |
| `rider` | Bolt only: `driverName`, `driverPhone`, `driverPicture`, `plate`, `vehicleModel`, `trackingUrl`, `etaToShopMinutes`, `state` |

### Behaviour
- Existing orders were copied over: PENDING_SHIPMENT → `PLACED`, SHIPPED / DELIVERED / DISPUTED → `READY`, COMPLETED →
  `COMPLETED`, CANCELLED / REFUNDED → `CANCELLED`.
- Automatic closing: a pickup or delivery order not accepted within **24 opening hours** is cancelled; a pickup not
  collected **14 days** after Ready becomes `EXPIRED`. Both refund in full; the order shows `CANCELLED` with no `cancelledBy`.
- **`shippingFee` and `totalAmount` can change after payment** (another shipper at another price; 0 after switching to
  pickup). `shippingFee` is what the buyer paid for delivery.
- For now the seller sees the full drop-off (pin, directions, receiver phone).

### App to do
- [ ] Read `fulfillment` instead of `deliveryAddress` / `trackingNumber` / `carrier`; pickup orders show the shop's place + Directions.
- [ ] Show `fulfillment.status` and the step times; lists show the `fulfillmentType` badge and `fulfillmentStatus`.
- [ ] A Call button per entry in `shippers`; a rider card (photo, plate, Track) for Bolt.

---

## 5.3 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Cancel an order

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-commerce/orders/{orderId}/cancel`

| | |
|---|---|
| **Change** | CHANGED — rules for pickup orders and orders a shipper delivers |
| **Impact** | Should update |
| **Reference** | §25, §34, §43, §69 |

| Who | Before | After (pickup orders and shipper orders) |
|---|---|---|
| Buyer | any time before shipped | only before the seller accepts (`PLACED`), or while the delivery has no shipper (`needsShipperSince` / a `decision` waiting) |
| Seller | any time before delivered | pickup: until collected; delivery: only before Ready. Body `{ "informedBuyer": true, "reason"? }` |
| Refund | the order's single escrow | **every** part (items + delivery), stock back |

Old orders without a shipper keep the old rules.

### Errors
| Code | Status | When | Before / After |
|---|---|---|---|
| `CANCEL_NOT_ALLOWED` | 409 | after those windows ("You can switch to pickup instead" / "Contact support to cancel") | NEW (was `FULFILLMENT_STATE`) |
| `INFORM_BUYER_FIRST` | 409 | seller without `informedBuyer: true` | NEW |
| `INSTALLMENT_ORDER` | 409 | an installment order — cancel the agreement instead | NEW |

### App to do
- [ ] Buyer: show Cancel only while `PLACED` or while "We're finding a new shipper" / a decision shows; otherwise "Collect it myself instead".
- [ ] Seller: hide Cancel on delivery orders from Ready on; send `informedBuyer`.
- [ ] Installment order: the Cancel button calls the agreement cancel (§6.2).

---

## 5.4 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Old ship / confirm-delivery / regenerate-code

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-commerce/orders/{orderId}/ship` · `/confirm-delivery` · `/regenerate-code`

| | |
|---|---|
| **Change** | BREAKING — refused for most new orders |
| **Impact** | Must update |
| **Reference** | §25, §40 |

| Order | Before | After |
|---|---|---|
| Pickup order | seller ships, buyer confirms with the code | **409 `USE_PICKUP_FLOW`** — use the shipping order actions |
| Order a shipper delivers | — | **409 `SHIPPER_DELIVERS`** |
| Old order without a shipper | | unchanged (old flow) |

`POST /ship` no longer takes or returns `trackingNumber` (it was a fake "TRACK-…").

### App to do
- [ ] For pickup and shipper orders, stop calling these three; use the buttons from shipping doc §C.

---

## 5.5 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Delivery code — 4 digits, own SMS, 24 h, "I lost my code"

**Endpoint**: `/orders/{orderId}/ship`, `/confirm-delivery`, `/regenerate-code` (old orders) — and the same code rules for
the shipping `…/code` action.

| | |
|---|---|
| **Change** | BREAKING |
| **Impact** | Must update |
| **Reference** | §20, §26 |

| | Before | After |
|---|---|---|
| Length | 6 digits | **4 digits** (`confirmationCode` must match `^[0-9]{4}$`) |
| Lifetime | 30 days | **24 hours**; a new code kills the old one; max **5 per order per 24 h** |
| Delivery of the code | inside the "Order shipped" email / SMS / push | its **own SMS** to the receiver; the bell says "Your delivery code was sent by SMS", never the code |
| Wrong code | HTTP 400 with prose | 409 coded refusal, `details.triesLeft` |
| Old codes | | gone — a buyer with a SHIPPED order taps "I lost my code" |

`POST /regenerate-code` response — fields added (old ones stay):

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{ "orderId": "…",
  "orderNumber": "ORD-2026-94070",
  "codeSent": true,
  "destination": "email",
  "codeExpiresAt": null,
  "maxAttempts": 5 }
```

</td><td>

```json
{ "orderId": "…",
  "orderNumber": "ORD-2026-94070",
  "codeSent": true,
  "destination": "sms",
  "codeExpiresAt": "2026-10-18T05:03:05",
  "maxAttempts": 5,
  "message": "New code sent by SMS to +255*****2944",
  "code": "4821",
  "sentTo": "+255*****2944" }
```

</td></tr>
</table>

`code` is shown **once**, on that screen; it is never stored readable, so it cannot be shown again.

### Errors
| Code | Status | When | Before / After |
|---|---|---|---|
| `CODE_WRONG` | 409 | wrong code; `details.triesLeft` | NEW (was 400) |
| `CODE_LOCKED` | 409 | after 5 wrong tries — even the right code, until a new one is asked for | NEW |
| `CODE_NOT_ACTIVE` | 409 | no active code, or already used | NEW |
| `CODE_EXPIRED` | 409 | older than 24 h | NEW |
| `CODE_LIMIT` | 409 | more than 5 new codes in 24 h | NEW |

### App to do
- [ ] 4-digit input; show `triesLeft`; on `CODE_LOCKED` / `CODE_EXPIRED` offer "I lost my code".
- [ ] Order screen: "Code sent by SMS to +255•••2944 · valid until …" from `fulfillment.code`.
- [ ] "I lost my code" → show `code` big, once. Never cache it.

---

# 6. Installments

**What changed in this service**: the agreement carries the fulfillment like an order; "ships now" plans can be delivered;
installment orders are cancelled only through their agreement; and two money bugs are fixed.

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| CHANGED | GET | installment agreement | `shippingAddress` → `fulfillment` | should update |
| CHANGED | POST | `/e-commerce/installments/agreements/{id}/cancel` | also cancels the order and refunds every part | no change |
| FIX | POST | `/installments/agreements/{id}/payments/{paymentId}/pay` | instalments after the down payment can be paid | no change |
| FIX | GET / POST | `/installments/agreements/{id}/early-payoff` | no "/ by zero" on 0 % plans | no change |
| FIX | — | confirming delivery | releases every instalment held so far to the seller | no change |

## 6.1 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Agreement — `fulfillment`, layaway, cancel

| Field / rule | Before | After |
|---|---|---|
| `shippingAddress` | always empty | **removed** |
| `fulfillment` | — | **added** (same object as orders) |
| Layaway (`AFTER_PAYMENT`) | | refuses delivery at checkout (`LAYAWAY_DELIVERY_LATER`); when paid off the order is pickup and the buyer may choose delivery from the order |
| `INSTALLMENT_AGREEMENT_COMPLETED` | | gains `nextStep` ("Collect it at the shop, or choose delivery in your order." for layaway) |
| Agreement cancel | | when the order exists it is cancelled too and **everything** refunded (down payment, delivery, instalments) — refused once the parcel left the shop |
| Order cancel on an installment order | allowed (left the agreement ACTIVE) | refused `INSTALLMENT_ORDER` everywhere |

**Reference**: §17, §56, §69

## 6.2 <span style="background-color: #20c997; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">FIX</span> Installment money

| Before (wrong) | After (correct) |
|---|---|
| every instalment after the down payment failed to pay (database error) | pays normally |
| confirming delivery released only the down payment + delivery; held instalments stayed held for good | every instalment held so far is released; one paid after confirmation goes to the seller at once |
| early payoff on a 0 % plan crashed "/ by zero" | works |
| a "ships now" plan sometimes never got its order | always gets it |

**Reference**: §56, §57, §70, §71

### App to do
- [ ] Offer pickup / delivery in the installment sheet for "ships now" plans; layaway: "choose delivery after the last payment".
- [ ] Installment order: Cancel = `POST /e-commerce/installments/agreements/{agreementId}/cancel`.

---

# 7. Group purchase

## 7.1 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Group checkout with delivery

| Rule | Before | After |
|---|---|---|
| `GROUP_PURCHASE` with DELIVERY | not possible | accepted — `byShop`, `deliveries`, `dropoff` like a direct buy |
| Member totals (`totalPaid`, `amountPaid`) | | items + tax only — the same for every member; each member's delivery is on their own session / order |
| Leaving (`POST /group-purchases/{id}/withdraw`) / group runs out of time | refund the items | refund **items and delivery** |
| Moving a payment to another group | | keeps its delivery choice |
| Changing the delivery while the group fills | | not possible; once the order exists the usual changes apply |

**Reference**: §55

### App to do
- [ ] The group checkout sheet offers pickup / delivery like a direct buy; the group screen keeps showing the group price.

---

# 8. Wallet & transactions

## 8.1 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Transaction history titles

| Where | Before | After |
|---|---|---|
| Buyer, split payment | "N sellers" | "N parts" |
| Shipper payout | — | "Delivery Earnings" ("Payment received for delivery"); type still `SALE` |
| Extra delivery payment after ordering | — | "Delivery Payment" |
| Money back (cheaper shipper, switch to pickup) | — | "Refund Received" |

Known, not changed: a refund also writes "Sale Refund Issued" (debit) into the payee's history although the payee never
received that money. **Reference**: §34, §40, §41

---

# 9. Events

**What changed in this service**: a venue is a **pin** turned into ward / district / region (like a shop); an in-person
event needs that pin to publish. Donation tickets can have a target and show progress.

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| CHANGED | PATCH | `/e-events/drafts/{draftId}/location` | pin outside Tanzania → `OUTSIDE_COVERAGE` | should update |
| BREAKING | PATCH | `/e-events/{eventId}/publish`, `/e-events/{eventId}/published/reveal-location` | in-person / hybrid needs a pin | must update |
| CHANGED | GET | event responses | `venue` gains `ward`, `areaLabel`, `matchType` | should update |
| CHANGED | POST / PUT / PATCH | ticket create / draft update / published update | optional `donationTarget` | to use |
| CHANGED | GET | ticket details, ticket list, event `tickets` | `donation` progress on donation tickets | should update |

## 9.1 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Venue — area from the pin

**Endpoint**: <span style="background-color: #17a2b8; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `{base_url}/e-events/drafts/{draftId}/location` · event responses

| Field | Before | After |
|---|---|---|
| request `venue` | | unchanged; `venue.coordinates` outside Tanzania's wards → `OUTSIDE_COVERAGE` |
| response `venue.ward`, `venue.areaLabel`, `venue.matchType` | — | **added** (null without a pin) |
| `locationSummary`, share preview, ticket / booking location, event on a post or clip | typed address | the area when no address was typed |

```json
"venue": { "name": "Mlimani City Hall", "address": null, "coordinates": { "latitude": -6.77, "longitude": 39.22 },
           "areaLabel": "Mikocheni, Kinondoni, Dar es Salaam", "matchType": "CONTAINED", "ward": { "…": "…" } }
```

**Reference**: §78

## 9.2 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Publish / reveal location needs a pin

**Endpoint**: <span style="background-color: #17a2b8; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `{base_url}/e-events/{eventId}/publish` · `{base_url}/e-events/{eventId}/published/reveal-location`

| Before | After |
|---|---|
| an `IN_PERSON` / `HYBRID` event published with a venue name only | refused 422 "Drop a pin on the map for the venue" (`details.stage` `LOCATION_DETAILS`) without `coordinates` |

TBA and online events unchanged; drafts can still be saved without a pin.

### App to do
- [ ] Venue step: the same pin screen as the shop location (resolve, show `areaLabel`, Confirm off when not covered);
  the address field stays optional ("directions").

## 9.3 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Donation target and progress

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-events/tickets/{eventId}` · <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-events/tickets/{ticketId}` · <span style="background-color: #17a2b8; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `{base_url}/e-events/tickets/{ticketId}/published`

| | |
|---|---|
| **Change** | CHANGED — optional field in, optional block out |
| **Impact** | To use (the progress bar) |
| **Reference** | §79 |

### Request
| Field | Before | After | Note |
|---|---|---|---|
| `donationTarget` | — | **added**, optional | > 0, at most 10 digits + 2 decimals; **donation tickets only** |

### Response (ticket details, ticket list, `tickets` in the event response)
| Field | Before | After | Note |
|---|---|---|---|
| `donation` | — | **added** | `{ target, raised, donations }` on donation tickets; `null` on others; `target` null when not set |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{ "name": "Support the choir",
  "ticketPricingType": "DONATION",
  "price": 0.00 }
```

</td><td>

```json
{ "name": "Support the choir",
  "ticketPricingType": "DONATION",
  "price": 0.00,
  "donation": { "target": 500000.00,
                "raised": 210000.00,
                "donations": 11 } }
```

</td></tr>
</table>

- `raised` = confirmed donations only (cancelled / refunded bookings do not count). The organiser can set or raise the
  target after publishing.

### Errors
| Message | Status | When |
|---|---|---|
| "A donation target can only be set on a donation ticket" | 422 | `donationTarget` on a paid / free ticket |

### App to do
- [ ] Organiser: optional "Target" field on donation tickets.
- [ ] Event page: progress bar from `donation.raised` / `donation.target` (hide the bar when `target` is null; still show `raised`).

---

# 10. Profile & account

## 10.1 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Profile cover photo

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/profile/update` · <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/profile/me`, `/profile/id/{userId}`, `/profile/u/{username}`

| | |
|---|---|
| **Change** | CHANGED — field in, field out |
| **Impact** | To use |
| **Reference** | §79 |

| Where | Field | Before | After |
|---|---|---|---|
| `PUT /profile/update` | `coverPhotoFileId` | — | **added**, optional — a File Thunder upload with context **`COVER_PHOTO`** |
| profile responses | `coverMedia` | — | **added** — same shape as `profileMedia` (`fileId`, `status`, `variants`, `mimeType`), null when not set |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{ "userName": "joshuasakweli",
  "profileMedia": { "fileId": "…", "status": "READY",
                    "variants": { "thumb": "https://…/thumb.webp" } } }
```

</td><td>

```json
{ "userName": "joshuasakweli",
  "profileMedia": { "fileId": "…", "status": "READY",
                    "variants": { "thumb": "https://…/thumb.webp" } },
  "coverMedia":   { "fileId": "…", "status": "READY",
                    "variants": { "medium": "https://…/medium.webp",
                                  "large": "https://…/large.webp" } } }
```

</td></tr>
</table>

**Errors**: a file uploaded with another context → "File was not uploaded as a cover photo"; someone else's file → 403.

### App to do
- [ ] Profile banner: upload as `COVER_PHOTO`, send `coverPhotoFileId`, show `coverMedia`.

## 10.2 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> New-device sign-in alert

`AUTH_NEW_DEVICE_LOGIN` is now sent by **password, SMS-code and Google** sign-in (was password only) and never for an
account's first device. Its data gains the details the app can show in the bell item:

| Key | Before | After |
|---|---|---|
| `device.name`, `device.platform`, `ipAddress` | yes | unchanged |
| `device.type` | — | `Phone` / `Tablet` / `Computer` |
| `device.browser` | — | e.g. `Chrome`, `Safari`, `NexGate app` |
| `device.os` | — | `Windows`, `macOS`, `iOS`, `Android`, `Linux`, `ChromeOS` |
| `device.location`, `location` | — | e.g. `"Dar es Salaam, TZ"` (`"Unknown location"` when not found) |
| `timestamp` | — | e.g. `"Oct 25, 2026 at 8:15 AM EAT"` |

Browser and system come from the `User-Agent` header — no new request fields. Email, push and SMS now show them.
**Reference**: §79

---

# 11. Interests

## 11.1 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Category management is admin only

| Endpoint | Before | After |
|---|---|---|
| `POST /interests/categories`, `PUT /{categoryId}`, `DELETE /{categoryId}`, `PATCH /{categoryId}/toggle-active`, `GET /{categoryId}/usage` | any signed-in user | **SUPER_ADMIN / STAFF_ADMIN** only (403 for others) |
| `GET /interests/categories/all` | public | unchanged |

**Picking interests (unchanged, for the onboarding screen):** load `GET /interests/categories/all`, then save at least 3 of
those ids with `POST /onboarding/secondary/interests` `{ "interestIds": [ … ] }`. One unknown or switched-off id → 404
"Some categories are inactive or do not exist" and nothing is saved — always send ids from the list you just loaded.
The categories are created by the system at startup. **Reference**: §79

---

# 12. Chat

## 12.1 <span style="background-color: #20c997; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">FIX</span> Chat avatars and group icons are real links

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/chat/conversations` (`display.avatar`) · group responses (`iconMedia`: create, get, `PUT /chat/groups/{conversationId}/icon`)

| Before (wrong) | After (correct) |
|---|---|
| `variants` held storage paths — `"thumb": "messages/4a8c…/thumb.webp"` — which no image can load | `"thumb": "https://…/thumb.webp"` — full links; same object shape |

Applies to groups, people and shops in the chat list. An icon set while its upload was still processing now upgrades to its
final images (it stayed stuck before). A failed icon upload now says "Upload failed" instead of "still in progress".

The chat doc said `avatar` was a plain link string — it is the object `{ fileId, status, mimeType, variants }`.
**Reference**: §80

### App to do
- [ ] Read the image from `display.avatar.variants.thumb` / `iconMedia.variants.thumb` (no prefixing).
- [ ] On `CHAT_GROUP_UPDATED` (§15) refresh the chat list row and the group header.

---

# 13. Digital downloads

## 13.1 <span style="background-color: #20c997; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">FIX</span> Files keep their name and type

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-commerce/digital-files/my-downloads/{accessId}/url` — then the app downloads the returned `url`

| Before (wrong) | After (correct) |
|---|---|
| the file arrived named `original`, type `application/octet-stream` — devices could not open it, and reading it as text corrupted it (and roughly doubled its size) | the link answers `Content-Disposition: attachment; filename="notes pedi.zip"` and the real `Content-Type` (`application/zip`, `video/mp4`, …) |

Response body unchanged (`url`, `expiresInSeconds`). Works for files already uploaded. **Reference**: §80

### App to do
- [ ] Download the `url` as **bytes** and stream it to a file; never read the body as text / string.
- [ ] Use the filename from `Content-Disposition` (or `fileName` from the order downloads list).

---

# 14. Admin dashboard (existing screens)

| Change | Method | Endpoint | One line |
|---|---|---|---|
| NEW | PUT | `/admin/shops/reviews/{reviewId}/status` | moderate a shop review: `{ "status": "HIDDEN", "reason": "…" }` (SUPER_ADMIN, reason required, audit-logged) → `{ reviewId, shopId, status }` |
| CHANGED | GET | fees, current + history | lists a new fee type `DELIVERY_COMMISSION` (PERCENTAGE) |
| CHANGED | GET | `/api/admin/reconciliation/position` | gains `owedToProviders`, `campaignMoney`, `ownerTopUps`; `expectedSelcomFloat` includes them |
| CHANGED | GET | `/api/admin/reconciliation/trial-balance` | new lines `PROVIDER`, `CAMPAIGN_BUDGET`, `CAMPAIGN_POT` |
| CHANGED | — | interest categories | management is admin only (§11) |

**Reference**: §30, §31, §58, §64

> New admin endpoints for shipping, Bolt, campaigns, the operations board, reports and the audit log → shipping doc §K and
> campaigns doc §7–§8.

---

# 15. Notifications

New types are listed so the bell list and push taps can handle them; each opens the screen in its `targetType` /
`targetId`. **Four new targets**: `SHIPPING_JOB`, `SHIPPER_BUSINESS`, `SHIPPING_CAMPAIGN`, `ADMIN_SHIPPING_BOARD` — an app
that does not know a target yet opens the bell list.

| Change | Type | To | When | Opens (`targetType`) | App |
|---|---|---|---|---|---|
| NEW | `HANDOVER_CODE` | receiver | a pickup / delivery code was sent (in-app + SMS; never the code in-app) | `ORDER_TRACKING` | bell item |
| NEW | `ORDER_ACCEPTED` | buyer | seller accepted (parcel number) | `ORDER_TRACKING` | refresh the order |
| NEW | `ORDER_ACCEPT_REMINDER` | seller | 2 h before the accept deadline | `SHOP_ORDER` | |
| NEW | `PICKUP_READY` | buyer | pickup order ready (place, collect-by) | `ORDER_TRACKING` | |
| NEW | `PICKUP_REMINDER` | buyer | not collected: day 7 and 12 | `ORDER_TRACKING` | |
| NEW | `PICKUP_LAST_DAY` | buyer (+ SMS) | 24 h before the collect deadline | `ORDER_TRACKING` | |
| NEW | `SHIPPER_FOUND` | buyer | every shipper accepted / Bolt rider assigned | `ORDER_TRACKING` | refresh |
| NEW | `SHIPPER_CHANGED` | buyer | automatic swap to an equal or cheaper shipper | `ORDER_TRACKING` | refresh |
| NEW | `DELIVERY_DECISION_NEEDED` | buyer | the buyer must choose (costs more, nobody found, …) | `ORDER_TRACKING` | open the decision |
| NEW | `PARCEL_AT_STATION` | buyer | the parcel reached the buyer's station | `ORDER_TRACKING` | |
| NEW | `RIDER_COMING` | seller (+ SMS) | shipper heading to the shop / Bolt rider (plate to check) | `SHOP_ORDER` | |
| NEW | `PARCEL_REFUSED` | seller | badly packed / Bolt left without the parcel | `SHOP_ORDER` | refresh |
| NEW | `JOB_OFFER` | shipper | a new job offer | `SHIPPING_JOB` | |
| NEW | `JOB_CANCELLED` | shipper | an offer or accepted job was taken away | `SHIPPING_JOB` | refresh |
| NEW | `DELIVERY_EARNINGS` | shipper | delivery money released | `TRANSACTION` | refresh |
| NEW | `SHIPPER_NEW_REVIEW` | shipper | new review | `SHIPPER_BUSINESS` | |
| NEW | `SHIPPER_REVIEW_REPLY` | reviewer | the shipper replied (in-app) | `SHIPPER_BUSINESS` | |
| NEW | `SHIPPER_STANDING` | shipper owner | warning / automatic pause / suspension | `SHIPPER_BUSINESS` | banner |
| NEW | `SHIPPER_DOCUMENT_EXPIRING` | shipper owner | a KYC document expires in 14 / 3 days | `SHIPPER_BUSINESS` | |
| NEW | `SHIPPING_CAMPAIGN_RAN_OUT` | campaign runner | a campaign ran out | `SHIPPING_CAMPAIGN` | |
| NEW | `SHIPPING_CAMPAIGN_ENDED` | campaign runner | a campaign ended (what it paid) | `SHIPPING_CAMPAIGN` | |
| NEW | `SHIPPING_ADMIN_DIGEST` | staff + super admins | 07:30 when anything is stuck | `ADMIN_SHIPPING_BOARD` | admin app |
| NEW | `CHAT_GROUP_UPDATED` | other group members (in-app only) | the group photo changed; data `conversationId`, `group.name`, `change: "ICON"` | `CONVERSATION` | refresh the chat row + header |
| CHANGED | `ORDER_AWAITING_CONFIRM` | buyer | now sent **when a delivery is delivered**: "tap Received so the seller gets paid" | `ORDER` | |
| CHANGED | `ORDER_DELIVERED` | was: buyer at completion → now: **seller at delivery** ("You are paid when they confirm") | | `SHOP_ORDER` | move the handler |
| CHANGED | `ORDER_SHIPPED` | buyer | no carrier, tracking number or code; no SMS | `ORDER_TRACKING` | |
| CHANGED | `AUTH_NEW_DEVICE_LOGIN` | user | all sign-in methods; new data keys (§10.2) | `ACTIVE_SESSIONS` | show the details |
| CHANGED | `INSTALLMENT_AGREEMENT_COMPLETED` | buyer | gains `nextStep` for layaway | `AGREEMENT` | |
| FIX | `ESCROW_RELEASED_TO_SELLER` | seller | was blank (" released to your wallet / From order "); now amount + order number | `TRANSACTION` | |
| FIX | `ESCROW_REFUNDED`, `ORDER_CANCELLED` | buyer | blank amount / order number / reason filled | | |
| FIX | `DIGITAL_PRODUCT_READY` | buyer | said only " is ready"; now the product name ("Notes Pedi and 2 more") and days the link lasts; sent only after access exists | `DOWNLOAD` | |
| FIX | order "delivered" to the buyer | buyer | crashed when the buyer had no email | | |

---

# 16. Error codes index

All HTTP 409, `data.code`; some carry `data.details`.

| Code | Change | Service | Endpoint(s) | Meaning |
|---|---|---|---|---|
| `SHOP_LOCATION_REQUIRED` | NEW | Checkout | create session | a physical product from a shop with no location |
| `SHOP_CLOSED` | NEW | Checkout | create session | shop paused (stop orders), suspended or closed; `details.reopensAt` |
| `FULFILLMENT_REQUIRED` / `DROPOFF_REQUIRED` / `DROPOFF_INVALID` | NEW | Checkout | create / patch session | missing choice / missing drop-off / not exactly one drop-off source |
| `RECEIVER_INCOMPLETE` | NEW | Checkout | create / patch session | receiver name and phone go together |
| `DELIVERY_NOT_AVAILABLE` | NEW | Checkout | create session | no shipper covers the address |
| `DELIVERY_PRICE_CHANGED` / `DELIVERY_OPTION_GONE` | NEW | Checkout | create session, process / retry payment | held price changed / shipper can't take it; show before → after |
| `CAMPAIGN_ENDED` | NEW | Checkout | process payment | a delivery campaign ended since checkout |
| `LAYAWAY_DELIVERY_LATER` | NEW | Checkout | create session (installment) | layaway: choose delivery after the last payment |
| `HOURS_INVALID` | NEW | Shops | `PUT /shops/{shopId}/hours` | a special day twice, or open with no hours |
| `SHOP_NOT_PAUSABLE` | NEW | Shops | `PUT /shops/{shopId}/pause` | only an ACTIVE (or already paused) shop can be paused |
| `OUTSIDE_COVERAGE` | NEW | Checkout, Events | `saveAs` pin, venue pin | pin outside Tanzania's wards |
| `LABEL_ALREADY_USED` / `ADDRESS_LIMIT_REACHED` | NEW | Checkout | `saveAs` pin | second HOME / WORK; 11th address |
| `USE_PICKUP_FLOW` | NEW | Orders | ship / confirm-delivery / regenerate-code | pickup order — use the shipping actions |
| `SHIPPER_DELIVERS` | NEW | Orders | ship / confirm-delivery / regenerate-code | a shipper delivers this order |
| `CODE_WRONG` / `CODE_LOCKED` / `CODE_NOT_ACTIVE` | NEW (was 400) | Orders | confirm-delivery | wrong code (`details.triesLeft`) / locked / none active |
| `CODE_EXPIRED` / `CODE_LIMIT` | NEW | Orders | confirm-delivery / regenerate-code | older than 24 h / more than 5 in 24 h |
| `CANCEL_NOT_ALLOWED` | NEW (was `FULFILLMENT_STATE`) | Orders | cancel | outside the cancel window |
| `INFORM_BUYER_FIRST` | NEW | Orders | cancel (seller) | send `informedBuyer: true` |
| `INSTALLMENT_ORDER` | NEW | Orders | cancel | cancel the installment agreement instead |

The codes of the new location, shipping and campaign endpoints are in their docs (location §Part 3, shipping §4.5,
campaigns §Part 3).

---

# 17. App checklist by screen

| Screen | What to change | Sections |
|---|---|---|
| Shop create / edit (seller) | drop location inputs; add the Shop location (pin) step | §1.2 |
| Shop page / lists / search | `location`, Directions, Call; `availability` (open now / paused); Buy off when not accepting | §1.3, §1.4 |
| Shop settings (seller) | opening hours screen; pause / reopen | §1.6, §1.7 |
| Shop reviews | show `replyText` | §1.5 |
| Product page | "Pickup at …" + Directions from `shopLocation` | §2.2 |
| Product form (seller) | "Bulky / heavy item" switch | §2.3 |
| Cart | group by `shops`, delivery per shop, `notice`, `nudge`, "Used before", ticks, `selectedTotal`, change messages | §3.2–§3.4 |
| Checkout | pickup / delivery per shop, drop-off, receiver, note; send `fulfillment`; price-changed codes | §4.2–§4.4 |
| Payment result | `sellerAmount` + `deliveryAmount`, `parts` | §4.4 |
| Order detail (buyer + seller) | `fulfillment` status, steps, times, shippers' phones, Bolt rider, code status, "I lost my code" | §5.2, §5.5 |
| Order lists | `fulfillmentType` badge, `fulfillmentStatus` | §5.2 |
| Order cancel | new windows; installment → agreement cancel | §5.3, §6.1 |
| Delivery code entry | 4 digits, `triesLeft`, locked / expired | §5.5 |
| Installment sheet / agreement | delivery for "ships now"; layaway text; `fulfillment` | §6.1 |
| Group purchase sheet | pickup / delivery | §7.1 |
| Wallet history | new titles | §8.1 |
| Event venue step (organiser) | pin screen; pin required to publish | §9.1, §9.2 |
| Donation ticket (organiser + event page) | `donationTarget`; progress bar from `donation` | §9.3 |
| Profile | cover banner (`COVER_PHOTO`, `coverMedia`) | §10.1 |
| Interests (onboarding) | send ids from the loaded list | §11.1 |
| Chat list / group header | avatars from `variants`; refresh on `CHAT_GROUP_UPDATED` | §12.1 |
| Digital downloads | download as bytes, keep the filename | §13.1 |
| Bell list / push | the new types and four new targets | §15 |
| Admin dashboard | review moderation, `DELIVERY_COMMISSION`, reconciliation lines, admin-only interests | §14 |