# X4.11-I2-FIX — CASHIER RECEIPT THERMAL PRINT WIRING

## Session

NEW SESSION / DEEPSEEK — 2026-10-08 Asia/Jakarta. Repository-only reconstruction; no conversational memory trusted. All claims verified against source/tests before implementation.

## Phase 0 Reconciliation

```
Repository state:      branch master, HEAD fdda4e0, large uncommitted working tree
                       (phases 3k–3l, X4.11-I1, X2 claim/lease — pre-existing)
Existing X4.11-I2 FIX: NONE — fix report absent, no POST print route, controller
                       had only read-only show()
Existing report:       reports/PHASE_X4_11_I2_CASHIER_RECEIPT_PRINT_ROUTE_AUDIT.md
                       (PASS — gaps G1–G4 confirmed; recommended fix matches this task)
Current ReceiptController:  show() only (GET, read-only)
Current ReceiptService:     build() (branch-scoped 404 + COMPLETED gate) +
                            buildPrintPayload() (snapshot-pure plain text)
Current PrintJobService:    create/claim/reportResult/transition/findScoped;
                            STATUSES PENDING|PROCESSING|PRINTED|FAILED, MAX_ATTEMPTS=3,
                            lease 90s; schema HAS kind ENUM('ORIGINAL','REPRINT') +
                            reprint_of_job_id (migration 012) — previously unused
Current print_jobs:     no unique constraint; claim() = FIFO FOR UPDATE (lock-based);
                        Database::transaction + row locks = established concurrency pattern
                        (OrderConcurrencyTest)
Current receipt route:  GET /receipts/{id} only (permission payment.view)
Current receipt UI:     views/receipts/show.php:3 button data-print "Cetak Struk" +
                        legacy "deferred" hint
Current receipt JS:     app.js [data-print] → window.print(); fetchCsrf helper exists
Existing duplicate/idempotency mechanism: payment-level guard + claim() FOR UPDATE +
                        row-lock serialization; no DB unique constraint
Existing voucher RECEIPT print pattern: POST /lottery-vouchers/{id}/print →
                        PrintJobService::create + flash + redirect (proven PRINTED
                        in production)
Router:                 auth + permission + CSRF enforced on POST by default;
                        KASIR has payment.view, KITCHEN does not;
                        CSP style-src 'self' → no inline styles/handlers
```

Previous Phase 0 result: **MATCH** (task §3–§5 baseline fully confirmed; no material mismatch).

Phase 1 already implemented: **NO** — this session implemented it from scratch.

## Previous State

Two disjoint paths existed: the proven server bridge (payment-time RECEIPT job → Cashier Android Agent → Bluetooth) and the legacy browser path ("Cetak Struk" = `window.print()` → Android System Print → PDF/A4). The button could never reach the bridge; the payment-time single-fire job was often already consumed by claim/lease time, leaving nothing thermal to claim (audit gaps G1–G3).

## Implementation

Minimal, server-authoritative, mirrors the proven voucher print pattern:

1. **Route** (`routes/web.php`): `POST /receipts/{id}/print` → `ReceiptController::print`, `permission => payment.view` (auth + CSRF enforced by router middleware, same as all POST routes).
2. **Controller** (`ReceiptController::print`):
   - `ReceiptService::build((int)$id)` first — branch isolation (out-of-scope → 404), COMPLETED-only (DomainException → flash + redirect), and the authoritative snapshot.
   - Ignores ALL client input — no branch/destination/amount/items/printer/payload is ever read from the request.
   - Payload: `ReceiptService::buildPrintPayload($receipt)` — the same snapshot-pure text the HTML receipt uses.
   - Calls `PrintJobService::createForOrder(branch, 'RECEIPT', orderId, payload)`; enqueue failure is audited (`receipt_print.create_failed`) and flashed, never fatal (mirrors `PaymentController::record`).
   - Flash outcome + redirect back to `/receipts/{id}` (existing response convention).
3. **Service** (`PrintJobService::createForOrder`) — see Duplicate Print Guard below.
4. **View** (`views/receipts/show.php`): primary button is now a form `POST /receipts/{id}/print` with `csrf_field()` ("Cetak Struk Thermal"); `window.print()` demoted to a secondary `data-print` button ("Cetak / Simpan PDF"); legacy "deferred" hint replaced. CSP-compliant (no inline handlers/styles; native form submit, no JS added).
5. **Docblock** (`ReceiptService`): "Physical printer DEFERRED" replaced with the real state; build() remains side-effect free (print is a separate action).

NOT changed: Android agents, Kitchen printing, payment logic, device API, claim/ACK protocol, Bluetooth, stock, order calculation, database schema (no migration — `kind`/`reprint_of_job_id` already exist from migration 012).

## Duplicate Print Guard

`PrintJobService::createForOrder` — the endpoint NEVER unconditionally creates a job:

- **CASE A (PENDING)** → reused; no new row.
- **CASE B (PROCESSING, any lease)** → reused — `claim()` re-claims stale leases (crash recovery), so a new row would double-print.
- **CASE C (FAILED, attempts < 3)** → reused — the existing bounded retry is the retry mechanism.
- **CASE D (PRINTED)** → explicit reprint: exactly ONE new row, `kind=REPRINT`, `reprint_of_job_id` rooted DIRECTLY at the ORIGINAL job (no parent chains, per migration 012).
- **CASE E (FAILED, attempts ≥ 3 = exhausted)** → recovery reprint: ONE new REPRINT row (exhausted rows are terminal in `claim()`/`transition()` — a new row matches the documented REPRINT data model).
- **CASE F (no job)** → ONE new ORIGINAL row.

**Concurrency (server-side, not JS):** the order row is locked `SELECT … FOR UPDATE` first inside a transaction; parallel requests for the same order serialize on that lock, and the second request re-reads the first request's row and reuses it (no phantom duplicate). Proven by `testConcurrentRequestsSerializeOnOrderRow` (two connections, lock-wait blocking asserted) and `testRapidDoublePostCreatesOneJob` (HTTP level).

Known bounded residual (documented, consistent with the service's AT-LEAST-ONCE contract): a print POST arriving in the milliseconds between a payment transaction's commit and its post-commit job insert could enqueue alongside it. Unreachable via normal UI (the receipt page only exists after that insert) and requires an out-of-band replay; closing it would require touching payment code or a DB constraint (both out of scope).

## Endpoint

```
POST /receipts/{id}/print
  auth: required (session)
  permission: payment.view (route-declared, server-enforced)
  csrf: required (route default)
  branch isolation: ReceiptService::build → 404 out-of-scope + createForOrder branch check
  payload: server-derived only (ReceiptService::buildPrintPayload)
  response: flash (success/error) + 302 redirect to /receipts/{id} (or /cashier/orders
            for non-COMPLETED, mirroring show())
```

## UI

- Primary: **"Cetak Struk Thermal"** → `POST /receipts/{id}/print` (native form + `csrf_field()`, no JS).
- Secondary: **"Cetak / Simpan PDF"** → `window.print()` (explicit browser/PDF fallback only).
- Hint text: "Struk thermal dicetak Printer Kasir (Bluetooth) — PDF browser hanya cadangan."
- `window.print()` is no longer the primary behavior of the main receipt button.

## Tests

New: `tests/integration/ReceiptReprintTest.php` — **16/16 PASS**:

- T1 create exactly one ORIGINAL job when none exists + payload authoritative
- T6 reprint payload from snapshot, not live masters (mutation-proof)
- T2 PENDING reused · T3 PROCESSING reused (valid + stale lease) · T-C retryable FAILED reused
- T4 PRINTED → exactly one REPRINT (repeat click reuses; reprint-of-reprint stays rooted at ORIGINAL)
- T-E exhausted FAILED → exactly one recovery REPRINT
- T5 concurrent service-level requests serialize on the order row + rapid double POST → one job
- T7 client cannot override branch/destination/amount/items/payload
- T8 wrong branch → 404 · T9 kitchen role → 403, anonymous → redirect to login
- T10 nonexistent → 404; non-COMPLETED → rejected, no job
- T11 no RECEIPT printer configured → job still enqueued (printer_id NULL)
- T12 payment-time job reused by the endpoint AND consumed end-to-end by the claim path → PRINTED
- CSRF missing → rejected, no job

## Regression

```
Targeted tests:  16/16 PASS
Full regression: 639/639 PASS, 0 FAILED  (baseline 623 + 16 new; exit code 0)
```

## Static Safety Audit

- `git diff` reviewed: no Android source changes (agents untouched, untracked dirs pre-existing).
- No Kitchen printer changes; no payment atomicity change (`PaymentController`/`PaymentService` untouched this session).
- `window.print()` remains ONLY as the PDF/browser fallback (`views/receipts/show.php:8` + `app.js:55-57` — the only `data-print` element left).
- Main thermal button uses `POST /receipts/{id}/print`; no unconditional job creation; no client-provided payload; no branch bypass; browser cannot force KITCHEN/BAR/PACKING/OTHER (destination hard-coded `RECEIPT` server-side).
- CSP: no inline handlers/styles added; no new JS.
- No migration added (`kind`/`reprint_of_job_id` columns already exist from migration 012).

## Files Changed

| File | Change |
|---|---|
| `routes/web.php` | +`POST /receipts/{id}/print` (payment.view) + section comment update |
| `app/Modules/Receipt/ReceiptController.php` | +`print()` action; `show()` passes `orderId` to the view |
| `app/Modules/Printer/PrintJobService.php` | +`createForOrder()` — duplicate-safe reuse/reprint with order-row serialization |
| `views/receipts/show.php` | Primary thermal POST form; PDF fallback button; hint text |
| `app/Modules/Receipt/ReceiptService.php` | Docblock only (removed "DEFERRED"; build() unchanged) |
| `tests/integration/ReceiptReprintTest.php` | NEW — 16 targeted tests |
| `reports/PHASE_X4_11_I2_FIX_CASHIER_RECEIPT_THERMAL_WIRING.md` | This report |

## Android Changed

NO

## Kitchen Changed

NO

## Payment Logic Changed

NO

## Database Schema Changed

NO

## Production Touched

NO (development/local only; test runner recreated only `pos_sate_test` on the local 3307 instance)

## Status

PASS

## Remaining Risk

1. Bounded payment/print enqueue race (see Duplicate Print Guard) — accepted, consistent with the service's documented AT-LEAST-ONCE delivery contract.
2. Physical duplicate on print-OK + ACK-lost + lease-expiry window — pre-existing kitchen-class behavior, unchanged.
3. Printer kasir hardware survey (paper width/characters per line) still pending — hardware phase, out of this task's scope.

## Next Step

HARD STOP — per task §22: no deploy, no Android/Kitchen changes, no migrations, no next X4.11 phase from this session. Candidate follow-ups (not started): on-device validation of the new button against the running Cashier Agent (staging), then production rollout under the existing guarded-upgrade procedure.
