# Captain API — what is not built yet

**As of 2026-09-21.** Endpoints the captain app needs that `routes/driver.php` does not answer.

Specified in `docs/kapitano-captain-audit.md` §13 (API contract proposal), §14 (screen map) and
§8 (gap analysis). This file lists only the gaps — see §3 of the audit for the inventory of what
exists.

---

## The MVP cut

Against the MVP scope in audit §9, **one thing is missing**. Four of the five gaps listed here
earlier have been built; what remains is earnings.

| # | §9 item | State |
|---|---|---|
| 1 | `accept` / `decline` with countdown auto-expire | **Built** |
| 2 | Proof of delivery — a photo on the delivery call | **Built** |
| 3 | Captain cancel | **Withdrawn** — decline covers it, see below |
| 4 | Admin cancel | **Built** |
| 5 | Captain earnings summary | **Missing** |

Post-MVP by §9's own list, and not counted: wallets and payouts, ratings, attendance and shifts,
and the notifications list screen.

---

## Built since: the captain settlement ledger

Money a captain pays a supplier and cash a captain collects are now recorded, an order can be
handed from one captain to another mid-route, and a cash desk clears the balances — see
Feature 10 in `docs/CHANGELOG.md` and the Swagger pages. New captain endpoints:

| | |
|---|---|
| `GET /api/driver/ledger` | the captain's own balance and statement |
| `GET /api/driver/handovers` | orders other captains are handing to me |
| `POST /api/driver/orders/{uuid}/handover` | offer my order to another captain |
| `POST /api/driver/orders/{uuid}/handover/accept` · `…/decline` | answer an offer |
| `DELETE /api/driver/orders/{uuid}/handover` | withdraw my offer |

`amount_paid` on `picked-up` and `amount_collected` on `delivered` are new optional fields.

**This is not earnings.** The ledger records out-of-pocket money and collected cash; it does
not decide what a captain *earns* for a delivery, which is still the open gap below.

## 1. Earnings — the one MVP gap left

`GET /api/driver/earnings` — screen 9, audit §8 gap 11. Nothing exists on the captain side.
The admin counterpart `GET /api/dashboard/drivers/{uuid}/earnings` (§13) is also unbuilt.

`orders.fee` already exists as a decimal column, supplied by the store or admin when the order is
created, so this is aggregation and an endpoint rather than a schema change. §9 scopes it to a
summary only — a running total and a per-order list, with no wallet or payout engine. The store
half of the money model (`StoreSettlementData`, `SettlementStatus`, `/api/dashboard/settlements`)
is a pattern to follow.

**One decision has to be made first, and it is not a coding one.** `orders.fee` is the fee charged
*for the delivery*. Whether the captain earns that, a percentage of it, or something computed from
distance and time is undecided, and the endpoint cannot be written until it is.

## 2. Notifications list

`GET /api/driver/notifications` — screen 10, post-MVP.

Push delivery works (FCM, device tokens, `POST` / `DELETE /api/driver/device-token`), but a
captain who dismisses a notification cannot get it back.
`routes/dashboard-notifications.php` serves the admin side; there is no captain equivalent.

## 3. Ratings, attendance and shifts

Audit §8 gap 11, and §9 places all three explicitly post-MVP. No routes, no tables, no services.

---

## What was built, and what changed with it

### Accept and decline

```
POST /api/driver/orders/{uuid}/accept
POST /api/driver/orders/{uuid}/decline    { "reason": "optional" }
```

An assignment is now an **offer**, and the lifecycle gained a state:

```
pending → assigned → accepted → picked_up → on_the_way → delivered | delivery_failed
             ↓           ↓
           pending    pending          (declined, or the offer ran out)
```

Four decisions are worth knowing, because each could reasonably have gone the other way:

- **The capacity slot is taken at assignment, not at acceptance.** Unchanged from before, which
  is the point: the atomic `UPDATE ... WHERE active_orders < max` is the only thing that can
  decide capacity safely, and moving it to acceptance would mean an accept that can be *refused*
  because the slot went elsewhere while the phone was ringing. The cost is that a silent captain
  holds a slot until the offer expires.
- **A captain cannot pick up an order they have not accepted.** `assigned → picked_up` is no
  longer a legal transition. This is what makes the offer mean anything, and it is the one change
  here that **requires the captain app to be updated before deploy** — an app that does not call
  `accept` cannot complete a delivery.
- **Declining returns the order to `pending`,** not to a status of its own. A declined order is an
  order waiting for a captain, which is what pending already means.
- **A captain who refused an order is excluded from its next suggestion list.** Without this the
  ranking that put them first puts them first again, and the order lands back on the phone that
  just refused it. The exclusion is per order, never per captain.

The countdown is `DISPATCH_OFFER_TIMEOUT_S`, default 60 s. `ExpireOrderOffer` is queued with a
delay when the order is assigned, and identifies its offer by **captain and deadline together** —
`offer_expires_at` has only second granularity, so a decline and a re-offer inside the same second
produce an identical deadline, and a job comparing only that would cancel a live offer.

Timing out is recorded as a decline with `expired = true`. Refusing is a choice; never answering
may be a flat battery, and the difference is the only thing that makes a decline rate readable.

### Proof of delivery

```
POST  /api/driver/orders/{uuid}/delivered    multipart: proof (image), note
PATCH /api/driver/orders/{uuid}/delivered    note only — unchanged
```

Both verbs reach the same handler: PHP parses a multipart body only on a POST, so the photo can
only arrive that way, while PATCH keeps working for app builds that send no photo. The URL is
returned as `proof_of_delivery` on both the captain and the dashboard resources.

**The photo is optional,** which is a policy choice rather than a technical one. Making it
mandatory would reject deliveries from any build predating the camera step and strand a captain
with a broken camera. Tightening it is a one-word change once every captain is on a build that
sends it.

The upload is filed **before** the status moves. An order left undelivered because the photo would
not store is a captain tapping the button again; an order recorded as delivered whose proof was
quietly dropped is a dispute nobody can settle.

### Admin cancel

```
POST /api/dashboard/orders/{uuid}/cancel    { "reason": "required" }
```

Its own permission (`orders.cancel`, already defined and seeded): a dispatcher who may hand work
out is not by that fact someone who may call it off. Cancelling is terminal, so it also hands back
the carrying captain's capacity, inside the same transaction that moves the status.

Before this, the only door to a cancellation was the store integration — an order placed by phone
and entered by hand could never be ended by the people who entered it.

### Captain cancel — deliberately not built

`OrderStatus::captainNextSteps()` carries an explicit rule: cancelling *"is the store's or the
back office's to make, never the captain's"*. With decline in place that rule costs nothing — a
captain can always get out of an order, right up to the pickup, and the order goes back to the
pool instead of dying in their hands. Only the store or the back office can end it.

After the pickup, `PATCH /orders/{uuid}/failed` already covers a delivery that cannot be
completed, with its own mandatory reason.

---

## Specified under one name, built under another

These are **implemented** — do not build them. The audit predates the implementation:

| Audit §13 | Actually implemented as |
|---|---|
| `PATCH /api/driver/availability` | `POST /api/driver/availability` |
| `POST /api/driver/orders/{uuid}/pickup` | `PATCH /api/driver/orders/{uuid}/picked-up` |
| `POST /api/driver/orders/{uuid}/deliver` | `POST`/`PATCH /api/driver/orders/{uuid}/delivered` |
| `POST /api/driver/orders/{uuid}/cancel` | `POST /api/driver/orders/{uuid}/decline` |

## Built since the audit was written

Not in §13 at all, and easy to miss when reading the audit as the current contract:

- `PATCH /api/driver/orders/{uuid}/on-the-way` — a status between pickup and delivery
- `PATCH /api/driver/orders/{uuid}/failed` — the delivery-failed branch
- `GET /api/driver/route-plan` — the captain's live multi-stop route
- `POST /api/driver/documents` — re-upload when an application is sent back
- `GET` / `POST /api/driver/vehicle` — the captain's own vehicle
- `on_break` on the availability endpoint

---

## Deliberately admin-only

`PATCH /api/dashboard/dispatch/route-plans/{driver}/reorder` changes stop order. A captain cannot
reorder their own route, by design — reordering is a dispatcher decision, and `routes/driver.php`
gives a captain no way to name another captain at all.

## Not announced to stores

`accepted` and the `pending` a declined order returns to are **not** sent as webhooks. The store's
contract speaks about where its parcel is, not about how the fleet is organised behind that, and a
store told "pending" after "assigned" would reasonably read it as the order going backwards.
Adding events for either is a change to the published integration contract.
