# DALSEEN — POS Flow Endpoint Mapping

> **Verified accurate:** 2026-05-02 — D1–D10 sign-off (§7) is the **original** sign-off narrative; D-numbers now consolidated in `docs/handoff/DECISIONS.md §5a`. POS flow shapes match `front/pos/*` and the `front/api/api-namespace.js` sales/shifts handlers.
> **Status:** signed-off endpoint mapping; gap matrix still actionable (rows in §4–§6 marked ❓ MISSING are real backend gaps).

> **Source of truth (contract):** `docs/02-CONVENTIONS.md` + decisions
> D1–D10 below (signed off **owner, 2026-04-27**).
> **Source of truth (examples):** `uploads/DALSEEN.postman_collection.json`
> + `uploads/DALSEEN.postman_environment.json` — **but** where Postman
> contradicts D1–D10, **D1–D10 win**. Postman to be refreshed as a
> follow-up task.
> **Implementation target:** `Mohamkh93/DS-PL` (Laravel 11 + Sanctum).
> **Scope:** Login → Me → Branch → POS Sale → Receipt. Five flows, end-to-end.
> **Out of scope:** Everything else. HR, accounting, dine, pay-PSP — separate
> handoff docs.
>
> **Goal of this doc:** When Cursor finishes the items in §6 of each flow,
> the prototype's POS happy-path can talk to the real Laravel backend with
> a single config flip (`MOCK_MODE=false` in `front/common/api.js`).
>
> **Status legend per row:**
> - ✅ verified from Postman collection (we have method, path, body, example)
> - ⚠️  inferred — Postman has the route but the example is thin; assumptions called out
> - ❓ MISSING in Postman — flow needs it but no documented endpoint
> - 🔧 MISMATCH — frontend assumes one shape, backend documents another

---

## 0. Global contract (applies to every flow below)

### 0.1 Base URL & versioning

| | Value |
|---|---|
| Local dev base | `http://localhost:8000/api/v1` |
| Auth | Laravel Sanctum (bearer token) |
| Tenancy | path-implied; tenant inferred from `company_slug` at login |

### 0.2 Required headers

Every request the prototype sends carries these headers (auto-injected by
`front/common/api.js → buildHeaders()`):

| Header | Value | When | Notes |
|---|---|---|---|
| `Accept` | `application/json` | always | |
| `Content-Type` | `application/json` | writes only | |
| `Accept-Language` | `ar` \| `en` | always | env default `ar` |
| `Authorization` | `Bearer {token}` | after login | from `/auth/login` response |
| `X-Branch-Id` | `{branchId}` ULID string | branch-scoped routes | `/sales`, `/shifts/*`, `/orders`, etc. |
| `Idempotency-Key` | UUID v4 | every POST/PATCH/PUT/DELETE | prototype generates |

> **Cursor task:** confirm middleware order in `routes/api.php` enforces
> all five headers on the relevant route groups. Reject with 400/422 if
> missing on writes.

### 0.3 ID format — D1 ✅ ULIDs

**Decision (signed off):** All tenant primary keys and FKs are **ULIDs**
(26-char strings). Matches Laravel migrations (`ulid` / `foreignUlid`)
and `docs/02-CONVENTIONS.md § IDs`.

- Postman environment integers (`tenantId=12`, `branchId=1`, `id=50001`)
  are **stale** — refresh as a follow-up task.
- Prototype's `front/common/api.js` already generates ULIDs for ephemeral
  display state. **It must not mint ULIDs for server-owned entities** —
  IDs come back from POST responses; the client never invents them.
- Frontend types: `id: string` everywhere; never `number`.

### 0.4 Response envelope (verified ✅)

**Success:**
```json
{ "data": { ... } }            // single resource
{ "data": [ ... ], "meta": {   // collections
  "page": 1, "per_page": 25, "total": 2
} }
```

**Error:** Postman doesn't ship error examples. Convention from
`docs/02-CONVENTIONS.md` (which I cannot verify against the live backend
from here — Cursor must confirm):
```json
{ "error": {
  "code": "VALIDATION",
  "message_en": "...", "message_ar": "...",
  "fields": { "email": ["required"] }
} }
```

⚠️ **Cursor task:** confirm a global exception handler in
`app/Exceptions/Handler.php` produces this envelope for 4xx and 5xx,
including for Laravel's auto-generated 422 ValidationException.

### 0.5 Money — D2 ✅ minor units

**Decision (signed off):** `{ amount_minor, currency_code }` everywhere.
Halalas (bigint) in DB; never SAR floats in API or DB. Aligns with
`docs/02-CONVENTIONS.md § Money` and Laravel `MoneyCast`.

- Postman fixtures showing `"total": 56` / `"vat": 7.3` are **stale** —
  refresh to `"total": { "amount_minor": 5600, "currency_code": "SAR" }`,
  `"vat": { "amount_minor": 730, "currency_code": "SAR" }` (or whatever
  shape `MoneyCast` serializes — Cursor confirm).
- Frontend `Money.fmt(amount_minor, locale)` already exists; keep it.
- Affects: Capture Sale, Receipt, every reporting endpoint, refunds,
  shift drawer reconciliation, supplier invoices, payouts.

### 0.6 Bilingual fields — D10 ✅ split convention

**Decision (signed off):** Two patterns, by content type. Per
`docs/02-CONVENTIONS.md § Bilingual`.

| Pattern | When | Shape | Example |
|---|---|---|---|
| **A — indexed names** | Short, indexable, sortable, search-target | sibling fields `name_ar` / `name_en` | `{ "name_ar": "نقدي", "name_en": "Cash" }` |
| **B — prose** | Long-form descriptions, marketing copy, addresses | object `{ ar, en }` JSON column | `{ "description": { "ar": "...", "en": "..." } }` |

- The Postman `/tenders` example (`name: { en, ar }`) is **stale** for
  Pattern A — refresh to `name_ar` / `name_en`.
- A field's pattern must not switch shape across endpoints. Document
  per-resource exceptions in Postman if any arise.
- Frontend `Bilingual.t(obj, field, locale)` accepts both; no client
  change needed.
- Future: a global API Resource transform may unify the public JSON to
  Pattern B only. Out of scope for this handoff; requires convention
  doc amendment in the same PR if pursued.

---

## Flow 1 · Login (tenant)

### 1.1 Endpoint (✅ verified)

| | |
|---|---|
| Method | `POST` |
| Path | `/auth/login` |
| Auth | none (public) |
| Branch-scoped | no |
| Idempotent | no |

### 1.2 Request body
```json
{
  "email":        "owner@acme.sa",
  "password":     "secret123",
  "company_slug": "acme"
}
```

### 1.3 Response (200)
```json
{ "data": {
  "token":  "1|abc...",
  "user":   { "id": "01HXY...", "name": "Faisal", "email": "owner@acme.sa", "preferred_locale": "ar" },
  "company":{ "id": "01HXC...", "slug": "acme", "status": "active" },
  "branches":[{ "id": "01HXB..." }],          // D4: IDs only
  "mfa_required": false
} }
```

**D4 ✅ minimal branch context.** Login returns `branches: [{id}]` only.
Full branch rows (name, code) come from `GET /me` and `GET /me/branches`.

### 1.4 Frontend call site (today)

There is **no real sign-in screen** wired to `/auth/login` in the prototype.
The session is hard-coded:

```js
// front/common/api.js, lines ~106-115
const session = {
  token:    null,                  // never populated
  user:     { id: 'u-faisal', ... },
  company:  { id: ulid(), ... },
  branchId: 'b1',
  ...
};
```

❓ **MISSING — sign-in screen.** Build one. Suggested location:
`front/auth/sign-in.compiled.js`, mounted before the dashboard router. On
success it calls `API.session.set({ token, user, company, branchId,
permissions, locale })` then navigates.

### 1.5 Errors the UI must handle

Postman has no error examples; Cursor confirm:

| Scenario | Expected status | Expected `error.code` | Prototype state |
|---|---|---|---|
| Wrong password | 401 | `INVALID_CREDENTIALS` | Inline form error: "Email or password is incorrect" |
| Email not found | 401 (not 404 — security) | `INVALID_CREDENTIALS` | Same as above |
| Company slug not found | 404 | `COMPANY_NOT_FOUND` | Inline error on slug field |
| Tenant suspended | 403 | `TENANT_SUSPENDED` | Full-page banner with support link |
| MFA required | 200 with `mfa_required: true` | — | Navigate to MFA screen `/auth/mfa` |
| Rate-limited | 429 | `THROTTLED` | "Too many attempts, try again in {meta.retry_after}s" |

### 1.6 Cursor checklist for Flow 1

- [ ] Confirm `routes/api.php` has `Route::post('auth/login', ...)` outside the `auth:sanctum` middleware
- [ ] Controller returns the exact envelope in §1.3 (token comes from `$user->createToken('pos')->plainTextToken`)
- [ ] Validate `company_slug` lookup happens before password check (don't leak existence)
- [ ] All 6 error scenarios in §1.5 produce the documented envelope
- [ ] Add a Postman example for at least one error response
- [ ] DB query to verify after Postman call:
      `select id, email from users where email = 'owner@acme.sa';`
      `select id, tokenable_id, name from personal_access_tokens order by id desc limit 1;`

---

## Flow 2 · Me

### 2.1 Endpoint (✅ verified)

| | |
|---|---|
| Method | `GET` |
| Path | `/me` |
| Auth | required (Bearer) |
| Branch-scoped | no |

### 2.2 Response (200)
```json
{ "data": {
  "id": "01HXY...",
  "name": "Faisal",
  "email": "owner@acme.sa",
  "roles": ["owner"],
  "permissions": [                       // D3: required
    "pos.sales.create", "pos.tenders.view", "catalog.products.view",
    "reports.sales.view", "hr.employees.view"
  ],
  "preferred_locale": "ar",
  "branches": [
    { "id": "01HXB1...", "name_ar": "العليا",  "name_en": "Olaya",  "code": "RUH-01" },
    { "id": "01HXB2...", "name_ar": "الخبر", "name_en": "Khobar", "code": "KHB-01" }
  ],
  "modules": ["retail", "dine", "pay", "accounting", "hr"]
}}
```

### 2.3 Decisions applied

- **D1 ✅ IDs:** ULIDs everywhere.
- **D3 ✅ permissions:** flat string array, resolved server-side via Spatie
  (or equivalent). Frontend gates use `permissions.includes('...')`.
- **D4 ✅ branches shape:** `/me` returns the full DTO
  (`{id, name_ar, name_en, code}`) — same shape as `/me/branches`.
- **D10 ✅ bilingual:** branch `name` is Pattern A (indexed) →
  `name_ar` + `name_en`.

### 2.4 Frontend call site (today)

Currently `front/common/api.js` line ~107: `user` is hard-coded.
After login, the dashboard needs to call `/me` and write the result into
the session before any other call fires.

### 2.5 Cursor checklist for Flow 2

- [ ] Confirm `Route::get('me', ...)->middleware('auth:sanctum')`
- [ ] Add `permissions: array` to response, even if empty for now
- [ ] Decide one shape for `branches` and use it everywhere (`/auth/login`, `/me`, `/me/branches`)
- [ ] DB verification after curl:
      `select id, name, email, preferred_locale from users where id = 1;`
      `select * from model_has_roles where model_id = 1;` (if spatie)

---

## Flow 3 · My Branches

### 3.1 Endpoint (✅ verified)

| | |
|---|---|
| Method | `GET` |
| Path | `/me/branches` |
| Auth | required |
| Branch-scoped | no |

### 3.2 Response (200)
```json
{ "data": [
  { "id": "01HXB1...", "name_ar": "العليا",  "name_en": "Olaya",  "code": "RUH-01" },
  { "id": "01HXB2...", "name_ar": "الخبر", "name_en": "Khobar", "code": "KHB-01" }
], "meta": { "page": 1, "per_page": 25, "total": 2 } }
```

**D1 ✅** ULIDs. **D10 ✅** Pattern A: indexed `name_ar`/`name_en`.

### 3.3 Branch switcher contract

Once the user picks a branch, the prototype writes it to
`session.branchId` and that value is auto-injected as `X-Branch-Id` on
every subsequent branch-scoped call.

⚠️ **Cursor task:** confirm a middleware checks
`X-Branch-Id ∈ user.branches` and returns 403 `BRANCH_FORBIDDEN` otherwise.

### 3.4 Errors

| Scenario | Status | `error.code` |
|---|---|---|
| Token missing/invalid | 401 | `UNAUTHENTICATED` |
| User has no branches | 200 with `data: []` | — |

### 3.5 Cursor checklist for Flow 3

- [ ] Add `branches.code` column if not present (Postman shows it; check `branches` migration)
- [ ] Endpoint scopes by `auth()->user()` → branches via pivot or scope
- [ ] DB verification:
      `select b.id, b.name, b.code from branches b join branch_user bu on bu.branch_id=b.id where bu.user_id=1;`

---

## Flow 4 · POS Sale (Capture)

This is the keystone flow. Three documented routes participate:
**Open shift → Capture sale → Get receipt.**

### 4.1 Endpoints involved

| Route | Method | Required headers | Purpose |
|---|---|---|---|
| `/tenders` | GET | Bearer | List allowed tenders for the branch (cash/mada/visa/...) |
| `/shifts/open` | POST | Bearer + `X-Branch-Id` | Cashier opens shift with float |
| `/sales` | POST | Bearer + `X-Branch-Id` + `Idempotency-Key` | Capture the sale |
| `/sales/{id}/receipt` | GET | Bearer | Render receipt HTML/PDF |
| `/sales/{id}/share` | POST | Bearer | Send via WhatsApp/email/SMS |
| `/shifts/close` | POST | Bearer + `X-Branch-Id` | Cashier closes shift |

### 4.2 Open Shift (✅ verified)

**Request:**
```json
POST /shifts/open
Headers: Authorization, X-Branch-Id
Body:
{ "register_id": 1, "opening_float": 500 }
```

**Response (201):**
```json
{ "data": { "id": 88, "opened_at": "..." } }
```

⚠️ **Thin response.** Cursor should expand to include `register`, `cashier_id`,
and the float currency. Otherwise the UI has to hold register/cashier
state separately.

❓ **MISSING — `GET /shifts/current`?** The prototype needs to check
"is there an open shift on this register?" on POS load. Postman has no
read endpoint. Cursor task: add `GET /shifts/current?register_id=` (returns
the open shift or 404).

### 4.3 List Tenders

**Request:**
```
GET /tenders
Headers: Authorization, X-Branch-Id
```

**Response (200) — D5 + D10 applied:**
```json
{ "data": [
  { "code": "cash",        "name_ar": "نقدي",       "name_en": "Cash",         "requires_pin": false, "requires_reference": false, "allows_change": true,  "min_amount": null, "max_amount": null },
  { "code": "mada",        "name_ar": "مدى",        "name_en": "Mada",         "requires_pin": false, "requires_reference": true,  "allows_change": false, "min_amount": null, "max_amount": null },
  { "code": "visa",        "name_ar": "فيزا",      "name_en": "Visa",         "requires_pin": false, "requires_reference": true,  "allows_change": false, "min_amount": null, "max_amount": null },
  { "code": "mastercard",  "name_ar": "ماستركارد", "name_en": "Mastercard",   "requires_pin": false, "requires_reference": true,  "allows_change": false, "min_amount": null, "max_amount": null },
  { "code": "applepay",    "name_ar": "آبل باي",   "name_en": "Apple Pay",    "requires_pin": false, "requires_reference": true,  "allows_change": false, "min_amount": null, "max_amount": null },
  { "code": "stcpay",      "name_ar": "STC باي",   "name_en": "STC Pay",      "requires_pin": false, "requires_reference": true,  "allows_change": false, "min_amount": null, "max_amount": null },
  { "code": "sadad",       "name_ar": "سداد",       "name_en": "SADAD",        "requires_pin": false, "requires_reference": true,  "allows_change": false, "min_amount": null, "max_amount": null },
  { "code": "giftcard",    "name_ar": "بطاقة هدية", "name_en": "Gift Card",    "requires_pin": false, "requires_reference": true,  "allows_change": false, "min_amount": null, "max_amount": null },
  { "code": "storecredit", "name_ar": "رصيد المتجر", "name_en": "Store Credit", "requires_pin": true,  "requires_reference": false, "allows_change": false, "min_amount": null, "max_amount": null }
] }
```

### 4.3.1 Decisions applied

- **D5 ✅ closed enum.** v1 codes: `cash | mada | visa | mastercard | applepay | stcpay | sadad | giftcard | storecredit`. Add `account_credit` when B2B on-account ships.
- **D5 ✅ `split` is not a tender.** It's a UI mode that produces multiple `payments[]` rows.
- **D10 ✅ Pattern A bilingual** — `name_ar` / `name_en` siblings.
- Per-branch enabling: backend filters this list by branch (e.g. only mada in store A, +sadad in store B).
- Validation: `POST /sales` 422 with `TENDER_NOT_ALLOWED` if a payment uses a code not in this list for the current branch.

### 4.4 Capture Sale

**Request:**
```json
POST /sales
Headers: Authorization, X-Branch-Id, Idempotency-Key
Body:
{
  "customer_id": null,                   // null = walk-in; else ULID
  "lines": [
    { "product_id": "01HXP1...", "qty": 1 },
    { "product_id": "01HXP2...", "qty": 2,
      "discount": { "type": "pct", "value": 10 } }
  ],
  "payments": [
    { "tender": "mada",
      "amount": { "amount_minor": 5600, "currency_code": "SAR" },
      "ref": "txn_xx" }
  ],
  "promo_codes": ["RAMADAN10"]
}
```

**Response (201):**
```json
{ "data": {
  "id": "01HXS50001...",
  "total": { "amount_minor": 5600, "currency_code": "SAR" },
  "vat":   { "amount_minor":  730, "currency_code": "SAR" },
  "zatca_uuid": "...", "zatca_qr": "...",
  "lines": [
    { "id": "01HXSL1...", "total": { "amount_minor": 1800, "currency_code": "SAR" } },
    { "id": "01HXSL2...", "total": { "amount_minor": 3800, "currency_code": "SAR" } }
  ],
  "receipt_url": "https://.../sales/01HXS50001.../receipt"
}}
```

### 4.4.1 Decisions applied

- **D1 ✅ ULIDs:** `id`, `customer_id`, `product_id`, `lines[].id` are all ULIDs.
- **D2 ✅ Money:** every monetary value is `{ amount_minor, currency_code }`. Halalas in `amount_minor` (5600 = 56.00 SAR).
- **D5 ✅ Tender:** `payments[].tender` must be a code from `GET /tenders` for the current branch.

### 4.4.2 Frontend adapter notes (no backend impact)

The prototype's current cart shape (`{id, sku, name, price, qty}`) and
checkout state (`method`, `tender`, `discountPct`, `splitCash`) don't
match this body. The frontend adapter (see §6.4) will translate at the
boundary:

- `cart[i].id` — today a string SKU — must become the product **ULID** at
  cart-add time (lookup from product search response). The SKU stays for
  display/scan only.
- `cart[i].name` and `cart[i].price` are display-only; **never sent**.
  Backend looks up canonical price per `product_id`.
- The wrapping field is `lines`, not `items`.
- `payments[]` is built from `(method, total, splitCash, ref?)`.
- `discountPct` (sale-level today) becomes per-line `discount: {type, value}`. ZATCA-friendlier; aligns with backend.

❓ **MISSING — error envelope examples.** Cursor confirm error codes:

| Scenario | Status | `error.code` |
|---|---|---|
| Idempotency replay (same key, different body) | 409 | `IDEMPOTENCY_CONFLICT` |
| Idempotency replay (same body) | 200/201 with original response | — |
| Insufficient stock | 422 | `OUT_OF_STOCK` (with `fields.product_id`) |
| Unknown product | 422 | `PRODUCT_NOT_FOUND` |
| Tender disabled for branch | 422 | `TENDER_NOT_ALLOWED` |
| Promo code invalid/expired | 422 | `PROMO_INVALID` |
| Total mismatch (sum of payments ≠ total) | 422 | `PAYMENT_BALANCE` |
| Shift not open | 409 | `SHIFT_REQUIRED` |
| ZATCA submission failed | 503 | `ZATCA_UNAVAILABLE` (queued for retry) |

### 4.5 Receipt — D6 ✅ hybrid

**Decision (signed off):** Hybrid rendering.

- **In-store / thermal print:** rendered **client-side** from the Capture
  Sale response. Uses `zatca_qr`, `zatca_uuid`, totals, VAT, lines. Fast,
  works offline, prints to 58mm immediately.
- **Share, archive, disputes:** rendered **server-side** via
  `GET /sales/{id}/receipt?format=html|pdf`. Canonical version. Fed into
  the share pipeline (§4.6).

Two templates: client React `<CheckoutReceipt>` + server Blade. Layout
can differ; **business fields must agree** (totals, VAT, ZATCA QR,
line-level prices and discounts).

**Server endpoint:** `GET /sales/{id}/receipt?format=html|pdf`

**Response (200):**
```json
{ "data": {
  "html": "<html>...</html>",          // when format=html
  "pdf_url": "https://signed.s3...",   // when format=pdf, signed URL
  "expires_at": "2026-04-27T18:00:00Z" // PDF URL lifetime
}}
```

⚠️ **Cursor tasks (server-render only):**
- Decide signed-URL lifetime (recommend ≥ 24h so customer email links don't dead-end)
- HTML must be self-contained: inline CSS, embedded QR data-URI, no external font URLs (works in email clients)
- ZATCA QR included in both client and server templates

### 4.6 Share Receipt — D7 ✅ v1 channels

**Decision (signed off):** v1 ships **`whatsapp` + `email`**. SMS deferred.
**Print is client-only** (no `/share` channel — direct to ESC/POS or AirPrint).

**Request:**
```json
POST /sales/{id}/share
Body: { "channel": "whatsapp" | "email", "to": "+9665XXXXXXX" | "customer@acme.sa" }
```

**Response (202):**
```json
{ "data": { "dispatch_id": "01HXD..." } }
```

**Validation:**
- `channel` enum: `whatsapp | email` (reject others 422 `INVALID_CHANNEL`)
- `to` matches the channel: phone for whatsapp, email for email
- 202 = queued; UI shows "Sent ✓" toast and doesn't block
- Failures (provider down, invalid number) surface via dispatch status —
  out of scope here, but add `GET /dispatches/{id}` later for delivery receipts

### 4.7 Frontend call site (today)

`front/retail/retail-checkout.compiled.js` lines 134-160 builds a `txn`
object purely for local state. There is **no `fetch('/sales')` call
today**. The mutation hook lives in the prototype's mock router.

### 4.8 Cursor checklist for Flow 4

#### Backend tasks
- [ ] **D9 ✅** Add `GET /shifts/current?register_id={id}` — returns open shift or 404
- [ ] Confirm `POST /shifts/open` returns `register`, `cashier_id`, `currency_code`
- [ ] **D5 ✅** `/tenders` returns the canonical 9-code list with all flags (§4.3)
- [ ] **D2 ✅** All monetary fields use `{ amount_minor, currency_code }` shape via `MoneyCast`
- [ ] Confirm `POST /sales` enforces idempotency: same `Idempotency-Key` + same body → cached response; same key + different body → 409
- [ ] Document all 9 error scenarios in §4.4 with concrete examples
- [ ] **D6 ✅** Server-render `GET /sales/{id}/receipt` for share/archive (HTML + signed PDF URL)
- [ ] **D7 ✅** `POST /sales/{id}/share` accepts `channel: whatsapp | email` only

#### Postman additions
- [ ] Refresh ALL fixtures: ULIDs (D1), `amount_minor` shape (D2), `name_ar`/`name_en` for tenders (D10A)
- [ ] Add error response examples for each `error.code` enumerated above
- [ ] Add a successful idempotency replay example (same body + key, second call returns 200 with `data` from first)

#### Postman additions
- [ ] Add error response examples for each `error.code` enumerated above
- [ ] Add a successful idempotency replay example (same body, same key, second call returns 200 with `data` from first)

#### DB verification queries
After running the Capture Sale curl from Postman:
```sql
-- the sale itself
select id, branch_id, register_id, total, vat, zatca_uuid, status, created_at
from sales order by id desc limit 1;

-- lines
select id, sale_id, product_id, qty, unit_price, discount_amount, line_total
from sale_lines where sale_id = (select max(id) from sales);

-- payments
select id, sale_id, tender, amount, reference, processed_at
from sale_payments where sale_id = (select max(id) from sales);

-- stock movements (the sale should decrement stock)
select id, product_id, branch_id, kind, qty, ref_type, ref_id
from stock_movements where ref_type='sale' and ref_id=(select max(id) from sales);

-- ZATCA queue (B2C clears immediately, B2B is async)
select id, sale_id, status, attempts, last_error, submitted_at
from zatca_submissions order by id desc limit 1;

-- idempotency record (so replays return cached response)
select key, route, response_body_hash, status_code, created_at
from idempotency_keys order by created_at desc limit 1;
```

---

## Flow 5 · Sales listing & receipt display — D8 ✅

Receipt rendering is covered in §4.5 (D6 hybrid). The remaining piece is
the **sales listing** UI's "Today's transactions" panel and the
"Sales history" report read.

### 5.1 Endpoint (D8 ✅ — add)

```
GET /sales?from=&to=&branch_id=&cashier_id=&status=&page=&per_page=
Headers: Authorization, X-Branch-Id (when filtering by current branch)
```

### 5.2 Response (200)

```json
{ "data": [
  { "id": "01HXS50001...",
    "created_at": "2026-04-27T14:32:11Z",
    "customer": { "id": "01HXC...", "name_ar": "فيصل", "name_en": "Faisal" },
    "total": { "amount_minor": 5600, "currency_code": "SAR" },
    "vat":   { "amount_minor":  730, "currency_code": "SAR" },
    "payments": [ { "tender": "mada", "amount": { "amount_minor": 5600, "currency_code": "SAR" } } ],
    "status": "completed",                  // completed | refunded | voided | pending_zatca
    "cashier": { "id": "01HXU...", "name_ar": "...", "name_en": "..." }
  }
], "meta": { "page": 1, "per_page": 25, "total": 142 } }
```

**Decisions applied:** D1 ULIDs, D2 Money, D10A `name_ar`/`name_en`.

**Row shape rule:** the list-row `Sale` is a strict subset of the
`GET /sales/{id}` detail — same field names, same shapes, no renames.
Add fields to detail; never to list-only.

### 5.3 Cursor checklist

- [ ] Index controller with eager-loaded `customer`, `cashier`, `payments`
- [ ] Filterable scopes: `from`/`to` on `created_at`; `branch_id`, `cashier_id`, `status`
- [ ] Default sort: `created_at desc`
- [ ] Standard pagination (`page` / `per_page` — cap `per_page` at 100)
- [ ] Optional v1.1: `GET /sales/today` aggregating by tender for the shift drawer

---

## 6. Adapter to write (frontend side, post-handoff)

When Cursor finishes the backend tasks above, here's exactly what to
change on the frontend:

### 6.1 `front/common/api.js`

- Flip `MOCK_MODE: false`
- Confirm `API_BASE_URL: 'http://localhost:8000/api/v1'`
- **D1 ✅** Drop `ulid()` for server-owned entities. IDs come back from POSTs.
- **D2 ✅** Keep `Money.fmt(amount_minor, locale)`; backend now sends matching shape.
- **D3 ✅** Replace `hasPermission` role-mapping (if any) with flat `permissions.includes(...)`.

### 6.2 New file: `front/auth/sign-in.compiled.js`

- Form: email, password, company_slug
- Submit → `POST /auth/login` via `API._call('POST', '/auth/login', body)`
- On success: `API.session.set({ token, user, company, branchId: branches[0].id, locale: user.preferred_locale })` then immediately fire `GET /me` to populate `permissions` and full branch DTOs (D4: login returned IDs only).
- On `mfa_required: true`: navigate to MFA screen (separate handoff)
- Render error envelope per §1.5

### 6.3 New file: `front/auth/branch-picker.compiled.js`

- After login, if user has >1 branch, force pick before dashboard mounts
- `GET /me/branches`, render list, on click → `API.session.set({ branchId })`
- D4 ✅: full DTOs (`{id, name_ar, name_en, code}`) come from `/me/branches`, not from login

### 6.4 `front/retail/retail-checkout.compiled.js`

Insert at the moment the user clicks "Charge":

```js
try {
  const sale = await API._call('POST', '/sales', {
    customer_id: customer ? customer.id : null,    // ULID or null
    lines: cart.map(x => ({
      product_id: x.product_id,                    // ULID, captured at cart-add
      qty: x.qty,
      discount: x.discountPct > 0
        ? { type: 'pct', value: x.discountPct }
        : undefined,
    })),
    payments: buildPayments(method, totalMinor, splitCashMinor, tenderRef),
    // payments[i] = { tender, amount: {amount_minor, currency_code}, ref? }
    promo_codes: promoCode ? [promoCode] : [],
  }, {
    idempotencyKey: idempotencyKeyForThisAttempt,  // stable across retries
  });
  setTxn({
    ...sale,
    zatcaQr: sale.zatca_qr,
    zatcaUuid: sale.zatca_uuid,
    totalMinor: sale.total.amount_minor,
    vatMinor:   sale.vat.amount_minor,
  });
  setStage('receipt');
} catch (err) {
  switch (err.error?.code) {
    case 'OUT_OF_STOCK':          showInlineStockError(err.error.fields); break;
    case 'IDEMPOTENCY_CONFLICT':  showRetrySafeError();                   break;
    case 'SHIFT_REQUIRED':        redirectToShiftOpen();                  break;
    case 'TENDER_NOT_ALLOWED':    showTenderError(err.error.fields);      break;
    case 'PROMO_INVALID':         showPromoError(err.error.fields);       break;
    case 'PAYMENT_BALANCE':       showBalanceError();                     break;
    case 'ZATCA_UNAVAILABLE':     showZatcaQueuedNotice();                break;
    default:                       showGenericError(err);
  }
}
```

### 6.5 `front/common/api.js` — register the routes

```js
defineRoute('POST', '/auth/login',          handler);
defineRoute('GET',  '/me',                  handler);
defineRoute('GET',  '/me/branches',         handler);
defineRoute('GET',  '/me/modules',          handler);
defineRoute('GET',  '/me/setup-progress',   handler);
defineRoute('GET',  '/tenders',             handler);
defineRoute('POST', '/shifts/open',         handler);
defineRoute('POST', '/shifts/close',        handler);
defineRoute('GET',  '/shifts/current',      handler);   // D9 ✅
defineRoute('GET',  '/sales',               handler);   // D8 ✅
defineRoute('POST', '/sales',               handler);
defineRoute('GET',  '/sales/:id',           handler);
defineRoute('GET',  '/sales/:id/receipt',   handler);
defineRoute('POST', '/sales/:id/share',     handler);
```

The `_call` already mock-vs-real switches on `CONFIG.MOCK_MODE`, so
handlers are only used in mock mode. Once `MOCK_MODE=false`, the handler
bodies are never invoked — `_call` does real `fetch()`.

---

## 7. Decisions D1–D10 — SIGNED OFF (owner, 2026-04-27)

These are **locked**. Do not reopen unless product changes a feature
requirement. See repo `docs/02-CONVENTIONS.md §5a` for the canonical record.

| # | Topic | Locked decision |
|---|---|---|
| **D1** | ID format | **ULIDs** (26-char strings). Client never mints server-owned IDs. |
| **D2** | Money in JSON | **`{ amount_minor, currency_code }`**. Halalas, bigint DB. No SAR floats. |
| **D3** | `/me` permissions | **`roles[]` + `permissions[]`** — flat strings, server-resolved (Spatie). |
| **D4** | Login branches | **`branches: [{id}]`** only. Full DTOs via `/me` & `/me/branches`. |
| **D5** | Tender codes | Closed enum: `cash, mada, visa, mastercard, applepay, stcpay, sadad, giftcard, storecredit` (+ `account_credit` when B2B ships). `split` is **not** a tender. |
| **D6** | Receipt rendering | **Hybrid:** client-side for in-store/thermal; server-side HTML/PDF for share/archive/disputes. |
| **D7** | Share channels | v1: **`whatsapp` + `email`**. SMS deferred. Print is client-only. |
| **D8** | `GET /sales` listing | **Add now.** Filters: `from, to, branch_id, cashier_id, status, page, per_page`. |
| **D9** | `GET /shifts/current` | **Add now.** Returns open shift or 404. |
| **D10** | Bilingual JSON | **Pattern A** (`name_ar` / `name_en` siblings) for indexed names; **Pattern B** (`{ar, en}` JSON) for prose. Per `02-CONVENTIONS.md § Bilingual`. |

### 7.1 Follow-up tasks (engineering, not reopening D1–D10)

- **Postman refresh:** ULIDs (D1), `amount_minor` (D2), `permissions[]` in `/me` (D3), `branches:[{id}]` only on login (D4), full 9-tender list with flags (D5), `name_ar`/`name_en` for indexed names (D10A), add D8 + D9 collections.
- **Backend new routes:** `GET /shifts/current`, `GET /sales`, `GET /sales/{id}` (if not present), expand `POST /shifts/open` response.
- **Prototype:** drop integer ID assumptions; tender grid reads from `GET /tenders`; share modal restricted to whatsapp + email; checkout discount becomes per-line.

---

## 8. What this doc is **not**

- Not the contract spec — `docs/02-CONVENTIONS.md` + Postman are. This translates them into frontend implications.
- Not a backend implementation guide — Cursor decides Eloquent shapes, services, etc.
- Not a migration plan — this is the *handoff*; after backend is ready, the prototype-side adapter (§6) is a separate ticket.
- Not validated against a running server — every "✅" tag refers to alignment with signed-off decisions and Postman *as it should be after refresh*, not live HTTP.
