# PHASE X4.11-I2 — CASHIER RECEIPT PRINT ROUTE — READ-ONLY AUDIT

Mode: STRICT READ-ONLY — no source, DB, server, Android, or production mutation.
Date: 2026-10-08 Asia/Jakarta
Scope: `android-print-agent-cashier` (cashier APK) + `android-print-agent` (kitchen ref, read-only) + PHP cashier/receipt/print-bridge source
Prior: Phase 1A PASS, 1B PASS, 1C BLOCKED→1C-R1 PASS (Cashier APK installed, token provisioned, local Bluetooth test print OK per operator report)

---

## 1. Executive Summary

**Primary hypothesis = PROVEN.**

- Current Cashier "Cetak Struk" button (`views/receipts/show.php:3` + `public/assets/js/app.js:55-57`) invokes **only** `window.print()` — the Android System Print Framework. No HTTP request, no `print_jobs` creation, no Agent involvement on click.
- The **server-side RECEIPT bridge already exists and is proven**: `PaymentController::record` (`app/Modules/Payment/PaymentController.php:89-109`) enqueues exactly **one** `print_jobs` row with `destination=RECEIPT` *after* a full payment commits (post-transaction, try/catch, idempotent). Lotter-voucher slip already rides this path to `PRINTED` (jobs 49-55, `tokd7218_sate.sql`). `ReceiptService::buildPrintPayload` builds a snapshot-pure payload.
- The **Cashier Android Agent already is destination-generic** and server-authoritative (`DeviceApiController::DESTINATION_BY_TYPE` `PRINTER_KASIR=>RECEIPT`, `DeviceApiController::claimJob` ignores client destination, `PrintJobService::claim` by `branchId+destination`). It polls `POST /api/v1/device/print-jobs/claim` automatically when `START`ed — no browser open, no intent, no Web Bluetooth.
- Gap: the **original RECEIPT job created at payment time has likely already been claimed/printed/expired by the time the user taps "Cetak Struk"**, and the button never creates a follow-up job. The user therefore only sees the browser PDF chooser. The intended server-mediated path exists; the button is disconnected from it.

**Classification: A — UI still uses legacy browser printing, while server/Agent path already exists.**

Final status: **PASS** (all 8 acceptance questions answerable from source)

---

## 2. User-Observed Behavior

Operator-confirmed post-1C-R1:

1. Cashier APK (`id.sategajah.printagent.cashier`) installed — `adb install -r … Success`
2. Device token provisioned via Admin UI (`/devices/{id}/token`)
3. Android Cashier Agent authenticated (`POST /api/v1/device/auth` + `heartbeat` OK) and local Bluetooth test print succeeded → Bluetooth transport WORKING, token/server connectivity WORKING
4. New sale: open Cashier → select order → tap **"Cetak Struk"**
5. **Observed:** Android opens the standard system/browser print UI → "Save as PDF" / A4 chooser → "All printers" does not find the Bluetooth receipt printer → no automatic receipt
6. No receipt via Cashier Agent is produced on that tap

This matches a browser-print symptom, not a Bluetooth or server-auth failure.

---

## 3. Cashier UI Print Entry Point

### 3.1 Exact view file

`views/receipts/show.php` — single receipt template (read-only)

- Line 3:
  ```html
  <button type="button" class="btn btn-primary" data-print>Cetak Struk</button>
  ```
- Line 4: `<a … href="/cashier/orders">Kembali ke Kasir</a>`
- Line 5 hint (deliberate legacy text):
  ```html
  <span class="muted fs-13">Cetak via browser — printer fisik masih deferred (tanpa integrasi hardware).</span>
  ```
- Lines 8-92: pure HTML rendering from `$receipt` view model (no form, no action, no fetch)

### 3.2 Exact handler

`public/assets/js/app.js:54-57` (Phase 3H-1 CSP-safe, no inline `onclick`):

```js
// Receipt print — CSP-safe (no inline onclick). Phase 3H-1.
document.querySelectorAll('[data-print]').forEach(function (btn) {
    btn.addEventListener('click', function () { window.print(); });
});
```

No other handler, no `fetch`, no `XMLHttpRequest`, no `navigator.print`, no `fetchCsrf`, no route. Search scope: `Grep "Cetak Struk"` → only `views/receipts/show.php:3` + a doc reference; `Grep "window\.print"` → only this file plus docs/tests.

### 3.3 Route/action invoked

**None.** Route table (`routes/web.php:183`):
```php
$router->get('/receipts/{id}', [ReceiptController::class, 'show'], ['permission' => 'payment.view']);
```
`ReceiptController::show` (`app/Modules/Receipt/ReceiptController.php:14-30`) is `GET` read-only: `ReceiptService::build(id)` → `Response::view('receipts/show', …)`. Zero side effects.

The button never posts, never calls an API, never touches `print_jobs`.

---

## 4. Browser/System Print Evidence

### Primary hypothesis verdict: **PROVEN — current Cashier Receipt UI invokes standard browser/system printing**

- **File:** `public/assets/js/app.js:56`
- **Function:** anonymous click listener on `[data-print]`
- **Exact path:** `DOMContentLoaded` → `querySelectorAll('[data-print]')` → `click` → `window.print()` → Android System Print Framework → "Save as PDF" / "All printers" discovery
- **Before/after server job:** The `window.print()` happens **entirely client-side, after** the receipt page was rendered. It does **not** precede or follow a `print_jobs` creation on this click. The only `RECEIPT` job was already created earlier (at payment time, see §5) and is independent of this click.

Supporting legacy docs (not source of truth, but consistent):

- `docs/PHASE_3G_RECEIPT_STORE_SETTINGS_REPORT.md:9,33,91` — "Printing is browser-based (`window.print()` + `@media print`)"
- `docs/PHASE_3H_1_CSP_REMEDIATION_REPORT.md:27,101` — `onclick="window.print()"` migrated to `data-print` + `app.js` listener (CSP fix); behavior preserved
- `reports/PHASE_X4_11_CASHIER_RECEIPT_PRINT_AUDIT.md:24` — correctly recorded as `app.js [data-print] → window.print() (browser only)` / "printer fisik masih deferred"

CSS hides chrome on paper (`public/assets/css/app.css` — `@media print` / `.no-print`), confirming the intent was a printable HTML sheet, not a thermal slip.

**Explicit statement:** `window.print()` **IS** present in the current receipt print path and is the **sole** effect of "Cetak Struk".

---

## 5. Server RECEIPT Print Job Evidence

### 5.1 When it is created

**Immediately after a full payment commits, inside `PaymentController::record`** — not on button tap, not on receipt view.

`app/Modules/Payment/PaymentController.php:64-118` (`record`):

```php
$result = $this->service()->record((int)$id, $method, $amount, $received, $reference); // tx: payments + COMPLETED
$order = $this->service()->orderOf((int)$id);
// X4.11-I1: AFTER the payment tx committed:
if ($order !== null && (string)$order['status'] === 'COMPLETED') {
    try {
        $receiptService = new ReceiptService();
        $payload = $receiptService->buildPrintPayload($receiptService->build((int)$id));
        (new PrintJobService())->create((int)$order['branch_id'], 'RECEIPT', $payload, orderId: (int)$id);
    } catch (\Throwable $e) {
        App::instance()->audit()->logBranch(..., 'receipt_print.create_failed', ...);
        $message .= ' — catatan: struk thermal tidak terantre (infrastruktur); cetak manual via browser.';
    }
}
return Response::redirect($request->baseUrl('/receipts/' . $id)); // then show HTML
```

`PaymentService::record` (`app/Modules/Payment/PaymentService.php:139-223`) enforces: `WAITING_PAYMENT` only, active shift, `amount <= remaining`, `TUNAI` change server-computed, `payment_status` recomputed, then `OrderService::completeOrder` → `COMPLETED` + `StockService::finalizeSale` + `LoyaltyService::earnForOrder` — all atomic. Only then does the controller's post-tx `create('RECEIPT', …)` run.

**Answers to the 8 sub-questions:**

1. **After successful full payment?** YES — only when `status === 'COMPLETED'` (fully paid). Partial payments stay on the payment page, no RECEIPT job.
2. **Before or after redirect?** After the payment transaction commits, **before** the redirect to `/receipts/{id}`. The `PENDING` job therefore exists by the time the receipt HTML renders.
3. **Does "Cetak Struk" create another job?** NO — button is `window.print()` only; no endpoint exists for it.
4. **Does opening/refreshing receipt page create a job?** NO — `ReceiptController::show` is side-effect free (`ReceiptService.php:13-30` header: "Viewing/printing is idempotent: no payments, print jobs … are created"). `ReceiptPrintJobTest::testReceiptPageNeverCreatesAnotherJob` proves two `GET /receipts/{id}` leave count at 1.
5. **Duplicate-job guard?** YES — two layers: (a) payment-level: second `POST /cashier/orders/{id}/payment` with same amount is rejected because the order is no longer `WAITING_PAYMENT` (`PaymentService::record:144`, test `testRepeatedSubmitCannotDuplicateReceiptJob`); (b) receipt-level: `testReceiptPageNeverCreatesAnotherJob` proves refresh never enqueues. No in-controller "already has RECEIPT job?" guard — unnecessary because payment cannot repeat.
6. **Destination?** `RECEIPT` (hard-coded at creation site `PaymentController.php:99`).
7. **Snapshot-pure payload?** YES — `ReceiptService::build()` reads `order_items.unit_price/subtotal`, `payments`, `order.total` snapshots + CURRENT settings header/footer only (`ReceiptService.php:169-225` doc: "Never reads product masters, never recomputes prices/totals"). `ReceiptPrintJobTest::testPayloadIsImmutableSnapshot` mutates `products`/`product_branches` after the job and asserts `payload` unchanged. `testPayloadIsAuthoritativeReceipt` checks branch/order/total/cash/change.
8. **Independent of printer config?** YES — `PaymentController.php:103` catches infrastructure failure and logs `receipt_print.create_failed` but never rolls back payment. `ReceiptPrintJobTest::testPaymentSucceedsWithoutAnyReceiptPrinter` proves count=1 with `printer_id=null` when no RECEIPT printer configured (agent claims by destination, not printer row). UI appends "cetak manual via browser" notice in that branch.

### 5.2 Tests/reports as evidence

- `tests/integration/ReceiptPrintJobTest.php` — 7 tests, all locked semantics: `testPaymentSuccessCreatesOneReceiptJob`/`testPayloadIsAuthoritativeReceipt`/`testPayloadIsImmutableSnapshot`/`testFailedPaymentCreatesNoReceiptJob`/`testPaymentSucceedsWithoutAnyReceiptPrinter`/`testRepeatedSubmitCannotDuplicateReceiptJob`/`testReceiptPageNeverCreatesAnotherJob`
- `reports/PHASE_X4_11_I1_RECEIPT_PRINT_MVP.md` — RECEIPT MVP report (job created post-payment, duplicate guard, receipt page idempotence)
- Production fixture dump (`tokd7218_sate.sql:1215-1225,2425`): 7 `RECEIPT` jobs, audit `print_job.create`, sample `payload` "Sate Gajah Gembul … Voucher Code …" via the same destination (voucher slip `LotteryVoucherAdminController:print` also uses `PrintJobService::create(...,'RECEIPT',payload)` and is proven `PRINTED`).

---

## 6. Cashier Agent Claim Path

### 6.1 Does the Agent poll/claim automatically?

YES — fully autonomous foreground service. `src/agent/cashier/PrintAgentService.java` (cashier APK) / `src/agent/android/PrintAgentService.java` (kitchen APK, identical logic):

- `onStartCommand` with `ACTION_START = agent.cashier.START` → `startForeground(NOTIF_ID)` → `Executors.newSingleThreadExecutor().execute(this::loop)`
- `loop()` (`PrintAgentService.java:66-125`) reads `server_url`+`printer_mac` from `pos_cashier` prefs + `SecureTokenStore.load()`; if incomplete → `ERROR` and `stopSelf()`
- Constructs `PrintPipeline(transport=new HttpTransport(serverUrl,token), printerFn, render, "BLUETOOTH-"+printerMac, log, clock)` and loops `pipeline.runOnce()` with `AgentPolicy` backoff (`BACKOFF_CAP_MS` 30s, `REQUEST_TIMEOUT_MS` 10s); on `AUTH_STOP` or `TRANSPORT_THRESHOLD` surfaces notification and stops.

No UI open required after `START AGENT`; service is `foregroundServiceType="connectedDevice"` and survives. `BootReceiver` (`agent.cashier.BootReceiver`) restarts it on `BOOT_COMPLETED` when config present.

### 6.2 What endpoint does it call?

`PrintAgentService.java:326-439` inner `HttpTransport implements PrintPipeline.Transport`:

```java
claim():  POST /api/v1/device/print-jobs/claim          (no body, no branch/destination)
result(): POST /api/v1/device/print-jobs/{id}/result    (claim_token + result + error)
```

`MainActivity` test connection (`MainActivity.java:367-380`) hits `POST /api/v1/device/heartbeat` for token validation — but the agent loop never requires the receipt page.

### 6.3 What destination does it claim?

**Whatever the server assigns for that device token.** The APK itself is destination-agnostic:

- `DeviceApiController::DESTINATION_BY_TYPE` (`app/Modules/Device/DeviceApiController.php:69-73`):
  ```php
  'KITCHEN_PRINT_BRIDGE' => 'KITCHEN',
  'PRINTER_KITCHEN'      => 'KITCHEN',
  'PRINTER_KASIR'        => 'RECEIPT',
  ```
- `DeviceApiController::claimJob` (`:104-137`) derives `$destination = DESTINATION_BY_TYPE[device.type]` server-side; `claimJob` ignores any client-supplied `branch_id`/`destination` ("client-supplied branch/destination are never read" comment). Then `PrintJobService::claim(deviceId, branchId, destination, leaseSeconds=90)` FIFO `FOR UPDATE`.
- Grep confirmation: cashier `src/agent/cashier/*.java` + `src/agent/core/*.java` → 0 hits for hard-coded `"RECEIPT"` / `"KITCHEN"` / `PRINTER_KASIR` — the Android client never filters by destination.
- A fresh Android install registered as type `PRINTER_KASIR` (branch kasir) therefore claims `RECEIPT` jobs at that `branch_id` exclusively; `PRINTER_KITCHEN` would claim `KITCHEN`. No APK code change needed.

### 6.4 How does the server identify PRINTER_KASIR?

Device row (`devices` table) has `type` enum including `PRINTER_KASIR`/`PRINTER_KITCHEN`/`KITCHEN_PRINT_BRIDGE`; `DeviceService::authenticate(token)` joins `branches` and returns `device.type` + `branch_id`, which `claimJob` maps as above. `DeviceService::resolveByToken` still allows `reportResult` from a deactivated device (policy N).

### 6.5 Must the Cashier browser UI remain open?

NO. The Agent is a native Android `Service`, not a WebView. The receipt page (`GET /receipts/{id}`) has no role in triggering the claim; it is HTML for `window.print()` only.

### 6.6 Roles

- Browser → receipt page: none (read-only render)
- Browser → Bluetooth: none (no Web Bluetooth, no `navigator.bluetooth`, no BLE; printer is Bluetooth Classic SPP `UUID 00001101-...`, paired discovery + RFCOMM — per `BluetoothPrinter.java`)
- Browser → Android intent/deep-link: none (no `intent:` URL, no `registerProtocolHandler`)
- Intended architecture: **completely server-mediated** — `device token → server device identity → server derives destination → claim → render (Cp437/EscPosRenderer) → RFCOMM write → ACK/result`

Pipeline (`app/Modules/Printer/PrintJobService.php`  `src/agent/core/PrintPipeline.java`): `PENDING → claim FOR UPDATE → PROCESSING (claim_token+lease) → render → write → SUCCESS→PRINTED / FAILURE→FAILED (→ re-claimable ≤3)`; stale-claim rejection, lease re-claim, backoff— proven for both KITCHEN and RECEIPT.

---

## 7. Current Architecture Diagram

Based ONLY on source evidence (no assumption):

```
CURRENT — TWO DISJOINT PATHS (same DB, different UX)

 PATH A — server-mediated thermal receipt (already built, already proven):

   Cashier taps [Bayar/TUNAI…] on /cashier/orders/{id}
        │
        ▼
   PaymentController::record  ──tx──►  payments + COMPLETED + stock + loyalty
        │  post-tx (still in request):
        ▼
   ReceiptService::build() snapshot
        │
        ▼
   ReceiptService::buildPrintPayload()  (plain-text, \n, snapshot-pure)
        │
        ▼
   PrintJobService::create(branchId, 'RECEIPT', payload, orderId)
        │  status=PENDING, destination=RECEIPT, attempt=0
        ▼
   print_jobs  (branch-scoped, immutable payload)
        │
        │  poll (generic)
        ▼
   Cashier Android Agent (agent.cashier)
     device token (pos_cashier_device_token)
     → server derives PRINTER_KASIR→RECEIPT
     → POST /api/v1/device/print-jobs/claim  (server FIFO FOR UPDATE, claim_token+lease 90s)
     → EscPosRenderer → Cp437
     → BluetoothPrinter (Classic SPP RFCOMM 00001101-…: secure→insecure, settle, retry≤2)
     → POST /api/v1/device/print-jobs/{id}/result  {SUCCESS|FAILURE}
     → PRINTED/FAILED (FAILED re-claimable <3)

 PATH B — legacy browser receipt (what "Cetak Struk" actually does):

   GET /receipts/{id}  →  ReceiptService::build() → HTML receipt-paper
        │
        ▼
   User taps <button data-print>"Cetak Struk"</button>
        │
        ▼
   public/assets/js/app.js  [data-print] click → window.print()
        │
        ▼
   Android System Print Framework
        │
        ├─► Save as PDF (A4/document)
        └─► "All printers" search  (finds only Android framework printers, NOT the paired Bluetooth Classic thermal printer)
              × no automatic thermal receipt

 DISCONNECT:

   Path A job is enqueued at PAYMENT time, then the user is redirected to /receipts/{id}.
   Path B button never triggers or re-triggers Path A.
   By the time the user taps Path B, the single RECEIPT job from Path A may already be
   PRINTED (claimed within seconds), FAILED, or lease-pending — but the button never
   creates a fresh job, never polls status, never falls back to the Agent.

 ALSO PRESENT but not on this flow:

   POST /lottery-vouchers/{id}/print → PrintJobService::create(...,'RECEIPT',voucherPayload)
   → same bridge, same claim path — proven PRINTED jobs 49-55 (voucher slip).
```

---

## 8. Exact Architectural Gap

**Category: A — UI still uses legacy browser printing, while server/Agent path already exists.**

Not B/C/D (server does create the job; Agent does claim RECEIPT correctly when device is `PRINTER_KASIR` — voucher path proves it; no identity mismatch in code). It is also not a Bluetooth or auth failure per operator confirmation.

| # | Finding | File / class / function | Evidence | Severity |
|---|---|---|---|---|
| G1 | **"Cetak Struk" is browser-only** | `views/receipts/show.php:3` + `public/assets/js/app.js:55-57` + `routes/web.php:183` | `data-print → window.print()` is the sole click effect; no endpoint; `ReceiptController::show` is read-only; legacy label "Cetak via browser — … deferred" still present | **BLOCKING** for auto-thermal UX — operator lands in System Print → PDF instead of Bluetooth slip |
| G2 | **No receipt-reprint bridge** | `app/Modules/Payment/PaymentController.php:89-109` creates ONE job at payment; `app/Modules/Receipt/ReceiptController.php:14-30` never creates; no `POST /receipts/{id}/print` route exists (cf. lotto `/lottery-vouchers/{id}/print`) | `ReceiptPrintJobTest::testReceiptPageNeverCreatesAnotherJob` proves refresh ≠ new job — correct for original, but leaves no way for the button to trigger a thermal print after the original lease/completion window | **BLOCKING** — the button cannot reach Path A; reprint is browser-only |
| G3 | **Original RECEIPT job is single-fire and time-bound** | `PrintJobService::claim` lease 90s, `MAX_ATTEMPTS=3`, `AgentPolicy::BACKOFF_*`; `PaymentController::record` has no "if no PENDING RECEIPT job for this order, create one" on re-entry | After `COMPLETED`, a late tap cannot create a fresh `RECEIPT` PENDING job, so even a running Agent has nothing claimable by the time the user presses the button | **MAJOR** — explains why the proven Agent still shows no receipt while the same Agent claims voucher RECEIPT jobs fine |
| G4 | **Legacy UX signals mask the bridge** | `views/receipts/show.php:5` copy + `public/assets/css/app.css` print styles | Users interpret "Cetak Struk" as the way to print, so the browser path looks like the product — the bridge's post-payment auto-print is invisible/untested as a user action | **MINOR** |

Root cause in one sentence: *the payment flow already enqueues exactly one `RECEIPT print_jobs` row for the Agent, but the receipt screen's primary button is still wired to `window.print()` and there is no server endpoint to re-enqueue or poll a `RECEIPT` job from the receipt screen, so the proven Bluetooth path is never invoked by the user gesture.*

Smallest required change (not implemented): introduce the same pattern already proven for lottery vouchers — a `POST /receipts/{id}/print` (permission `payment.view`, branch-scoped, idempotent guard: do not duplicate a still-claimable `RECEIPT` job for that `order_id`; reuse or create `RECEIPT` via `PrintJobService` + `ReceiptService::buildPrintPayload`), retire/relabel `window.print()` as fallback (e.g. "Cetak via Browser (PDF)"), and surface claim status (`PENDING→PROCESSING→PRINTED/FAILED`) via polling or flash. Android side needs **no** change (generic claim).

---

## 9. Candidate Minimal Fix

**Recommended next phase (scoped implementation):**

`X4.11-I2-FIX — Replace/retire legacy browser receipt printing in the Cashier flow and connect the existing UI to the already-proven RECEIPT print-job architecture`

- **NOT assumed — conditioned on §4-§6 proof above.**
- Scope must be tightly limited to:

1. **Server:** new route + controller action `ReceiptController::print` (or `ReceiptPrintController`)
   - `POST /receipts/{id}/print`, `permission=>payment.view`, CSRF, branch-scoped (`ReceiptService::build` already 404s out-of-scope)
   - Guard: if a `RECEIPT` job for `order_id` with status `PENDING` or `PROCESSING` (lease future) or `FAILED`+attempts<3 already exists, do not create a duplicate — return its id/status (or re-queue a `REPRINT` kind if the original is `PRINTED` — decision per existing reprint policy). Otherwise `PrintJobService::create(branchId,'RECEIPT', ReceiptService::buildPrintPayload(ReceiptService::build(id)), orderId)`
   - Audit `print_job.create` (already present for the payment path) and `receipt_print.reprint_requested`
   - Must not couple payment rollback to printer failure (same try/catch pattern as `PaymentController.php:103-108`)

2. **View/JS:** `views/receipts/show.php` + `public/assets/js/app.js`
   - Replace primary "Cetak Struk" `data-print` button with a form `POST /receipts/{id}/print` (e.g. `btn btn-primary data-receipt-print`) or a `fetchCsrf` call that posts to the new endpoint and shows inline status
   - Keep `window.print()` only as secondary "Cetak via Browser (PDF) / Simpan PDF" action or remove when thermal is proven
   - Remove legacy hint "Cetak via browser — printer fisik masih deferred" or reword to "Cetak thermal via Printer Kasir (Bluetooth); fallback browser tersedia"

3. **Tests:** mirror `ReceiptPrintJobTest` + `LotteryVoucherAdminTest` pattern
   - one reprint creates job; duplicate guard; branch isolation; snapshot purity; succeeds without printer row; failed/empty invoice states blocked

4. **Docs:** update `PHASE_3G_*` / `PHASE_X4_11_*` status to reflect that RECEIPT thermal printing is no longer deferred

**Android / DB / infra:** no change — Cashier Agent claims `RECEIPT` when device `type=PRINTER_KASIR` (already mapped), rendering stays `EscPosRenderer+Cp437`, transport stays Classic SPP.

**Explicitly out of scope for the minimal fix:** payment logic, stock, loyalty, kitchen flow, gateway, Web Bluetooth, intent shim, new printer model config.

Alternative considered and rejected: polling the receipt page for job status without a re-enqueue endpoint — insufficient because the original single-fire job may already be consumed/expired.

---

## 10. Production Safety Verification

This audit performed:

- payment: **NO** — no `POST /cashier/orders/*/payment`
- print job creation: **NO** — no `PrintJobService::create` call
- claim: **NO** — no `POST /api/v1/device/print-jobs/claim`
- ACK/result: **NO** — no `POST /api/v1/device/print-jobs/{id}/result`
- heartbeat: **NO** — no `/api/v1/device/heartbeat`
- Bluetooth print: **NO** — no pairing, no `BluetoothPrinter.connect/write`
- database mutation: **NO** — no INSERT/UPDATE/DELETE; inspection was static file reads + grep; DB dump inspected as committed file `tokd7218_sate.sql` (existing REPORT evidence), no live DB connection for writes
- source mutation: **NO** — no file written except this report; prior reports `PHASE_X4_11_I1_*`, `PHASE_X4_11_I2_*` untouched

Verified by: audit was `Read`/`Grep`/`Glob` only; `Write` used once for this report path only.

---

## 11. Acceptance Matrix

| # | Question | Answer | Evidence |
|---|---|---|---|
| 1 | What exactly happens when Cashier clicks "Cetak Struk" | `window.print()` → Android System Print Framework → "Save as PDF" / printer discovery chooser; no HTTP, no `print_jobs` | `views/receipts/show.php:3` (`data-print`), `public/assets/js/app.js:56` (`window.print()`), `routes/web.php:183` (GET-only) |
| 2 | Whether `window.print()` / standard browser/system printing is involved | **YES** — it is the only effect; hypothesis proven | `app.js:55-57` CSP-safe listener; docs `PHASE_3H_1` migration; no alternative endpoint |
| 3 | Whether a RECEIPT `print_job` already exists | Yes — exactly one PENDING RECEIPT job exists **before** the receipt page renders, created at payment commit | `PaymentController.php:89-102`, `ReceiptPrintJobTest::testPaymentSuccessCreatesOneReceiptJob` |
| 4 | When that job is created | After `PaymentService::record` transaction commits and `status===COMPLETED`, synchronously in the same request, then redirect to `/receipts/{id}` | `PaymentController.php:93-114`, `ReceiptPrintJobTest::testReceiptPageNeverCreatesAnotherJob` (refresh ≠ new job) |
| 5 | Whether Cashier Agent is designed to claim that job | Yes — generic claim (`PRINTER_KASIR→RECEIPT` server-derived, no APK hard-code); poll `POST /api/v1/device/print-jobs/claim` with device token | `DeviceApiController.php:69-73,104-137`, `PrintJobService.php:124-161`, cashier APK grep 0× `RECEIPT` |
| 6 | Whether Agent is actually independent of browser printing | Yes — native `PrintAgentService` foreground service, `BootReceiver`, `HttpTransport`, `PrintPipeline`, `BluetoothPrinter`; receipt page never triggers it | `android-print-agent-cashier/src/agent/cashier/*.java`, `device/auth+heartbeat` |
| 7 | Exact gap causing the PDF/printer-discovery screen | UI button is still browser-only and has no way to (re-)enqueue a RECEIPT job; the payment-time single-fire job may already be consumed by lease time, leaving the button with nothing thermal to claim | §8 G1-G3; voucher path proves the bridge itself works |
| 8 | Smallest safe implementation | `POST /receipts/{id}/print` mirroring `POST /lottery-vouchers/{id}/print`, retiring `window.print()` to fallback, keeping payment decoupled | §9 candidate fix; `LotteryVoucherAdminController::print` precedent |

---

## 12. Recommended Next Phase

If the project approves (do not auto-start):

**X4.11-I2-FIX — Cashier Receipt Thermal Print Wiring (server + view only)**

Objective: make "Cetak Struk" enqueue/poll the existing `RECEIPT` bridge instead of `window.print()`, achieving the same user action now proven for voucher slips. Do not rebuild the APK for this phase unless a Cashier-side rendering tweak is later proven needed (none indicated — transport is already proven).

Entry criteria (already met): Phase 1A/1B/1C-R1 PASS; Bluetooth + token WORKING; lotto RECEIPT path PRINTED.

---

## 13. Final Status

```
PASS
```

- All 8 acceptance questions answered from source (no inference, no assumption).
- Primary hypothesis proven from source (legacy browser print is the current path; server/Agent path exists and is proven but disconnected from the button).
- Exact gap and smallest fix identified without performing any mutation.
- Production safety maintained (no payment, no print job, no claim/ACK, no Bluetooth, no DB/source mutation).
- HARD STOP — do not implement, rebuild, deploy, or contact production until the next phase is explicitly authorized.

Prior reports not modified:
- `reports/PHASE_X4_11_CASHIER_RECEIPT_PRINT_AUDIT.md`
- `reports/PHASE_X4_11_I1_RECEIPT_PRINT_MVP.md`
- `reports/PHASE_X4_11_I2_KITCHEN_AGENT_REFERENCE_AUDIT.md`
- `reports/PHASE_X4_11_I2_PHASE1A_CASHIER_AGENT.md`
- `reports/PHASE_X4_11_I2_PHASE1B_IDENTITY_ISOLATION_AUDIT.md`
- `reports/PHASE_X4_11_I2_PHASE1C_ANDROID_RUNTIME_IDENTITY.md`
