# EAlliancePOS → POS API v2: client migration guide

Written for the Flutter developer moving `C:\bctech\EAlliancePOS` off `https://upgrade.ealliance.net/POS/*.php` onto `https://posapi.ealliance.net/v1`. Every legacy call site was traced in the 29-Sep audit; the table below maps each to its replacement. Keep the screens; replace the network layer.

## 1. One HTTP client

Create `lib/api/pos_api.dart` around `Dio`:

- base URL from config (`POS_API_BASE_URL`), never a literal per call site (48 literals today);
- `connectTimeout: 15s`, `receiveTimeout: 30s` on every call (today one call in 48 has a timeout);
- interceptor adds `Authorization: Bearer`, `X-POS-Device`, `X-Request-Id`; on 401 `token_expired` it calls `/v1/auth/refresh` once and retries;
- **remove** `badCertificateCallback => true` in `lib/http_client.dart:8-12` and `usesCleartextTraffic` from the manifest;
- map the envelope: `ok:false` → throw `ApiException(code, message, details)`; the UI shows `message`, never "Network Error";
- never `json.decode` outside the client (45 unguarded sites today).

## 2. Identity

| Today | Replace with |
|---|---|
| `Auth.php` form post `pin` (`auth_provider.dart:185`) | `POST /v1/auth/login {pin, device_code, app_version}` → store `access_token`, `refresh_token` in `flutter_secure_storage`; branch, rp, scopes in memory. On `409 pin_ambiguous` show `candidates` and re-post with `username`. |
| `GetEmployees.php` (`employee_provider.dart:192`) with passwords cached for offline PIN checks | `GET /v1/branches/{id}/supervisors` for the supervisor picker; **stop caching passwords**. Offline supervisor checks go: if online, `POST /v1/auth/supervisor`; if offline, queue the action with the supervisor's *username* chosen from the list and let the server verify at drain time (the server refuses and reports it). |
| `SyncComps.php post-type=deviceDetails` / `AssignRevenuePoint.php` | `POST /v1/devices` once; store `device_code` (replaces prefs `deviceWedCode`). |
| `userId == '0033'` admin test (`cashup_page.dart:26` etc.) | `scopes.contains('pos.admin')` from the token. |
| `UpdateExpense.php?status=pin_verification` (`cart_screen.dart:413`) | `POST /v1/auth/supervisor {pin}`. |

## 3. Datasets

| Today | Replace with |
|---|---|
| Login blob: `inventory`, `customerList`, `branch_emps`, `assignedBranchList`, `regions`, `branches`, `paymentMethods`, `expenseCategories`, `ExpensesList` | `GET /v1/reference` (ETag) once per login and on demand; `GET /v1/catalogue?rp=` (ETag) on login, start/resume shift, back-online and a 30-min timer; `GET /v1/customers?q=` when searching; `GET /v1/expenses?days=3` when opening the expenses screen. |
| `FetchProducts.php`, `Products.php`, `GetProducts.php`, `StartShift.php.products` (four catalogue copies) | One Hive box `catalogue_<branch>_<rp>` with its ETag. Send `If-None-Match`; on 304 keep the copy. Product rows: `{code, name, price, price_source, ids, category}`. |
| `Categories.php`, `GetBranches.php` | `GET /v1/products/categories`, `GET /v1/reference.branches`. |

## 4. Shifts

| Today | Replace with |
|---|---|
| `StartShift.php` (`open_shift_page.dart:385`) and the three local resume guards | `POST /v1/shifts {rp, client_shift_id, opened_at, with_catalogue:true}` with an `Idempotency-Key`. `201` = new, `200 resumed` = continue the returned shift, `409 shift_open` = show who has it. The three local guards go away; `GET /v1/shifts/current` is the truth. |
| Offline shift id = `deviceWedCode + 4 random digits`, `shift_id.length <= 8` tests (14 sites in `sync.dart`) | Offline: generate a UUID `client_shift_id`, queue records against it. When online, `POST /v1/shifts` returns `mapped_from` → `id`; renumber the queue once, atomically, in `SyncCoordinator`. |
| `StartOfflineShift.php` (6 copies of the function) | Gone. Same `POST /v1/shifts`. |
| `UpdateExpense.php?status=close_shift_now` two-step PIN + amount (`dashboard_screen.dart:976`) | `POST /v1/shifts/{id}/close {counted_cash}` then `POST /v1/shifts/{id}/approve {supervisor_pin}`; the approve response carries `theoretical_closing` for the printout. |
| `CloseOfflineShift.php` (`sync.dart:569`) | Queue `close` with `closed_at`; drain in order after the sales. |
| `prefs['shift_id'] = 'closed'` sentinel | Delete. `GET /v1/shifts/current` returns `null`. |
| `ShiftStatusApiOffline.php` (result discarded) | `GET /v1/shifts/{id}`. |
| `SyncComps.php manager_start_day/end_day` (`register.dart:96`) | `POST /v1/branch-days/open|close {branch_id}`. |
| `UpdateExpense.php?status=eod` | `POST /v1/eod`. |

## 5. Sales

| Today | Replace with |
|---|---|
| `SyncOffline.php` from `sync.dart:995` (20-s timer, `sync='uploaded'` on bare HTTP 200) and from `cart_provider.dart:346` (different key names) | Every sale record gets `idempotency_key: uuid()` **when created**. Drain with `POST /v1/sales/batch {sales:[…]}` (≤100). Mark a record uploaded only on item `status 201` or `replayed:true`. `409 receipt_exists` = already booked, mark uploaded. `422` = keep with the error text visible in "Unsynced". |
| Client-side price | Send `unit_price`; on `422 price_mismatch` refresh the catalogue and re-render the cart. Supervisor override: `price_override: {supervisor_pin, comment}`. |
| `SalesStatus.php` refund (`sales_page.dart:178`, `refunds_page.dart:96`) | `POST /v1/sales/void {rc, supervisor_pin, reason}`; stock is returned by the server. |
| Receipt number | Unchanged format; still printed; sent as `rc`. Uniqueness is `(shift_id, rc)` + the key, so a reset counter no longer collides. |

## 6. Stock events, comps, bottles, vouchers, special sales

| Today (`sync.dart`) | Replace with |
|---|---|
| `PosGrvApiOffline.php` (:722) | `POST /v1/stock/grv {shift_id, reference, supplier, lines[{code, qty, unit_price?}]}` |
| `PosRevpointToRevpointApiOffline.php` (:1178) | `POST /v1/stock/transfers {…, to:{rp}}` |
| `PosRevpointToBranchApiOffline.php` (:1376) | `POST /v1/stock/transfers {…, to:{branch_id}}` |
| `PosRevCountsheetApiOffline.php` (:1289) | `POST /v1/stock/counts` |
| `PosUllagesApiOffline.php` (:1461), `PosBreakagesApiOffline.php` (:1549) | `POST /v1/stock/ullages`, `POST /v1/stock/breakages` |
| `PosAdjustmentsApiOffline.php` (:113) with `category:'Increase'|'Decrease'` | `POST /v1/stock/adjustments {…, direction:'increase'|'decrease'}` |
| `SyncCompsOffline.php post-type=comps` (:1079) — note the duplicate `'phone'` key bug in `cart_entry_actions.dart` | `POST /v1/comps {…, beneficiary, phone}` (send the typed phone) |
| `PosBottlesApiOffline.php` bottles / bottles_redeemed (:1866/:1952) | `POST /v1/bottles`, `POST /v1/bottles/redeem {reference, shift_id}` |
| `PosChangesApiOffline.php` change_list / change_list_redeem (:2129/:2219), `GetChangesList.php` (:1658) | `POST /v1/vouchers`, `POST /v1/vouchers/{code}/redeem`, `GET /v1/vouchers?unredeemed=1` |
| `PosSpecialSalesApiOffline.php` (:2039) | `POST /v1/special-sales` |
| Line identity by `name` | Always `code`. |
| `'status': 'status'`, `'phone': 'null'` literals | Drop; the server ignores unknown keys. |

All of these take `Idempotency-Key` and return `409 reference_exists` when the same reference is posted twice on a shift, so a queue record is uploaded on `201` or `409`.

## 7. Expenses

`view_expenses.dart` approve/reject/pay/aquit → `POST /v1/expenses/{id}/approve|reject|pay|acquit` with `reason` on reject. The list is `GET /v1/expenses?days=3`. `create_expenses.dart:155` posts to `https://yourapi.com/upload-expense` today: expense creation stays on the web app until a create endpoint is agreed.

## 8. Reports

The WebView screens (`cashup_page.dart`, `stocksheet_page.dart`, `rp_*` over plain http) become native screens over `GET /v1/shifts/{id}/cashup`, `GET /v1/shifts/{id}/stocksheet`, `GET /v1/shifts/{id}/theoretical-closing`, `GET /v1/reports/sales`. The product list screen's unauthenticated call to `Admin/a_a_product_list_api.php` becomes `GET /v1/reports/stock-on-hand?rp=` (same row fields, numbers instead of strings).

## 9. One drain loop

Replace the 20-s / 200-s / 5-min timers plus the network-edge and startup bursts (`main.dart:201-206, 393-404, 428-489`) with one `SyncCoordinator` holding a mutex:

1. device registered? → `POST /v1/devices`
2. offline shifts → `POST /v1/shifts` → renumber
3. sales → `/v1/sales/batch`
4. stock events, comps, bottles, vouchers, special sales → their endpoints
5. shift closes → `/close` then `/approve`
6. run on: app start, back-online, every 60 s while anything is pending

A queue record has `status: pending | uploaded | rejected(error)`; rejected records are shown to the user with the server's message and never retried silently.

## 10. Feature flag

`USE_POS_API_V2` (remote config or build flavour) selects the new client. Roll out one branch, then a region, then all. The legacy scripts keep working meanwhile because both stacks write the same tables.
