# Shifts, Voids, Refunds, X/Z-Reports & Day-Close — Endpoint Mapping

> **Verified accurate:** 2026-05-02 — D11 (cashier-immediate refund) is the doc that introduced this decision; D1–D11 now consolidated in `docs/handoff/DECISIONS.md §5a`. State machines (`opened → closing → closed`), tender variance contract, and refund vs RMA branching match the live `front/pos/*` and `api-namespace.js` shifts handlers.
> **Status:** signed-off endpoint mapping with concrete migration shapes; §11 Cursor checklist still actionable.

> **Companion doc.** Read `handoff/POS-flow-endpoint-mapping.md` first. This doc picks up where capture-sale ends and walks the **closing bookend** of the cashier daily cycle: open shift → mid-shift voids/refunds/cash movements → X-report → close shift (Z-report) → branch day-close.

> **Scope.** Tenant-staff cashier flows on the retail/dine POS. Pay-rail acquirer refunds (`/pay/transactions/{id}/refund`) and full RMA returns (`/rma/...`) are referenced but specified in their own handoffs. Courier shifts (`/couriers/{id}/close-shift`) are out of scope.

> **Audience.** Backend implementer (Cursor) verifying that Laravel routes, controllers, request validators, idempotency, and DB schema match this contract.

---

## Legend

- ✅ **Postman-verified** — exact path, method, request body, response shape exist in `docs/postman/DALSEEN.postman_collection.json`. Cursor verifies the route file matches.
- ❓ **MISSING** — endpoint required by the daily cycle but not in Postman. Spec below is a **suggested contract**; Cursor adds the route, controller, validator, Postman entry, and OpenAPI spec.
- ⚠️ **AMBIGUOUS** — Postman has overlapping endpoints; Cursor confirms which one this flow uses (or adds a new lightweight path).

---

## Inheritance

This doc inherits **all** conventions from `POS-flow-endpoint-mapping.md`:

- ULID IDs, `Authorization: Bearer`, `Accept-Language`, `X-Branch-Id` on branch-scoped routes, `Idempotency-Key` on POSTs
- Money as `{amount_minor, currency_code}` (halalas, never floats)
- Bilingual `name_ar`/`name_en` plus `message_ar`/`message_en` in errors
- Canonical envelope: success `{data, meta}` / error `{error: {code, message_en, message_ar, fields}}`
- 5-state UI (idle / loading / empty / error / success); state-machine constraints surfaced in UI

**All D-level decisions referenced here are approved** — see `DECISIONS.md` D1–D11. D11 (cashier-immediate refund path, §6) is locked per `DECISIONS.md` §5a.

---

## Actor & state model

**One actor:** Tenant cashier with `pos.shifts.open`, `pos.shifts.close`, `pos.sales.void`, `pos.sales.refund`, `pos.cash.movements` permissions. Some operations require **manager PIN** override (server-side checks PIN against a manager user account on the same branch).

**Shift state machine:**
```
opened → (sales/voids/refunds/cash-movements happen) → closed
```
`opened` is exclusive per `(register_id, cashier_user_id)` — server returns 409 if cashier already has an open shift, or if the register already has an open shift by anyone else.

**Register state model:** A `register` is the physical/logical till (POS terminal). Registers belong to a branch. A register can host **at most one open shift** at a time. ❓ MISSING register CRUD endpoints — flagged at end.

**Sale state machine** (from POS doc, repeated for clarity):
```
draft → captured → (voided | refunded)
                 ↘ (settled by EOD posting)
```
- `voided` = entire sale negated, ZATCA invoice issued as credit-note, GL reversed
- `refunded` = partial or full money returned to customer (new tender movement); original sale stays `captured`

---

## Flow inventory

| # | Flow | Method + Path | Status |
|---|---|---|---|
| 1 | Open shift | `POST /shifts/open` | ✅ |
| 2 | Get current shift | `GET /shifts/current` | ❓ MISSING (D9 contract approved) |
| 3 | List tenders (drives close UI) | `GET /tenders` | ✅ |
| 4 | Cash movement (paid-in / paid-out) | `POST /shifts/{id}/cash-movements` | ❓ MISSING |
| 5 | Void sale | `POST /sales/{id}/void` | ✅ |
| 6 | Refund sale (cashier-immediate) | `POST /sales/{id}/refund` | ❓ MISSING — D11 ✅ approved (see `DECISIONS.md` §5a) |
| 7 | X-report (mid-shift, read-only) | `GET /shifts/{id}/x-report` | ❓ MISSING |
| 8 | Close shift (returns Z-report data) | `POST /shifts/close` | ✅ |
| 9 | Branch day-close / EOD rollup | `POST /branches/{id}/day-close` | ❓ MISSING |
| 10 | Voids & returns report (post-hoc) | `GET /reports/sales/voids-returns` | ✅ |

---

## §1 — Open shift

**Endpoint:** `POST /shifts/open` ✅ Postman-verified.

**Permission:** `pos.shifts.open`

**Headers:** `Authorization`, `Accept-Language`, `X-Branch-Id`, `Idempotency-Key`

**Request body:**
```json
{
  "register_id": "01J...REG...",
  "opening_float": { "amount_minor": 50000, "currency_code": "SAR" }
}
```

> ⚠️ **Postman drift.** Postman shows `"opening_float": 500` (scalar). Per D2 (money is always `{amount_minor, currency_code}`), this MUST be normalized to the Money envelope before launch. Cursor task: update Postman + validator + DB column.

**Response 201:**
```json
{
  "data": {
    "id": "01J...SHIFT...",
    "register_id": "01J...REG...",
    "branch_id": "01J...BRANCH...",
    "cashier_user_id": "01J...USER...",
    "opening_float": { "amount_minor": 50000, "currency_code": "SAR" },
    "opened_at": "2026-04-29T08:00:00Z",
    "status": "opened"
  },
  "meta": { "request_id": "..." }
}
```

**Validation:**
- `register_id` exists, belongs to `X-Branch-Id`
- `opening_float.amount_minor >= 0`
- `opening_float.currency_code` matches branch's currency

**Error scenarios (UI must handle each):**

| HTTP | `error.code` | When | UI |
|---|---|---|---|
| 422 | `validation.failed` | missing `register_id` / negative float | Inline field errors |
| 403 | `permission.denied` | user lacks `pos.shifts.open` | `<Forbidden>` |
| 404 | `register.not_found` | register doesn't exist OR belongs to other branch | "Register not available on this branch" |
| 409 | `shift.register_already_open` | another cashier has open shift on this register | "Register has open shift by {cashier_name}. Close it first or pick another." |
| 409 | `shift.cashier_already_open` | this cashier already has open shift (any register, any branch) | "You already have an open shift on {register_label}. Continue it or close it." |
| 409 | `idempotency.conflict` | same `Idempotency-Key` replayed with different body | Show original response |
| 423 | `branch.day_closed` | branch is in `day_closed` state — no shifts allowed | "Branch closed for the day. Manager must reopen." |

**Side effects:**
- INSERT `shifts` row with `status='opened'`, `opening_float_minor`, `opened_at`
- Cashier session now has `current_shift_id` (server derives from `cashier_user_id` + `status='opened'`; not stored on user)
- No GL posting yet — opening float is already in the drawer (transferred from safe via separate cash-movement, or carried over from prior close)

**Cursor checklist:**
- [ ] Route `POST /shifts/open` exists in `routes/api/v1/pos.php` with `permission:pos.shifts.open` middleware
- [ ] `Idempotency` middleware applied
- [ ] Request validator enforces Money envelope on `opening_float`
- [ ] Controller checks register-uniqueness AND cashier-uniqueness, returns specific 409 codes
- [ ] DB: `shifts` table has `(register_id, status='opened')` partial unique index
- [ ] DB: `shifts` table has `(cashier_user_id, status='opened')` partial unique index
- [ ] Postman example updated to Money envelope

---

## §2 — Get current shift

**Endpoint:** `GET /shifts/current` ❓ MISSING — **D9 contract approved**, Cursor adds.

**Why required:** POS UI loads on every cashier action; needs to know whether a shift is open and which one. Without this, every page does its own shift-discovery query.

**Permission:** `pos.shifts.view`

**Headers:** `Authorization`, `X-Branch-Id`

**Request:** none

**Response 200 — shift open:**
```json
{
  "data": {
    "id": "01J...SHIFT...",
    "register_id": "01J...REG...",
    "register_label": "Counter 1",
    "branch_id": "01J...BRANCH...",
    "cashier_user_id": "01J...USER...",
    "opening_float": { "amount_minor": 50000, "currency_code": "SAR" },
    "opened_at": "2026-04-29T08:00:00Z",
    "status": "opened",
    "running_totals": {
      "sales_count": 47,
      "sales_gross": { "amount_minor": 1284500, "currency_code": "SAR" },
      "voids_count": 2,
      "voids_amount": { "amount_minor": 8800, "currency_code": "SAR" },
      "refunds_count": 1,
      "refunds_amount": { "amount_minor": 4500, "currency_code": "SAR" },
      "cash_movements_count": 1,
      "by_tender": [
        { "tender_code": "cash", "amount": { "amount_minor": 320000, "currency_code": "SAR" } },
        { "tender_code": "mada", "amount": { "amount_minor": 955700, "currency_code": "SAR" } }
      ]
    }
  },
  "meta": { "request_id": "..." }
}
```

**Response 200 — no open shift:**
```json
{ "data": null, "meta": { "request_id": "..." } }
```
> Returns `data: null` (NOT 404). This lets the UI distinguish "you have no shift, please open one" from "the endpoint is broken." 404 is reserved for "shift {id} not found" on the byId variant.

**Implementation:** Server query: `shifts WHERE cashier_user_id = ? AND status = 'opened' LIMIT 1`. Running totals aggregated server-side from `sales`, `cash_movements`, `refunds` tables joined on `shift_id`.

**Performance note for Cursor:** Running totals on every poll = expensive at scale. Recommend either (a) cached counter columns updated by sale/void/refund triggers, or (b) Redis counter incremented on each event. Document choice; UI doesn't care.

**Error scenarios:**

| HTTP | `error.code` | When | UI |
|---|---|---|---|
| 401 | `auth.unauthenticated` | no/expired token | Redirect to login |
| 403 | `permission.denied` | lacks `pos.shifts.view` | `<Forbidden>` |

**UI usage:**
- POS shell calls this on mount + every 30s (or on focus-back)
- If `data: null` → show "Open shift" CTA covering the till UI
- If `data: {status: 'opened'}` → show shift chip in header with `register_label` and elapsed time
- Running totals power the X-report side panel (§7)

**Cursor checklist:**
- [ ] Add route `GET /shifts/current` in `routes/api/v1/pos.php`
- [ ] Controller returns `null` (not 404) when no open shift
- [ ] Add Postman entry under "05 — Shifts" folder
- [ ] Add OpenAPI spec
- [ ] Decide cache strategy for running totals; document in `docs/02-CONVENTIONS.md`

---

## §3 — List tenders (drives close-shift UI)

**Endpoint:** `GET /tenders` ✅ Postman-verified.

**Why this lives here:** §8 (close shift) requires the cashier to count cash + each electronic tender. The set of tenders is per-tenant configurable, so the close-shift form is dynamic. Without this endpoint, the frontend can't render the right rows.

**Permission:** `pos.tenders.view`

**Response 200:**
```json
{
  "data": [
    { "code": "cash", "name_en": "Cash", "name_ar": "نقد", "is_cash": true, "requires_count": true, "active": true },
    { "code": "mada", "name_en": "Mada", "name_ar": "مدى", "is_cash": false, "requires_count": true, "active": true },
    { "code": "visa", "name_en": "Visa", "name_ar": "فيزا", "is_cash": false, "requires_count": true, "active": true },
    { "code": "stc_pay", "name_en": "STC Pay", "name_ar": "STC Pay", "is_cash": false, "requires_count": false, "active": true }
  ],
  "meta": { "request_id": "..." }
}
```

**Field contract (Cursor verifies):**
- `code` — stable string key used everywhere (sales lines, close payload, GL accounts)
- `is_cash` — UI shows denomination breakdown (50/100/200/500 SAR notes) only when true
- `requires_count` — UI prompts cashier for count at close. When false, system auto-reconciles from acquirer settlements (e.g. STC Pay reconciles in Pay-rail flow, not here)
- `active` — inactive tenders still appear in historical sales but are not shown in close form

**UI usage in close-shift form (§8):** Loop over tenders where `requires_count = true`, render a counted-amount input per row.

**Cursor checklist:**
- [ ] Verify `tenders` table has all four fields
- [ ] Confirm response includes `is_cash` and `requires_count` (Postman example may be thinner — update if so)

---

## §4 — Cash movement (paid-in / paid-out)

**Endpoint:** `POST /shifts/{id}/cash-movements` ❓ MISSING.

**Why required:** Mid-shift cash drops to safe ("paid-out", reduces drawer), petty-cash withdrawals, or cash injections from manager ("paid-in", adds to drawer). Without this, drawer reconciliation at close is impossible — variance will always show false discrepancies.

**Permission:** `pos.cash.movements`

**Headers:** `Authorization`, `Accept-Language`, `X-Branch-Id`, `Idempotency-Key`

**Request body:**
```json
{
  "type": "paid_out",
  "amount": { "amount_minor": 100000, "currency_code": "SAR" },
  "reason_code": "safe_drop",
  "note_ar": "إيداع في الخزينة",
  "note_en": "Drop to safe",
  "manager_pin": "1234"
}
```

**Type values:**
- `paid_in` — cash added to drawer (manager top-up, customer overpayment refund-in, etc.)
- `paid_out` — cash removed from drawer (safe drop, petty cash, refund-out paid in cash)

**Reason codes (Cursor seeds; tenant can extend):**
- `safe_drop`, `petty_cash`, `bank_deposit`, `manager_topup`, `tip_payout`, `correction`, `other`

**Response 201:**
```json
{
  "data": {
    "id": "01J...MOVE...",
    "shift_id": "01J...SHIFT...",
    "type": "paid_out",
    "amount": { "amount_minor": 100000, "currency_code": "SAR" },
    "reason_code": "safe_drop",
    "note_ar": "إيداع في الخزينة",
    "note_en": "Drop to safe",
    "performed_by_user_id": "01J...USER...",
    "manager_user_id": "01J...MGR...",
    "performed_at": "2026-04-29T13:30:00Z",
    "journal_entry_id": "01J...JE..."
  },
  "meta": { "request_id": "..." }
}
```

**Validation:**
- Shift exists, belongs to current cashier OR cashier has `pos.cash.movements.any`
- Shift `status = 'opened'`
- `amount.amount_minor > 0`
- If `reason_code = 'other'`, require `note_en` OR `note_ar`
- `manager_pin` validates against a user with `pos.manager` role on this branch (always required for paid-out; configurable per tenant for paid-in)

**Error scenarios:**

| HTTP | `error.code` | When | UI |
|---|---|---|---|
| 422 | `validation.failed` | bad amount / missing reason note | Inline errors |
| 401 | `manager_pin.invalid` | wrong PIN | "Manager PIN incorrect. Try again." |
| 403 | `permission.denied` | lacks `pos.cash.movements` | `<Forbidden>` |
| 404 | `shift.not_found` | shift {id} doesn't exist | "Shift not found" |
| 409 | `shift.not_open` | shift status ≠ opened | "This shift is closed. Cash movements not allowed." |
| 409 | `cash.insufficient` | paid-out exceeds drawer balance | "Cannot withdraw {amount}. Drawer has {balance}." |

**Side effects:**
- INSERT `cash_movements` row
- INSERT `journal_entries` row (Dr/Cr against drawer + safe/petty-cash accounts per accounting policy)
- `GET /shifts/current` running totals reflect this immediately

**Cursor checklist:**
- [ ] Add route + permission + idempotency
- [ ] DB: `cash_movements` table with `shift_id`, `type`, `amount_minor`, `currency_code`, `reason_code`, `note_ar`, `note_en`, `performed_by_user_id`, `manager_user_id`, `journal_entry_id`
- [ ] Add to Postman under "05 — Shifts"
- [ ] Verify GL posting policy in `docs/03-MONEY-AND-VAT.md`
- [ ] Decide manager-PIN policy (always-required for paid_out; tenant-toggle for paid_in)

---

## §5 — Void sale

**Endpoint:** `POST /sales/{id}/void` ✅ Postman-verified.

**Permission:** `pos.sales.void`

**Headers:** `Authorization`, `Accept-Language`, `X-Branch-Id`, `Idempotency-Key`

**Request body:**
```json
{
  "reason": "wrong_item",
  "manager_pin": "1234"
}
```

**Reason values (Cursor seeds; tenant configurable):**
- `wrong_item`, `wrong_price`, `customer_changed_mind`, `system_error`, `cashier_error`, `other`

**Response 200:**
```json
{
  "data": {
    "id": "01J...SALE...",
    "status": "voided",
    "voided_at": "2026-04-29T11:42:00Z",
    "voided_by_user_id": "01J...USER...",
    "manager_user_id": "01J...MGR...",
    "reason": "wrong_item",
    "credit_note": {
      "zatca_uuid": "...",
      "zatca_qr": "...",
      "invoice_number": "CN-2026-04-00342"
    },
    "journal_entry_id": "01J...JE..."
  },
  "meta": { "request_id": "..." }
}
```

> ⚠️ **Postman drift.** Postman example body is thin: `{"id": 50001, "status": "voided"}`. Real implementation must include credit-note + journal entry data per ZATCA Phase-2 requirements (D6) and accounting integration (D5).

**Validation:**
- Sale exists, belongs to tenant + branch
- Sale `status = 'captured'` (not already voided/refunded)
- Sale was captured **within current shift's open window** OR cashier has `pos.sales.void.any` permission (manager-level, can void cross-shift)
- `manager_pin` valid

**Error scenarios:**

| HTTP | `error.code` | When | UI |
|---|---|---|---|
| 422 | `validation.failed` | missing reason | Inline error |
| 401 | `manager_pin.invalid` | wrong PIN | "Manager PIN incorrect" |
| 403 | `permission.denied` | lacks `pos.sales.void` | `<Forbidden>` |
| 404 | `sale.not_found` | bad sale ID | "Sale not found" |
| 409 | `sale.not_voidable` | sale already voided/refunded | "This sale is already {status}" |
| 409 | `sale.cross_shift_void_forbidden` | sale was captured in different shift; cashier lacks override | "Sale was made on a different shift. Manager required." |
| 409 | `branch.day_closed` | branch already day-closed for that date | "Day is closed. Adjustments require head-office." |
| 502 | `zatca.unreachable` | ZATCA credit-note submission failed | "ZATCA temporarily unavailable. Retry?" — server holds void in `voiding` status, retries async |

**Side effects:**
- UPDATE `sales` SET `status='voided'`, `voided_at`, `voided_by_user_id`, `manager_user_id`, `void_reason`
- INSERT `zatca_invoices` row (credit-note variant) — async submit per D6
- INSERT `journal_entries` reversal — debits revenue, credits cash/tender accounts (mirrors original sale's GL)
- Inventory: stock reversed (each line increments stock back) — per D7
- `GET /shifts/current` running totals: `sales_gross` reduced, `voids_count++`, `voids_amount += sale.total`

**Critical accounting note for Cursor:** Voiding a sale captured **in this shift** vs **a previous shift but same business day** vs **previous business day** has different GL implications. Recommend:
- Same shift: full reversal, sale doesn't appear in any report
- Same business day, different shift: original sale + credit note both appear; net = 0
- Previous business day: 422 `sale.cross_day_void_forbidden`; force RMA flow

**Cursor checklist:**
- [ ] Verify Postman response shape gets credit-note + journal-entry fields
- [ ] Implement cross-shift / cross-day rules above; document in `docs/04-STATE-MACHINES.md`
- [ ] Verify ZATCA credit-note variant is wired (D6)
- [ ] Verify GL reversal posts (D5)
- [ ] Verify inventory reversal (D7)

---

## §6 — Refund sale (cashier-immediate)

**Endpoint:** `POST /sales/{id}/refund` ❓ MISSING — **D11 ✅ approved** per `DECISIONS.md` §5a.

### Context

Postman currently has **two** refund-shaped endpoints, neither of which fits the cashier-floor case:

1. `POST /rma/{id}/refund` — final step of full RMA workflow (`requested → approved → received → inspected → refunded`). Heavy: customer brings item back later, staff inspects, manager approves.
2. `POST /pay/transactions/{id}/refund` — payment-rail refund. Sends reversal to acquirer (Mada/Visa). Used internally when refund leg is electronic.

### D11 = approved (locked, per `DECISIONS.md` §5a)

**Add `POST /sales/{id}/refund`** as a lightweight cashier-floor path. It:
- Accepts partial-line or full-sale refund
- Calls `/pay/transactions/{txn_id}/refund` internally for electronic-tender lines
- Records cash refund via cash_movement for cash-tender lines
- Issues ZATCA credit note
- Posts GL reversal
- Does NOT require RMA (no inspection step — assumes item is back at counter)

**Constraint:** Only allowed within **same shift** as original sale. Cross-shift refund returns 409 `sale.cross_shift_refund_forbidden` and the UI deep-links to RMA flow.

### Contract

**Permission:** `pos.sales.refund`

**Headers:** `Authorization`, `Accept-Language`, `X-Branch-Id`, `Idempotency-Key`

**Request body:**
```json
{
  "lines": [
    { "sale_line_id": "01J...LINE...", "qty": 1 },
    { "sale_line_id": "01J...LINE2...", "qty": 2 }
  ],
  "reason": "wrong_size",
  "refund_to": [
    { "tender_code": "cash", "amount": { "amount_minor": 4500, "currency_code": "SAR" } }
  ],
  "manager_pin": "1234"
}
```

**Notes on body:**
- Empty `lines` → full refund of remaining un-refunded lines
- `qty` must be `<=` remaining un-refunded qty on that line
- `refund_to[].tender_code` may differ from original tender (cashier choice; tenant policy may restrict)
- `refund_to` totals must equal sum of `lines[].qty * sale_line.unit_price` (server validates exact match incl. VAT)

**Response 201:**
```json
{
  "data": {
    "id": "01J...REFUND...",
    "sale_id": "01J...SALE...",
    "lines": [...],
    "refund_total": { "amount_minor": 4500, "currency_code": "SAR" },
    "refund_to": [...],
    "credit_note": { "zatca_uuid": "...", "invoice_number": "CN-2026-04-00343" },
    "pay_transactions": [
      { "tender_code": "mada", "transaction_id": "txn_aaa", "refund_status": "submitted" }
    ],
    "journal_entry_id": "01J...JE...",
    "refunded_at": "...",
    "performed_by_user_id": "01J...USER...",
    "manager_user_id": "01J...MGR..."
  },
  "meta": { "request_id": "..." }
}
```

**Error scenarios:**

| HTTP | `error.code` | When | UI |
|---|---|---|---|
| 422 | `validation.failed` | bad qty, totals don't match | Inline |
| 422 | `refund.amount_mismatch` | `sum(refund_to) != sum(lines * price)` | "Refund total {a} doesn't match lines total {b}" |
| 401 | `manager_pin.invalid` | bad PIN | "Manager PIN incorrect" |
| 403 | `permission.denied` | lacks permission | `<Forbidden>` |
| 404 | `sale.not_found` | bad ID | — |
| 409 | `sale.cross_shift_refund_forbidden` | sale was different shift | "Different shift. Open RMA instead." → deep-link to RMA flow |
| 409 | `refund.exceeds_remaining` | line already partially refunded | "Only {N} units remain refundable on this line" |
| 502 | `pay.acquirer_unreachable` | Mada/Visa acquirer down | "Card refund pending. Will retry. Cash refund proceeds immediately." (partial-success: cash leg done, card leg async) |

**Side effects:**
- INSERT `refunds` row + `refund_lines` rows
- For each `refund_to[].tender_code`:
  - if `is_cash`: INSERT `cash_movements` (type=`paid_out`, reason=`refund`)
  - else: call internal `/pay/transactions/{txn}/refund`; status pending until acquirer confirms
- INSERT ZATCA credit-note (D6)
- INSERT `journal_entries` reversal (D5)
- Inventory: refunded qty restored to stock (D7)
- `GET /shifts/current` running totals: `refunds_count++`, `refunds_amount += refund_total`

**Cursor checklist (D11 ✅ approved):**
- [ ] Add route, permission, idempotency
- [ ] DB: `refunds` + `refund_lines` tables; FK to `sales`, `sale_lines`
- [ ] Validator enforces totals match exactly (incl. VAT precision)
- [ ] Same-shift constraint + cross-shift error code
- [ ] Wire ZATCA credit-note (D6), GL (D5), inventory restock (D7)
- [ ] Wire internal call to `/pay/transactions/{id}/refund` for electronic tenders
- [ ] Add Postman entry

---

## §7 — X-report (mid-shift, read-only)

**Endpoint:** `GET /shifts/{id}/x-report` ❓ MISSING.

**Why required:** Cashier or manager wants to see running totals without closing the shift. The "X" stands for "running totals snapshot" — printable, no GL impact, shift stays open. Common in retail SOPs.

> If the running totals from `GET /shifts/current` are sufficient for your UX, this endpoint is optional and a UI-only "Print X-report" button can render from `/shifts/current` data. Decision is Cursor's; doc both paths.

**Permission:** `pos.shifts.view` (read-only) OR `pos.shifts.x_report` if tenant wants to gate it separately

**Response 200:**
```json
{
  "data": {
    "shift_id": "01J...SHIFT...",
    "register_label": "Counter 1",
    "branch_id": "01J...BRANCH...",
    "branch_name_en": "Olaya Main",
    "branch_name_ar": "العليا الرئيسي",
    "cashier_user_id": "01J...USER...",
    "cashier_name": "Ali H.",
    "opened_at": "2026-04-29T08:00:00Z",
    "snapshot_at": "2026-04-29T13:00:00Z",
    "elapsed_minutes": 300,
    "totals": {
      "sales_count": 47,
      "sales_gross": { "amount_minor": 1284500, "currency_code": "SAR" },
      "sales_vat": { "amount_minor": 167500, "currency_code": "SAR" },
      "sales_net": { "amount_minor": 1117000, "currency_code": "SAR" },
      "voids_count": 2,
      "voids_amount": { "amount_minor": 8800, "currency_code": "SAR" },
      "refunds_count": 1,
      "refunds_amount": { "amount_minor": 4500, "currency_code": "SAR" },
      "discounts_amount": { "amount_minor": 32000, "currency_code": "SAR" }
    },
    "by_tender": [
      { "tender_code": "cash", "name_en": "Cash", "name_ar": "نقد", "count": 18, "amount": { "amount_minor": 320000, "currency_code": "SAR" } },
      { "tender_code": "mada", "name_en": "Mada", "name_ar": "مدى", "count": 24, "amount": { "amount_minor": 855700, "currency_code": "SAR" } },
      { "tender_code": "visa", "name_en": "Visa", "name_ar": "فيزا", "count": 5, "amount": { "amount_minor": 100000, "currency_code": "SAR" } }
    ],
    "cash_movements": [
      { "type": "paid_out", "reason_code": "safe_drop", "amount": { "amount_minor": 100000, "currency_code": "SAR" }, "performed_at": "2026-04-29T11:30:00Z" }
    ],
    "expected_cash_in_drawer": { "amount_minor": 270000, "currency_code": "SAR" }
  },
  "meta": { "request_id": "..." }
}
```

**`expected_cash_in_drawer` formula** (Cursor implements, also reused in §8 Close shift variance):
```
expected_cash = opening_float
              + Σ sales[tender=cash].total
              + Σ cash_movements[type=paid_in]
              − Σ cash_movements[type=paid_out]
              − Σ refunds[tender=cash].amount
              − Σ voided_sales[tender=cash].total  (only if void cleared cash from drawer)
```

**Side effects:** None. Read-only.

**Error scenarios:**

| HTTP | `error.code` | When | UI |
|---|---|---|---|
| 401 | `auth.unauthenticated` | no token | Login |
| 403 | `permission.denied` | — | `<Forbidden>` |
| 404 | `shift.not_found` | bad ID | — |

**Cursor checklist:**
- [ ] Decide: add this endpoint, OR make UI compute from `/shifts/current` data + a separate `print-x-report.html` view
- [ ] If endpoint: add route, controller (read-only, no idempotency), Postman entry
- [ ] Document `expected_cash` formula in `docs/04-STATE-MACHINES.md`

---

## §8 — Close shift (returns Z-report data)

**Endpoint:** `POST /shifts/close` ✅ Postman-verified.

> ⚠️ **Postman path drift.** Postman shows `POST /shifts/close` (no shift ID in path). This is unusual — server derives the shift from `Authorization` (cashier's open shift) OR `X-Shift-Id` header. Cursor verifies. RECOMMENDED: change to `POST /shifts/{id}/close` for consistency with §4 (cash-movements) and to support managers closing other cashiers' shifts.

**Permission:** `pos.shifts.close`

**Headers:** `Authorization`, `Accept-Language`, `X-Branch-Id`, `Idempotency-Key`

**Request body:**
```json
{
  "counted": {
    "cash": { "amount_minor": 268500, "currency_code": "SAR" },
    "mada": { "amount_minor": 855700, "currency_code": "SAR" },
    "visa": { "amount_minor": 100000, "currency_code": "SAR" }
  },
  "denominations": {
    "cash_500": 4,
    "cash_200": 3,
    "cash_100": 4,
    "cash_50": 5,
    "cash_10": 4,
    "cash_5": 5,
    "cash_1": 60
  },
  "manager_pin": "1234",
  "note_en": "Cash short by 1.50 — likely change rounding",
  "note_ar": "نقص نقدي 1.50 — ربما بسبب تقريب الباقي"
}
```

> ⚠️ **Postman drift.** Postman shows `"counted": {"cash": 1480, "mada": 12340.5}` — scalars. Per D2, must be Money envelopes.

**Notes:**
- `counted` keys must match active tender codes from `GET /tenders` where `requires_count = true`
- `denominations` is optional (recommended for `is_cash` tenders for audit trail)
- `note_*` required when variance exceeds tenant-configured threshold (e.g. > 5 SAR)

**Response 200:**
```json
{
  "data": {
    "id": "01J...SHIFT...",
    "status": "closed",
    "closed_at": "2026-04-29T20:00:00Z",
    "closed_by_user_id": "01J...USER...",
    "manager_user_id": "01J...MGR...",
    "expected": {
      "cash": { "amount_minor": 270000, "currency_code": "SAR" },
      "mada": { "amount_minor": 855700, "currency_code": "SAR" },
      "visa": { "amount_minor": 100000, "currency_code": "SAR" }
    },
    "counted": {
      "cash": { "amount_minor": 268500, "currency_code": "SAR" },
      "mada": { "amount_minor": 855700, "currency_code": "SAR" },
      "visa": { "amount_minor": 100000, "currency_code": "SAR" }
    },
    "variance": {
      "cash": { "amount_minor": -1500, "currency_code": "SAR" },
      "mada": { "amount_minor": 0, "currency_code": "SAR" },
      "visa": { "amount_minor": 0, "currency_code": "SAR" },
      "total": { "amount_minor": -1500, "currency_code": "SAR" }
    },
    "z_report": {
      "shift_id": "01J...SHIFT...",
      "branch_id": "01J...BRANCH...",
      "register_label": "Counter 1",
      "cashier_name": "Ali H.",
      "opened_at": "2026-04-29T08:00:00Z",
      "closed_at": "2026-04-29T20:00:00Z",
      "totals": { "...": "same shape as X-report totals" },
      "by_tender": [ "..." ],
      "cash_movements": [ "..." ],
      "voids": [ { "sale_id": "...", "amount": {...}, "reason": "wrong_item", "voided_at": "..." } ],
      "refunds": [ "..." ],
      "z_report_number": "Z-2026-04-29-000142",
      "z_report_pdf_url": "https://...signed-url..."
    },
    "journal_entry_id": "01J...JE...",
    "next_register_state": "ready_for_next_shift"
  },
  "meta": { "request_id": "..." }
}
```

> ⚠️ **Postman drift.** Postman example response is `{id, variance: {cash: -2.5}, journal_entry_id: 1300}`. Real implementation must include the full `z_report` block — this is what cashier/manager prints. Z-report is **not** a separate endpoint; it's part of close response. (Optionally also retrievable later via `GET /shifts/{id}/z-report` for re-print — Cursor decides.)

**Validation:**
- Shift exists, status=`opened`
- All required tender codes present in `counted`
- All `counted[*].currency_code` match branch currency
- Variance ≤ tenant threshold OR `manager_pin` provided OR `note_*` provided

**Error scenarios:**

| HTTP | `error.code` | When | UI |
|---|---|---|---|
| 422 | `validation.failed` | missing tender / bad currency | Inline |
| 422 | `close.variance_unexplained` | variance > threshold AND no `note` AND no manager PIN | "Variance is {amount} — explain or get manager approval" |
| 401 | `manager_pin.invalid` | bad PIN | "PIN incorrect" |
| 403 | `permission.denied` | — | `<Forbidden>` |
| 404 | `shift.not_found` | — | — |
| 409 | `shift.not_open` | already closed | "Shift already closed at {time}" |
| 409 | `shift.has_pending_refunds` | electronic refunds in `submitted` state, not yet confirmed by acquirer | "Wait for {N} pending card refunds to clear, or force-close (manager only)" |
| 502 | `gl.posting_failed` | journal posting downstream error | "Couldn't post to GL. Try again or contact support." (shift stays `closing`, retried async) |

**Side effects:**
- UPDATE `shifts` SET `status='closed'`, `closed_at`, `counted_*`, `variance_*`, etc.
- Generate Z-report PDF, upload to storage, sign URL
- INSERT `journal_entries` for variance (debit/credit cash-over/short account per D5)
- UPDATE `registers` SET `state='ready_for_next_shift'` (next cashier can open without intermediate close)
- Trigger async: aggregate this shift into branch day-totals (used by §9)

**Cursor checklist:**
- [ ] Update Postman body to Money envelopes
- [ ] Update Postman response to full Z-report block
- [ ] DB: `shifts` table has columns for counted/expected/variance per tender (use `shift_tender_counts` join table — variable tender codes)
- [ ] DB: `z_reports` table for archival (PDF URL, totals, sequenced number)
- [ ] Implement `close.variance_unexplained` business rule
- [ ] Decide: keep `POST /shifts/close` (server-derives shift) OR move to `POST /shifts/{id}/close`
- [ ] Wire variance GL posting (D5)
- [ ] Async: `pending_refunds_clearing_check` before allowing close

---

## §9 — Branch day-close / EOD rollup

**Endpoint:** `POST /branches/{id}/day-close` ❓ MISSING.

**Why required:** Per-shift close handles cashier-level reconciliation. Branch day-close is the **end-of-business-day** operation:
- Asserts all shifts on this branch for this date are `closed`
- Aggregates totals → `branch_day_totals` row
- Locks the day (no further sales/voids/refunds for that date — force RMA / next-day adjustments)
- Posts daily summary GL entry (per D5)
- Files day's ZATCA invoices (D6 — already submitted per-sale, but day-close confirms reconciliation)
- Triggers daily settlement to Pay-rail processor (per D8)

**Permission:** `pos.branch.day_close` (typically branch-manager only, never cashier)

**Headers:** `Authorization`, `Accept-Language`, `X-Branch-Id`, `Idempotency-Key`

**Request body:**
```json
{
  "business_date": "2026-04-29",
  "force": false
}
```

**Notes:**
- `business_date` defaults to "today" in branch's timezone if omitted
- `force=true` allows day-close even if some shifts have unexplained variance > threshold (manager override; logged + alerted)

**Response 200:**
```json
{
  "data": {
    "id": "01J...DAYCLOSE...",
    "branch_id": "01J...BRANCH...",
    "business_date": "2026-04-29",
    "closed_at": "2026-04-29T23:30:00Z",
    "closed_by_user_id": "01J...USER...",
    "shifts_count": 4,
    "totals": {
      "sales_count": 287,
      "sales_gross": { "amount_minor": 7842500, "currency_code": "SAR" },
      "sales_vat": { "amount_minor": 1023000, "currency_code": "SAR" },
      "sales_net": { "amount_minor": 6819500, "currency_code": "SAR" },
      "voids_count": 8,
      "voids_amount": { "amount_minor": 38400, "currency_code": "SAR" },
      "refunds_count": 4,
      "refunds_amount": { "amount_minor": 21500, "currency_code": "SAR" },
      "total_variance": { "amount_minor": -2200, "currency_code": "SAR" }
    },
    "by_tender": [ "..." ],
    "by_shift": [
      { "shift_id": "01J...", "cashier_name": "Ali H.", "register_label": "Counter 1", "totals": { "..." }, "variance": { "..." } },
      "..."
    ],
    "z_report_pdf_url": "https://...signed...",
    "journal_entry_id": "01J...JE...",
    "next_state": "day_closed"
  },
  "meta": { "request_id": "..." }
}
```

**Validation:**
- Branch exists, belongs to tenant
- All shifts for `business_date` are `status='closed'` OR `force=true` with manager-override permission
- No `pending` electronic refunds OR `force=true`
- Branch `day_state` for `business_date` is currently `open` (not already day-closed; not `force` re-close)

**Error scenarios:**

| HTTP | `error.code` | When | UI |
|---|---|---|---|
| 422 | `validation.failed` | bad date | Inline |
| 403 | `permission.denied` | not branch manager | `<Forbidden>` |
| 404 | `branch.not_found` | — | — |
| 409 | `day_close.shifts_still_open` | open shifts exist | "Close these shifts first: {list}" with deep-links |
| 409 | `day_close.pending_refunds` | electronic refunds not cleared | "Wait or force-close (logs override)" |
| 409 | `day_close.already_closed` | already closed | "Day {date} already closed at {time}" |
| 502 | `gl.posting_failed` | downstream | retry async, day stays `closing` |

**Side effects:**
- INSERT `branch_day_closes` row
- UPDATE `branches.day_state[business_date] = 'closed'`
- INSERT daily summary `journal_entries` (per D5)
- Trigger ZATCA daily reconciliation (per D6)
- Trigger Pay-rail settlement (per D8)
- Lock all sales/refunds/voids on `business_date` (subsequent attempts → 423 `branch.day_closed`)

**Cursor checklist:**
- [ ] Add route, permission, idempotency
- [ ] DB: `branch_day_closes` table
- [ ] DB: `branches.day_state` JSON column OR `branch_day_states` join table
- [ ] Implement shift-still-open precondition
- [ ] Wire daily GL summary (D5)
- [ ] Wire ZATCA daily recon (D6)
- [ ] Wire Pay-rail settlement trigger (D8)
- [ ] Decide: timezone source (branch.timezone column required)
- [ ] Add Postman entry
- [ ] Document state machine in `docs/04-STATE-MACHINES.md`

---

## §10 — Voids & returns report (post-hoc)

**Endpoint:** `GET /reports/sales/voids-returns?period=YYYY-MM` ✅ Postman-verified.

**Permission:** `reports.sales.view`

**Response 200:**
```json
{
  "data": [
    {
      "branch_id": "01J...",
      "branch_name_en": "Olaya Main",
      "voids_count": 12,
      "voids_amount": { "amount_minor": 88000, "currency_code": "SAR" },
      "returns_count": 8,
      "returns_amount": { "amount_minor": 64000, "currency_code": "SAR" }
    }
  ],
  "meta": { "request_id": "..." }
}
```

> ⚠️ **Postman drift.** Postman uses scalars (`88000`); per D2 must be Money envelopes.

**Use case:** Manager dashboard, monthly review. Not part of daily flow but cross-references shifts/voids/refunds for audit.

**Cursor checklist:** Already exists; just normalize money fields.

---

## End-to-end happy path (run against fresh DB after onboarding + first products + first sale)

| Step | Endpoint | Notes |
|---|---|---|
| 1 | `GET /me` | Current cashier user |
| 2 | `GET /branches?role=cashier` | Cashier's assigned branch |
| 3 | `POST /sessions/branch` (or set `X-Branch-Id`) | Branch context |
| 4 | `GET /tenders` | List of tenders for close form |
| 5 | `GET /shifts/current` | Returns `data: null` (no shift yet) |
| 6 | `POST /shifts/open` body `{register_id, opening_float}` | Returns shift `id` |
| 7 | `GET /shifts/current` | Now returns shift with running totals = 0 |
| 8 | `POST /sales` (per POS doc) ×N | Each sale increments running totals |
| 9 | `POST /sales/{id}/void` body `{reason, manager_pin}` | One void mid-shift |
| 10 | `POST /sales/{id}/refund` body `{lines, refund_to, manager_pin}` (D11 ✅ approved) | One refund mid-shift |
| 11 | `POST /shifts/{id}/cash-movements` body `{type:'paid_out', amount, reason_code:'safe_drop', manager_pin}` | One drawer drop |
| 12 | `GET /shifts/{id}/x-report` ❓ MISSING — or compute from `/shifts/current` | Mid-shift snapshot |
| 13 | `GET /shifts/current` → `running_totals.expected_cash_in_drawer` | Cashier counts drawer |
| 14 | `POST /shifts/close` body `{counted: {cash, mada, ...}, manager_pin, note_*}` | Close + Z-report returned |
| 15 | (Manager) `POST /branches/{id}/day-close` ❓ MISSING | EOD rollup at end of business day |
| 16 | (Audit, days later) `GET /reports/sales/voids-returns?period=2026-04` | Historical view |

---

## Consolidated Cursor checklist

### Routes (verify each exists in `routes/api/v1/pos.php` or equivalent)

- [x] ✅ `POST /shifts/open` — verify Money envelope
- [ ] ❓ `GET /shifts/current` — **add** (D9 contract)
- [x] ✅ `GET /tenders` — verify response includes `is_cash`, `requires_count`
- [ ] ❓ `POST /shifts/{id}/cash-movements` — **add**
- [x] ✅ `POST /sales/{id}/void` — verify response includes credit-note + journal-entry
- [ ] ❓ `POST /sales/{id}/refund` — **add** (D11 ✅ approved per `DECISIONS.md` §5a)
- [ ] ❓ `GET /shifts/{id}/x-report` — **add OR delegate to UI compute from `/shifts/current`**
- [x] ✅ `POST /shifts/close` — verify Money envelope, full Z-report response, consider rename to `/shifts/{id}/close`
- [ ] ❓ `POST /branches/{id}/day-close` — **add**
- [x] ✅ `GET /reports/sales/voids-returns` — verify Money envelope

### New tables / migrations

- [ ] `cash_movements` (`id`, `shift_id`, `type`, `amount_minor`, `currency_code`, `reason_code`, `note_ar`, `note_en`, `performed_by_user_id`, `manager_user_id`, `journal_entry_id`, timestamps)
- [ ] `refunds` + `refund_lines` (D11 ✅ approved)
- [ ] `shift_tender_counts` (shift_id, tender_code, counted_minor, expected_minor, variance_minor) — variable rows per shift
- [ ] `z_reports` (id, shift_id, sequence_number, pdf_url, totals_json, generated_at)
- [ ] `branch_day_closes` (id, branch_id, business_date, closed_at, totals_json, journal_entry_id, status)
- [ ] `branches.timezone` column (required for day-close business_date computation)
- [ ] Partial unique index `shifts(register_id) WHERE status='opened'`
- [ ] Partial unique index `shifts(cashier_user_id) WHERE status='opened'`

### Conventions to document in `docs/02-CONVENTIONS.md`

- [ ] Money envelope is mandatory on all shift/refund/void payloads — no scalars
- [ ] Manager-PIN check pattern: server-side validates against user with `pos.manager` role + branch access
- [ ] Cross-shift / cross-day rules for void & refund (force RMA after threshold)
- [ ] `expected_cash_in_drawer` formula

### State machines to document in `docs/04-STATE-MACHINES.md`

- [ ] `shifts`: `opened → closed` (one-way; with `closing` intermediate state for async GL posts)
- [ ] `branch_day_state[date]`: `open → closing → closed`
- [ ] `sales` adds: `captured → voided`, `captured → (partially_)refunded`
- [ ] `refunds`: `pending → submitted → confirmed | failed` (electronic legs)

### Postman tasks

- [ ] Update `Open shift` body to Money envelope
- [ ] Update `Close shift` body+response to Money + full Z-report
- [ ] Update `Void sale` response to include credit-note + journal-entry
- [ ] Add `GET /shifts/current` example (both data-null and data-shift cases)
- [ ] Add `POST /shifts/{id}/cash-movements`
- [ ] Add `POST /sales/{id}/refund` (D11 ✅ approved)
- [ ] Add `GET /shifts/{id}/x-report` (if endpoint chosen over UI-compute)
- [ ] Add `POST /branches/{id}/day-close`
- [ ] Update `Voids & returns report` to Money envelope

### DB verification queries (post-implementation)

```sql
-- After §1 open shift
SELECT id, register_id, cashier_user_id, status, opened_at FROM shifts WHERE id = ?;

-- After §4 cash movement
SELECT cm.*, je.* FROM cash_movements cm JOIN journal_entries je ON cm.journal_entry_id = je.id WHERE cm.id = ?;

-- After §5 void
SELECT s.status, s.voided_at, zi.invoice_type, je.entry_type FROM sales s
  LEFT JOIN zatca_invoices zi ON zi.sale_id = s.id AND zi.invoice_type = 'credit_note'
  LEFT JOIN journal_entries je ON je.source_id = s.id AND je.entry_type = 'sale_void'
  WHERE s.id = ?;

-- After §8 close
SELECT s.status, s.closed_at, stc.tender_code, stc.counted_minor, stc.expected_minor, stc.variance_minor
  FROM shifts s JOIN shift_tender_counts stc ON stc.shift_id = s.id
  WHERE s.id = ?;

-- After §9 day-close — verify all shifts closed
SELECT COUNT(*) FROM shifts WHERE branch_id = ? AND DATE(opened_at AT TIME ZONE 'Asia/Riyadh') = ? AND status != 'closed';
-- Must return 0
```

---

## What this doc is NOT

- **Not RMA / formal returns.** That's the `/rma/...` flow with a separate state machine (`requested → approved → received → inspected → refunded|replaced|rejected`). Out of scope here.
- **Not Pay-rail mechanics.** When a cashier-immediate refund (§6) calls `/pay/transactions/{id}/refund` internally, the acquirer-rail handshake is not detailed here — see Pay-rail handoff.
- **Not loyalty / promotions reversal.** Voiding a sale that consumed loyalty points should reverse them; that policy is in `docs/03-MONEY-AND-VAT.md` (or PROMOS handoff TBD).
- **Not physical cash logistics** (safe drops to bank, armored-car pickups). Those are post day-close treasury operations.
- **Not register CRUD.** Cashiers open shifts on a `register_id` but registers are managed elsewhere (admin → branches → registers). ❓ MISSING — flag for separate handoff.

---

## D-level decisions referenced & proposed

| D# | Status | Topic |
|---|---|---|
| D1 | ✅ Approved | ULID IDs, envelope shape, error format |
| D2 | ✅ Approved | Money envelope `{amount_minor, currency_code}` |
| D3 | ✅ Approved | Bilingual fields `name_ar` + `name_en` |
| D4 | ✅ Approved | Idempotency on writes |
| D5 | ✅ Approved | GL auto-posting from sales/refunds/voids/cash-movements |
| D6 | ✅ Approved | ZATCA invoice + credit-note submission |
| D7 | ✅ Approved | Inventory adjusts on sale/void/refund |
| D8 | ✅ Approved | Pay-rail settlement on day-close |
| D9 | ✅ Approved | `GET /shifts/current` returns `data: null` (not 404) when no open shift |
| D10 | ✅ Approved | (per onboarding doc) — setup-progress is derivation |
| D11 | ✅ Approved | Cashier-immediate refund path: lightweight `POST /sales/{id}/refund` for same-shift refunds; cross-shift forces RMA. See `DECISIONS.md` §5a. |

---

*End of handoff.*
