# POS API - Implementation Guide

Bearer-token API for the WMA POS / mobile app, implemented as the `PosApi`
module. Every endpoint lives under **`/api/pos/v1`**; interactive Swagger
docs are served at **`/pos-api/docs`** (spec: `/pos-api/openapi.yaml`).

The module contains **no business logic of its own** - it is a thin,
mobile-shaped surface over the same services the React portal uses
(`BillingService`, `StickerService`, `InstrumentSearchService`,
`VarianceReportService`), so a bill or sticker handled from the POS behaves
identically to one handled from the portal.

---

## Accessing the Swagger docs

The module ships interactive Swagger UI - no package or build step, the
page loads Swagger UI from a CDN and renders the hand-maintained spec.

| What | URL |
|---|---|
| Swagger UI (interactive) | `<base-url>/pos-api/docs` |
| Raw OpenAPI 3 spec (YAML) | `<base-url>/pos-api/openapi.yaml` |

Examples:

- Local dev (`php artisan serve`): <https://training.wma.go.tz/pos-api/docs>
- Production: `https://<your-domain>/pos-api/docs`

Both routes are web routes (registered in `routes/web.php` of this module,
served by `DocsController`) and require **no login** to view. The machine
needs internet access to reach the Swagger UI CDN (`unpkg.com`); the spec
itself is served locally.

### Trying endpoints from Swagger ("Try it out")

1. Open `/pos-api/docs`.
2. Expand `POST /auth/login` → **Try it out** → enter `email`, `password`,
   `device_name` → **Execute**. With MFA on the response has
   `requires_otp: true` and a `challenge_token`, and a code is texted to the
   user. Expand `POST /auth/otp/verify`, send `challenge_token` + `otp`, and
   copy `data.token` from that response. (With MFA off, `data.token` comes
   straight back from `/auth/login`.)
3. Click the **Authorize** button (top right), paste the token (just the
   token - Swagger adds the `Bearer ` prefix), then **Authorize** → **Close**.
4. Every subsequent "Try it out" call now sends
   `Authorization: Bearer <token>`. The authorization persists across page
   reloads (`persistAuthorization` is enabled).

### Updating the docs

The spec is hand-maintained at `Modules/PosApi/docs/openapi.yaml` - edit
it in the same commit as any endpoint change and the UI picks it up
immediately (the spec is served with `Cache-Control: no-cache`).

---

## 1. Architecture

```
Modules/PosApi/
├── module.json / composer.json          nwidart module registration
├── config/config.php                    per_page (20), token ability, units list
├── routes/api.php                       /api/pos/v1/*  (Sanctum Bearer)
├── routes/web.php                       /pos-api/docs  (Swagger UI)
├── docs/openapi.yaml                    hand-maintained OpenAPI 3 spec
├── app/Providers/…                      standard module providers
├── app/Http/Concerns/
│   ├── ResolvesRegionScope.php          region visibility for every listing
│   └── RespondsWithPagination.php       the one pagination envelope
├── app/Http/Controllers/
│   ├── AuthController.php               login (+ SMS code) / logout / profile / switch-region
│   ├── BillController.php               create / search / show / cancel-request
│   ├── PaymentController.php            payment search (new - none existed)
│   ├── StickerController.php            search / printed / unprinted / reprint / print-log
│   ├── InstrumentSearchController.php   proxy over InstrumentSearchService
│   ├── AnalyticsController.php          variance analysis
│   ├── ReferenceDataController.php      revenue sources / units / regions
│   └── DocsController.php               Swagger UI + spec
├── app/Http/Requests/                   LoginRequest, VerifyOtpRequest, ResendOtpRequest, SwitchRegionRequest, CreateBillRequest
├── app/Http/Resources/                  ProfileResource, BillResource, PaymentResource, StickerResource
├── app/Services/PosLoginChallenge.php   the texted sign-in code (cache-held, hashed, 3 tries)
└── app/Services/PosStickerService.php   paginated sticker query (portal's is unpaginated)
```

Registered in `modules_statuses.json` (`"PosApi": true`). The module's
`RouteServiceProvider` mounts `routes/api.php` at `/api` with the `api`
middleware group, so routes resolve as `/api/pos/v1/...` with names
`api.pos.*`.

### Response conventions

- Envelope: `{ success: bool, message?: string, data?: … }` - same as the
  rest of the backend.
- Every listing paginates **20 per page** (config `posapi.per_page`):
  `{ success, data: [...], meta: { current_page, per_page, total, last_page } }`.
- Validation failures return Laravel's standard `422 { message, errors }`.

### Authentication

Sanctum Personal Access Tokens (same mechanism as the portal, no
cookies/CSRF involved). While multi-factor authentication is on
(portal: Settings > Security - the one switch for the whole system),
signing in takes **two steps**:

```
POST /auth/login        { email, password, device_name }
  -> 200 { requires_otp: true, data: { challenge_token, phone: "******5678",
           expires_in: 300, resend_after: 60, max_attempts: 3 } }
     (a six-digit code is texted to the user)

POST /auth/otp/verify   { challenge_token, otp: "482913" }
  -> 200 { data: { token, token_type: "Bearer", user } }

POST /auth/otp/resend   { challenge_token }        (optional, after 60 s)
  -> 200 { data: { expires_in: 300, resend_after: 60 } }
```

With MFA switched off, `/auth/login` returns `{ token, token_type, user }`
directly and `requires_otp` is absent. **The app must branch on
`requires_otp`**, never assume one flow.

1. **Where the code goes**: the phone registered as the user's SMS second
   factor in the portal, otherwise the account's phone number. No phone ->
   `403 no_phone`.
2. **Code rules**: 6 digits, valid **5 minutes**, stored hashed on the
   server. **3 wrong codes** end the sign-in (`otp_attempts_exceeded`, go
   back to `/auth/login`) and count as one wrong password towards the
   account lock. A new code after **60 s**, at most **3** per sign-in; it
   replaces the previous code.
3. **`challenge_token`** is 64 opaque characters, not a Bearer token - it
   opens nothing on its own. Hold it in memory only; never log or persist it.
4. **Account lock**: **3 wrong passwords in a row** (portal and POS count
   together; configurable 3-10 in Settings > Security) switch the account
   off and end every session. The user is told by SMS. Afterwards even the
   right password returns `403 account_locked`. It opens again when the user
   **resets their password** (portal "Forgot password" by texted code, or an
   administrator's reset link) or an administrator presses **Unlock** on the
   Users page. A correct password resets the count. An account an
   administrator locked (`account_inactive`) is not reopened by a reset.
5. **Tokens**: the Bearer token carries the `pos` ability. A new sign-in
   with the same `device_name` revokes that device's previous token (one
   live token per device). Send `Authorization: Bearer <token>` on every
   request; protected routes are throttled at 120 req/min per user.
   `POST /auth/logout` revokes the current token only.
6. **Throttles**: `/auth/login` and `/auth/otp/verify` 10/min per IP,
   `/auth/otp/resend` 5/min per IP.
7. **Development only**: on a `local`/`testing` server, when SMS is mocked
   (`SMS_MOCK_ENABLED=true`) or the gateway fails, the response also carries
   `data.debug_otp`. It is never returned in production; there a code that
   could not be texted returns `503 sms_failed`.

#### Error codes

Every auth error carries `error_code` next to `message` (the message is
safe to show to the user; branch on the code):

| error_code | HTTP | App behaviour |
|---|---|---|
| `invalid_credentials` | 401 | Wrong email or password - show the message |
| `account_locked` | 403 | Locked after wrong passwords - point to password reset / administrator |
| `account_inactive` | 403 | Switched off by an administrator |
| `no_phone` | 403 | No phone for the code - administrator must add one |
| `too_many_attempts` | 429 | Too many sign-ins from this IP - wait |
| `sms_failed` | 503 | Code could not be texted - retry shortly |
| `invalid_otp` | 422 | Wrong code - `data.attempts_left` tries remain |
| `otp_attempts_exceeded` | 422 | 3 wrong codes - restart at `/auth/login` |
| `challenge_expired` | 422 | Code expired or already used - restart at `/auth/login` |
| `resend_too_soon` | 429 | Wait `data.retry_after` seconds |
| `resend_limit` | 429 | No more codes this sign-in - restart at `/auth/login` |

POS routes deliberately skip the portal's `mfa.complete` and
`handover.unlocked` middleware: the second factor is enforced at sign-in
above instead (a POS token is only ever issued after the code).

### Region model ("each user sees data based on their region and roles")

- A user's **primary region** is `users.collection_center`; **other
  regions** come from the `user_collection_centers` pivot (plus temporary
  handover delegations).
- `GET /auth/profile` returns `primary_region`, `other_regions[]`, `roles`,
  and `can_access_all_regions`.
- `POST /auth/switch-region { region_code }` promotes any *assigned* region
  to primary (persisted to `users.collection_center`). Handover-delegated
  regions cannot become primary. Alternatively, the stateless
  `X-Active-Workstation: <code>` header (same one the portal uses) switches
  the working region per-request without persisting.
- Every listing applies `ResolvesRegionScope`: users with a role in
  `the role scope in config/rbac.php` (`super-admin`, `admin`, `surveillance`,
  `hq-accountant`, `internal-audit`, `ceo`, `dts`, `dbs`) see **all**
  regions and may narrow with `?region=`; everyone else sees only their
  assigned regions, and an out-of-scope `?region=` matches nothing rather
  than widening access.

---

## 2. Endpoints

| Method | Path | Purpose |
|---|---|---|
| POST | `/auth/login` | Sign-in step 1: password; texts a code (or returns the token when MFA is off) |
| POST | `/auth/otp/verify` | Sign-in step 2: the texted code -> Bearer token + profile |
| POST | `/auth/otp/resend` | Text a new sign-in code (after 60 s, max 3) |
| POST | `/auth/logout` | Revoke current token |
| GET | `/auth/profile` | Full name, primary + other regions, roles |
| POST | `/auth/switch-region` | Promote an assigned region to primary |
| GET | `/bills` | Search bills (20/page, region-scoped) |
| POST | `/bills` | Create bill (returns immediately, poll for CN) |
| GET | `/bills/{billUuid}` | One bill - the control-number poll target |
| POST | `/bills/{billUuid}/cancellation-request` | Request cancellation (portal approves) |
| GET | `/payments` | Search payments (20/page, region-scoped) |
| GET | `/analytics/variance` | Variance analysis (rollup tables) |
| GET | `/stickers` | Search stickers (20/page) |
| GET | `/stickers/printed` / `/stickers/unprinted` | Printed / unprinted (20/page) |
| GET | `/stickers/reprint-requests` | My reprint requests (20/page) |
| POST | `/stickers/{stickerUuid}/reprint-request` | Request reprint of a printed sticker |
| POST | `/stickers/{stickerUuid}/print-log` | Record a successful physical print |
| GET | `/instrument-search` | Same behaviour as the React portal page |
| GET | `/instrument-search/{activity}/{id}/history` | Last 5 verifications |
| GET | `/revenue-sources` | Billable revenue sources (name + code, from DB) |
| GET | `/units` | Units of measure for bill items (grouped) |
| GET | `/regions` | All regions (code + name) |

Full request/response schemas: `/pos-api/docs`.

---

## 3. Key flows

### Creating a bill (fast, non-blocking)

The portal's create-bill endpoint blocks up to 30 s waiting for GePG to
call back with the control number. That is wrong for mobile, so the POS
endpoint calls `BillingService::generateBill($payload, skipWait: true)`:

1. `POST /bills` - validated against the same rules as the portal
   (including the refusal of `exclude_from_bill_management` revenue codes).
   Inside one DB transaction the service creates the `wma_bill` row and
   `bill_items`, updates the collection rollups, cuts one sticker per unit,
   submits the bill XML to GePG, and issues the certificate. Responds
   **201** with `control_number: null`.
2. GePG answers asynchronously; `ProcessGepgControlNumber` +
   `SyncControlNumberToDocuments` stamp the control number onto the bill,
   its stickers and certificates, and the customer is SMSed.
3. The app polls `GET /bills/{bill_uuid}` every 2-3 s (give up ~60 s and
   tell the operator the number will arrive by SMS) until
   `control_number_ready: true`.

**Simulated bills (non-production).** With `GEPG_MOCK_ENABLED=true` the
gateway is stood in for locally, so steps 2-3 collapse: `POST /bills` already
returns the number with `control_number_ready: true` and the poll loop finds
nothing left to wait for. The number is not invented - `MockControlNumberSequence`
continues the series already in `wma_bill.bill_control_number` (`1996xxxxxxxx`,
12 digits, per `GEPG_MOCK_CONTROL_NUMBER_PREFIX`), taking the high-water mark
across `wma_bill` and `control_number` under a lock, so POS and portal bills
share one unbroken sequence. `BillingService` falls back to that same sequence
when the GePG signing certificate is missing, so a POS bill gets a series
number on any machine, with its `control_number` row and stickers stamped
exactly as the live callback would. Leave the flag off in production - these
are numbers no bank can settle.

POS-specific extras: optional `device_id`, `device_terminal_id`,
`gps_latitude`, `gps_longitude` are persisted onto the bill row (columns
already existed, unused) for the audit trail. The bill's region is the
operator's **active** region (honours `X-Active-Workstation`), not the raw
primary column.

`payment_option: "partial"` maps to GePG option 2, otherwise 3 (exact).
`payment_method: "electronicFundTransfer"` requires `bank_account_id` so
the bill records the receiving account's real SWIFT code; default is
mobile money / NMB.

### Bill & receipt payload structures

Every bill response (`POST /bills`, `GET /bills`, `GET /bills/{uuid}`) uses
the POS structure:

```jsonc
{
  "bill_uuid": "…",
  "control_number_ready": true,
  "is_cancelled": false,

  "bill_info": {                       // A: bill information
    "payer_name": "Juma Shop",
    "payer_phone_number": "0712345678",
    "bill_reference": "WMA-202600120",
    "control_number": "90192094651",   // null until GePG answers
    "payment_option": "exact",         // exact | partial
    "created_at": "23 Aug 2026",
    "expiry_date": "30 Aug 2026",
    "bill_amount": 601890,
    "outstanding_amount": 601890,
    "payment_status": "PENDING"        // PENDING | PARTIAL | PAID
  },

  "additional_info": {                 // B: additional information
    "bill_description": "Scale verification",
    "pos_center": "Dodoma",            // region NAME, not code
    "phone_number": "0755000000"       // center manager's number
  },

  "bill_items": ["Scale 20kg X 4"],    // C: item names only

  "qr_code": {                         // D: standard GePG payment QR
    "opType": "2",
    "shortCode": "001001",
    "billReference": "90192094651",    // = control number
    "amount": "601890",
    "billCcy": "TZS",
    "billExprDt": "2027-07-22T13:01:58",
    "billPayOpt": "1",                 // 1 exact, 2 partial (NOT the 2/3
                                       // vocabulary of bill submission!)
    "billRsv01": "Weights And Measure Agency|Juma Shop"
  }
}
```

**QR code**: render the `qr_code` object as JSON inside the QR on the bill
preview and print - this is the standard format payment apps (Tigo Pesa,
banking apps) recognise and process. It is `null` until the control number
arrives. The fixed fields are configurable via env:
`POS_QR_OP_TYPE` (default `2`), `POS_QR_SHORT_CODE` (default `001001`),
`POS_QR_CURRENCY` (default `TZS`), `POS_QR_SP_NAME` (default
`Weights And Measure Agency` - the first half of `billRsv01`). `amount` is
the outstanding amount (falls back to the bill total).

Receipts (`GET /payments`) use:

```jsonc
{
  "id": 51,
  "payer_name": "Juma Shop",           // ALWAYS from the bill - payments may
  "payer_phone_number": "0712345678",  // arrive under the payer's agent name
  "payment_reference": "PAY1TICQTQF0JJL",
  "receipt_number": "TRXOBNWYDCH8J",
  "control_number": "90192094651",
  "bill_uuid": "…",
  "payment_date": "23 Aug 2026",
  "bill_amount": 601890,
  "paid_amount": 300000,
  "outstanding_amount": 301890,
  "pos_center": "Dodoma",
  "bill_items": ["Scale 20kg X 4"]
}
```

### Cancelling a bill

`POST /bills/{uuid}/cancellation-request` creates a
`bill_cancellation_requests` row - **it does not touch GePG**. Rules
(identical to the portal): only the bill's creator may request; refused
once anything is paid (422); one pending request at a time (409). A
cross-center reviewer approves in the portal, which submits the
cancellation to GePG, voids stickers/certificates, and updates rollups.

### Sticker printing

1. Find the sticker (`GET /stickers?control_number=…`). An empty
   control-number search automatically triggers the backfill that rebuilds
   missing stickers from the bill items (lock-protected, safe), so a paid
   bill's stickers are always findable.
2. Print on the physical printer **first**.
3. On printer acknowledgement, `POST /stickers/{uuid}/print-log` (no
   body). This writes the print log and flips `is_printed` in one
   transaction - never call it before the print succeeds, or the sticker
   locks without a physical sticker existing.
4. A printed sticker can only be printed again after
   `POST /stickers/{uuid}/reprint-request` is approved in the portal
   (approval clears `is_printed`).

Note: the portal sticker list endpoint is unpaginated and cannot filter
unprinted stickers; the POS endpoints fix both (server-side pagination and
a real `status=printed|unprinted` filter) via `PosStickerService`.

### Variance analysis

`GET /analytics/variance?period_type=month&year=2026&month=7` - served by
the portal's `VarianceReportService`, which reads only the pre-aggregated
rollup tables (`daily_collections` + `collection_targets`; fiscal year
starts July). Responses are cached (60 s open periods, 6 h closed);
`fresh=1` bypasses. Unlike the portal (permission-gated), every POS user
may see their own regions' variance; cross-center roles see all regions.
`target` is the actual target set for the months the period covers - there
is no second, time-prorated reading of it.

### Instrument search

`GET /instrument-search?activity=vtv|sbl&search_type=plate|sticker|certificate&q=…`
delegates to `InstrumentSearchService` - the exact class behind the React
page - so params, matching rules (min 3 chars, max 10 results), and the
response shape (`results[]` with `customer`, `vehicle`, `verification`,
`billing`, `billPreview`) are byte-for-byte identical to the portal.
Mobile parity notes (mirroring `InstrumentSearch.jsx`):

- Status pill: `Not Verified` when `verification` is null, else `Valid`
  when `verification.nextDate >= today`, else `Expired`.
- Payment pill from `billing.status`; show "Print Bill" only when
  `billPreview` exists and status is `Pending` or `Partial`.
- History tab loads lazily from `/instrument-search/{activity}/{id}/history`.

### Reference data

- `/revenue-sources` reads the `revenue_sources` table (nothing
  hardcoded), returning only rows that are `is_active` and not
  `exclude_from_bill_management` - the same set `POST /bills` accepts.
- `/units` serves the grouped unit list from `config('posapi.units')`. No
  units table exists in the backend; the portal hardcodes the list in
  `CreateBill.jsx` (`UNIT_OPTIONS`). The config is the mobile source of
  truth - keep the two in sync.
- `/regions` lists all collection centers for pickers.

---

## 4. Operational notes & best practices

- **Speed**: every listing is paginated and indexed-column-filtered; bill
  creation skips the 30 s GePG wait; variance reads pre-aggregated rollups
  behind a cache. Keep Horizon running - control numbers, payments and
  SMS all arrive through queued jobs.
- **Rate limiting**: this module throttles login and code verification
  (10/min/IP), code resends (5/min/IP) and authenticated traffic
  (120/min/user).
- **Sign-in security**: SMS code second step (when MFA is on), account lock
  after 3 wrong passwords, codes hashed and limited to 3 tries. Keep the
  SMS gateway healthy - with MFA on, nobody can sign in to the POS without
  it (`503 sms_failed`).
- **Token hygiene**: tokens never expire (`sanctum.expiration = null`).
  Consider setting an expiry and a refresh flow for field devices.
- **Idempotency gotcha (pre-existing)**: `wma_bill.bill_reference_number`
  is `now()->format('Y-m-d-H-i-s')` and unique - two bills created in the
  same second collide. Low risk on the portal, higher with many POS
  terminals; consider switching the service to a UUID-suffixed reference.
- **PHP 8.5 deprecations**: keep deprecation output off in production -
  warnings printed into JSON have previously broken API consumers.
- **Docs discipline**: `docs/openapi.yaml` is hand-maintained. Update it
  in the same commit as any endpoint change; it is what `/pos-api/docs`
  renders.

## 5. Quick smoke test

```bash
BASE=https://training.wma.go.tz/api/pos/v1

# sign-in step 1 - a code is texted to the user
curl -s -X POST $BASE/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"<email>","password":"<password>","device_name":"pos-01"}'

CHALLENGE=…   # from data.challenge_token

# sign-in step 2 - the texted code
curl -s -X POST $BASE/auth/otp/verify \
  -H 'Content-Type: application/json' \
  -d "{\"challenge_token\":\"$CHALLENGE\",\"otp\":\"<6-digit code>\"}"

TOKEN=…   # from data.token

# profile + regions
curl -s -H "Authorization: Bearer $TOKEN" $BASE/auth/profile

# create a bill
curl -s -X POST $BASE/bills \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"payer_name":"Juma Shop","bill_description":"Scale verification",
       "phone_number":"0712345678","payment_option":"exact",
       "revenue_sources":[{"revenue_source":"140202","amount":15000,
         "number_of_items":2,"item_name":"Scale","item_capacity":"20","item_unit":"kg"}]}'

# poll for the control number
curl -s -H "Authorization: Bearer $TOKEN" $BASE/bills/<bill_uuid>
```
