From 021d78d486205c490c53686fc48e44b5e86ea650 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Sat, 26 Sep 2026 17:20:57 +0700 Subject: [PATCH 01/16] docs: Add PRD for Cash on Delivery (COD) payment method (#641) * docs: add PRD for order-app cash on delivery payment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FWw1wxmq74Dq98w5Ghu1vL * docs: resolve open questions in COD payment PRD Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FWw1wxmq74Dq98w5Ghu1vL --------- Co-authored-by: Claude --- docs/prd-order-cod-payment.md | 727 ++++++++++++++++++++++++++++++++++ 1 file changed, 727 insertions(+) create mode 100644 docs/prd-order-cod-payment.md diff --git a/docs/prd-order-cod-payment.md b/docs/prd-order-cod-payment.md new file mode 100644 index 00000000..b0ec5148 --- /dev/null +++ b/docs/prd-order-cod-payment.md @@ -0,0 +1,727 @@ +# PRD: Order App — Cash on Delivery (COD), Verified by a Photo + +**Status:** Draft for review — all open questions resolved (see [Resolved questions](#resolved-questions)) +**Scope:** a third order-app payment method — **Cash on Delivery (COD)**: the guest proves they are in the café with a live camera photo, a barista verifies it, the order is made *before* payment, and the guest pays at the counter when they pick it up. Shipped behind a trial feature flag, with the existing cash-at-the-cashier method hidden so the guest sees exactly two options: **QRIS** or **COD**. +**Extends, does not supersede:** `docs/prd-order-cash-payment.md` (cited as *Cash D*), `docs/prd-order-checkout-qris-doku.md` (*QRIS D*), `docs/prd-order-payment-cancellation.md` (*Cancel D*), `docs/prd-order-fulfillment-status.md` (*Fulfil D*), `docs/prd-kds-order-notifications.md` (*KDS D*), `docs/prd-order-whatsapp-notifications.md` (*WA D*). + +--- + +## Revision note (first review pass) + +All four open questions were answered in review. They are recorded as new decisions (D17–D20) and a [Resolved questions](#resolved-questions) section rather than edited into the originals: + +1. **An approved COD order the guest never collects is delivered to their table, and paid there** (D17). No new "abandon" path and no change to `DeleteTransactionById` — the first draft's recommended follow-up PR is dropped. +2. **No order-value cap for the trial** (D18). +3. **The verification window is 15 minutes, not 10** (D19, amending D8's default): `COD_VERIFICATION_EXPIRY_SECONDS=900`. +4. **No WhatsApp message on rejection** (D20) — the guest's status page is the only channel. + +None of the four changes the phase count, the dependency graph, or any phase's file list; only phase 4's default value, phase 11's copy and phase 14's production env move. + +--- + +## System Design Overview + +Read this section first; everything after it is the argument for it. + +### The path, end to end + +``` + Guest (order app, at the table) Barista (POS web / pos-mobile) + │ taps Checkout on /t/{code}/cart + ▼ + details sheet: name · WhatsApp · dining option · method (FR-8) + │ [ QRIS ] [ COD ] ← cash hidden by flag (FR-11) + │ + └── COD ─► live camera viewfinder (getUserMedia, rear camera) (FR-7) + guest photographs their surroundings → preview → "Pakai foto ini" + client downsizes to ≤1280px JPEG (~150–300 KB) + │ + │ POST /carts/current/checkout + │ { customerName, …, method: "cod", verificationPhoto: "" } + ▼ + PaymentUsecase.Checkout — COD branch (FR-2) + │ inside BeginTransaction: + ├─ price cart, reserve availability, CreateTransaction (unpaid, source='order') + ├─ INSERT payments (method='cod', status='pending', + │ verification_status='awaiting', + │ expired_at = now + COD_VERIFICATION_EXPIRY_SECONDS) + ├─ INSERT payment_verification_photos (payment_id, bytes) (FR-3) + └─ EnqueueForTransaction(kind='cod_verification') (FR-9) + COMMIT ──► TriggerDispatch + │ │ + ▼ ▼ + /orders/{reference} KDS push: "Verify COD order #12 — Meja 4" + "Menunggu konfirmasi barista…" POS list row: [Order] [Needs confirmation] + (polls every 3 s) │ + │ row menu → Verify (FR-12) + │ sheet shows the photo, table, items, total + │ ┌──────────────┴──────────────┐ + │ Approve Reject + │ │ │ + │ PaymentVerificationUsecase.Approve …Reject (FR-4) + │ verification_status='approved' finalizeUncollectedPayment( + │ DELETE photo cancelled, reason='rejected') + │ cart → converted ├ transaction soft-deleted + │ Enqueue(kind='order_paid') ← "start making" ├ availability released + │ │ └ DELETE photo + ▼ ▼ │ + "Sedang disiapkan · bayar Rp 45.000 KDS push: "New order #12" ▼ + di kasir saat mengambil" barista prepares guest: "Pesanan ditolak" + │ │ + │ row menu → Mark as Ready (existing) + │ │ CompleteTransaction → WhatsApp outbox + ▼ ▼ + "Siap diambil! Bayar Rp 45.000" Fonnte WhatsApp: "Pesanan siap… Bayar di kasir (COD)" + │ │ + guest walks to the counter, shows #12 │ + │ row menu → Pay (existing TransactionPaymentAlert, + │ wallet chosen by the barista) + │ TransactionUsecase.PayTransaction + │ ├ payTransaction(): wallet, income, paid_at + │ │ (its order_paid enqueue is a no-op — already sent) + │ └ settleOrderPayment(): payments.status='paid' + ▼ ▼ + done row: [Order] [Ready] [Paid · Cash] +``` + +**The shape worth noticing:** COD adds exactly one new human step — *verify* — and one new kind of data — *a photo that lives only until that step*. Everything after approval is machinery that already exists: `Mark as Ready` (Fulfil FR-6), the WhatsApp ready message (WA PRD), and the cashier's `Pay` flow with `settleOrderPayment` (Cash FR-6). No new payment gateway, no new transaction column, no new POS pay screen. + +### The two state axes + +A COD payment moves along **two independent axes**, and keeping them separate is the central design decision (D2): + +| Axis | Column | Values for COD | Question it answers | +|---|---|---|---| +| **Money** | `payments.status` (existing) | `pending` → `paid` · or `cancelled` / `expired` | *Has the money been collected?* | +| **Presence** | `payments.verification_status` (**new**) | `awaiting` → `approved` · (`null` for QRIS / cash) | *Has a barista confirmed this guest is in the café?* | + +``` + payments.status = pending status = paid + ┌──────────────────────────────────────────────┐ ┌────────────┐ + checkout ────► │ verification = awaiting ──approve──► approved │─Pay─►│ approved │ + └───────┬───────────────┬──────────────┬───────┘ └────────────┘ + │ reject │ guest cancel │ window elapses (sweeper) + ▼ ▼ ▼ + cancelled (reason=rejected) cancelled (reason=guest) expired + └──────────── transaction soft-deleted · availability released · photo DELETED ───┘ +``` + +An **approved** COD payment is `pending` — no money has moved — but is **never expired and never guest-cancellable** (FR-5): the bar has started making it. + +### How the user's statuses map onto the data + +The acceptance criteria name five transaction statuses. None of them is a new stored column; each is derived from what the row already carries, the same way Fulfil FR-1 derives `preparing` / `ready`: + +| Status in the AC | POS badge | `payments` | `transactions` | Guest screen | +|---|---|---|---|---| +| **Need confirmation** | `Needs confirmation` (amber) | `pending`, `awaiting` | unpaid, `completed_at NULL` | *Menunggu konfirmasi barista…* | +| **Preparing** | `Preparing` + `COD · unpaid` | `pending`, `approved` | unpaid, `completed_at NULL` | *Sedang disiapkan · bayar di kasir saat mengambil* | +| **Ready** | `Ready` + `COD · unpaid` | `pending`, `approved` | unpaid, `completed_at` set | *Siap diambil! Bayar Rp X di kasir* | +| **Success / paid** | `Ready` + paid footer | `paid`, `approved` | `paid_at` set | *Siap diambil* (unchanged) | +| **Rejected** | — (row gone: soft-deleted) | `cancelled`, reason `rejected` | `deleted_at` set | *Pesanan ditolak* | + +### Changed tables + +One migration, `000044_add_payment_verification` (`000043_create_whatsapp_number_verifications` is the latest): + +```sql +ALTER TABLE payments + ADD COLUMN verification_status VARCHAR(16) NULL AFTER status, + ADD COLUMN verified_at DATETIME NULL AFTER verification_status; + +CREATE TABLE payment_verification_photos ( + payment_id BIGINT NOT NULL, + content_type VARCHAR(32) NOT NULL, + byte_size INT NOT NULL, + data MEDIUMBLOB NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (payment_id), + CONSTRAINT fk_payment_verification_photos_payment + FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**No other schema change.** `payments.method` (`VARCHAR(16)`) takes `'cod'`; `payments.cancel_reason` (`VARCHAR(16)`, migration `000041`) takes `'rejected'`; `kds_notifications.kind` (`VARCHAR(20)`, migration `000035`) takes `'cod_verification'`. All three are values, not migrations — exactly what QRIS D5 and Cash D8 shaped those columns for. + +### New and changed API surface + +| Method | Path | Auth | Change | +|---|---|---|---| +| `POST` | `/carts/current/checkout` | session | `method` enum gains `cod`; new optional `verificationPhoto` (base64 JPEG/WebP), **required iff `method = cod`**; `400` when `ORDER_COD_PAYMENT_ENABLED` is off | +| `GET` | `/payments/{ref}`, `/payments` | session / access key | `Payment` and `PaymentSummary` gain `verificationStatus: awaiting \| approved \| null`; `cancelReason` gains `rejected`. **The photo is never in a guest response.** | +| `GET` | `/transactions`, `/transactions/{id}` | `CheckAuth` | `Transaction` gains `paymentVerificationStatus`, joined exactly as `payment_method` already is (`transaction_repo.go:22`) | +| `GET` | `/transactions/{transactionId}/verification` | `CheckAuth` | **new** — `{ photo: "data:image/jpeg;base64,…", capturedAt }`; `404` once the photo is gone | +| `PUT` | `/transactions/{transactionId}/verification/approve` | `CheckAuth` | **new** — `SuccessResponse` | +| `PUT` | `/transactions/{transactionId}/verification/reject` | `CheckAuth` | **new** — `SuccessResponse` | + +The staff routes hang off `/transactions/{id}` because that is the only identifier the POS holds (it never sees a payment reference), mirroring `/pay`, `/unpay`, `/complete`. + +### Feature flags + +| Variable | Where | Default | Effect | +|---|---|---|---| +| `ORDER_COD_PAYMENT_ENABLED` | `apps/api/.env` | `false` | Server-side gate: a `cod` checkout is `400` when off. Verify / approve / reject stay available so orders in flight can drain after the flag is flipped off (D12). | +| `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED` | `apps/order-web` | `false` | Shows the COD option in the details sheet. | +| `NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED` | `apps/order-web` | *(existing)* | Set to `false` for the trial — this is how cash is hidden (D11). | +| `COD_VERIFICATION_EXPIRY_SECONDS` | `apps/api/.env` | `900` | How long an unverified COD order waits for a barista before the sweeper expires it (D8, D19). | + +Trial configuration: API `ORDER_COD_PAYMENT_ENABLED=true`; order-web `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED=true`, `NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED=false` → the sheet shows **QRIS** and **COD**, nothing else. + +### Changed backend files + +| Layer | File | Change | +|---|---|---| +| Entity | `domain/payment_entity.go` | `PaymentMethodCod`; `PaymentVerificationStatus` (`awaiting`, `approved`); `PaymentCancelReasonRejected`; `Payment.VerificationStatus`, `VerifiedAt`; `IsExpirable()`; `CanBeCancelledBy` refuses an approved payment | +| Entity | `domain/payment_verification_entity.go` | **new** — `PaymentVerificationPhoto`; `ValidateVerificationPhoto(bytes)` (size cap, sniffed content type) | +| Repo iface | `domain/payment_verification_repository.go` | **new** — `Create`, `GetByPaymentId`, `DeleteByPaymentId`, `DeleteOrphaned(ctx, limit)` + `//go:generate mockgen` | +| Use case | `domain/payment_usecase.go` | `Checkout` COD branch (FR-2); `finalizeUncollectedPayment` deletes the photo (FR-6); `refreshPendingCashPaymentStatus` and `expireOne` never expire an approved payment (FR-5) | +| Use case | `domain/payment_verification_usecase.go` | **new** — `GetVerification`, `Approve`, `Reject` (FR-4) | +| Use case | `domain/transaction_usecase.go` | `PayTransaction` refuses a COD payment still `awaiting` (FR-5) | +| Message | `domain/kds_notification_entity.go` | `KdsNotificationKindCodVerification` title/body; `order_paid` body gains a `COD — collect Rp X at pickup` line for an unpaid COD transaction (FR-9) | +| Message | `domain/guest_whatsapp_message.go` | COD label and a pay-at-pickup line (FR-10) | +| MySQL | `data/mysql/payment_{repo,entity,transformer}.go` | new columns; `GetExpirablePayments` excludes `verification_status = 'approved'` | +| MySQL | `data/mysql/payment_verification_repo.go` (+ entity) | **new** | +| MySQL | `data/mysql/transaction_repo.go` | `payments.verification_status AS payment_verification_status` beside the existing `payment_method` select | +| REST | `presentation/restapi/payment_{handler,transformer}.go` | parse/validate `verificationPhoto`; `http.MaxBytesReader` on checkout (none exists today); serialise `verificationStatus` | +| REST | `presentation/restapi/payment_verification_{handler,route,transformer}.go` | **new** — the three staff routes under `CheckAuth` | +| Wiring | `main.go`, `utils/env.go` | the two env vars; the orphan-photo job on the existing maintenance sweeper tick | + +`data/doku/**`, `PaymentGatewayRepository` and `TransactionPaymentAlert` are **not touched** (D1). + +### Changed frontend slice (`libs/ui`) + +``` +domain/entities/Payment.ts PaymentMethod += 'cod'; verificationStatus; PaymentCancelReason += 'rejected' +domain/entities/Transaction.ts paymentVerificationStatus +domain/repositories/payment.ts checkout(…, method, verificationPhoto?) +domain/repositories/transaction.ts fetchVerification / approveVerification / rejectVerification +data/api + data/mock the above, plus COD fixtures +domain/usecases/checkout.ts verificationPhoto in Context; CAPTURE_PHOTO / RETAKE_PHOTO +domain/usecases/orderStatus.ts awaitingVerification state; COD preparing/ready carry payAtPickup +domain/usecases/transactionVerification.ts NEW — hidden → loading → shown → approving|rejecting → … + +presentation/views/components/base/CameraCapture/ NEW — index.tsx (getUserMedia) + index.native.tsx stub +presentation/views/components/checkout/CustomerDetailsSheet.tsx method list from enabledMethods; COD photo step +presentation/views/components/checkout/CodVerificationView.tsx NEW — "Menunggu konfirmasi barista" +presentation/views/components/orderStatus/Order{Preparing,Ready}View.tsx pay-at-pickup banner +presentation/views/components/transactions/TransactionListItem.tsx badges; Verify menu item; guards +presentation/views/components/transactions/TransactionVerificationSheet.tsx NEW — photo + Approve / Reject +presentation/handlers/order/{Cart,OrderStatus}Handler.tsx +presentation/handlers/pos/TransactionListHandler.tsx +app/order/Cart.tsx reads NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED → enabledMethods +app/pos/TransactionList.tsx constructs TransactionVerificationUsecase +``` + +--- + +## Problem Statement + +The order app offers two payment methods today, both behind `CustomerDetailsSheet` (`libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.tsx:122`): + +- **QRIS** — the guest pays at the table. Good experience; the order goes straight to the bar (`applyQrisStatus` → `payTransaction` → `order_paid` KDS push). +- **Cash at the cashier** (`docs/prd-order-cash-payment.md`) — the guest checks out, then **must walk to the counter immediately** and pay within `CASH_PAYMENT_EXPIRY_SECONDS` (600 s), or `ExpireStalePayments` (`apps/api/domain/payment_usecase.go:513`) cancels the order. Nothing is made until money changes hands, because the KDS `order_paid` notification is enqueued only inside `payTransaction` (`apps/api/domain/transaction_usecase.go:359`). + +For a guest who does not want to or cannot pay by QRIS, cash turns "order from your table" back into "queue at the counter" — the exact interaction the order app exists to remove. They then walk to the counter a **second** time to collect their drink. Two trips, one of them to stand in line while nothing is being made. + +### Root cause + +The system ties **"start making it"** to **"money collected"**. For a gateway payment that is the right rule: the money is certain before the bar lifts a finger. For a guest paying cash it forces the payment trip to happen *before* preparation, so the guest pays and then waits, instead of waiting at the table and paying on pickup. + +Café staff already know how to make an unpaid order safely — they do it every time a known guest says "I'll pay when I pick it up". The risk they manage is **the guest who is not actually here** (a prank order from outside, a guest who has already left). What the system is missing is a way for staff to make that judgement from the POS: evidence that the guest is in the café, a decision, and then the ordinary prepare → ready → pay flow — with the payment step *last*. + +--- + +## How comparable products handle this + +- **"Bayar di kasir" / pay-later in Indonesian QR-ordering products** (the category this app follows, see Fulfil PRD Sources) is common for dine-in: the order goes to the kitchen and the bill is settled at the counter or at the table on the way out. Their fraud control is the *table* — a QR bound to a physical seat. +- **Delivery COD** (GoFood / GrabFood cash) verifies the *customer's account history* and caps order value; the courier bears the no-show risk. +- **Proof-of-presence by photo** is used by attendance and field-sales apps (a selfie or surroundings photo taken in-app, never from the gallery), reviewed by a human or by geofencing. + +This design combines the first and third: the table QR already implies presence, and the live photo plus a human decision closes the gap the table QR leaves open (a QR photographed and used from outside, or a guest who left). A value cap, borrowed from delivery COD, was considered and deliberately left out of the trial (D18). + +> **Verification note.** As in Cash PRD's equivalent section, no vendor documentation was fetched while writing this; the paragraph is general familiarity with the category and the design does not depend on it describing any specific product accurately. No Sources section is offered rather than a fabricated one. + +--- + +## Alternatives Considered + +### 1. How the verification state is modelled + +**Option A — an orthogonal `payments.verification_status` column; the money state stays `pending` until paid. (Recommended)** +- ✅ True to the data: an approved COD order *is* uncollected money. `pending` is the honest money state, and every consumer of "pending" keeps meaning the right thing — `settleOrderPayment` still flips it to `paid` (`transaction_usecase.go:283`), order history still lists it (`paymentHistoryFilter`, `payment_repo.go:76`), idempotency still finds it (`GetPendingPaymentByCartId`). +- ✅ Rejection reuses the existing terminal shape: `cancelled` + `cancel_reason = 'rejected'`, through `finalizeUncollectedPayment` — release, soft-delete, done. +- ✅ `null` for QRIS and cash, so no existing row changes meaning. +- ❌ Two places carry the COD lifecycle (status + verification). Every "is this pending payment expirable / cancellable?" check has to ask the second column too — FR-5 names the three that must. + +**Option B — new values on `payments.status`: `awaiting_verification`, `approved`, `rejected`.** +- ✅ One column, one state machine. +- ❌ Every one of the dozen `pending` reads in the payment path (idempotency, cart lock via `IsAwaitingPayment`, history, sweeper, cancel, settle, the frontend's `stateTypeForPayment` `match(...).exhaustive()`) must be audited and most widened to `IN (...)`. Missing one is a silent bug — e.g. a cart that stops freezing. +- ❌ `approved` would sit in a column that otherwise answers "where is the money", while no money has moved. + +**Option C — the state on `transactions` (a `verification_status` there).** +- ✅ The POS already reads transactions. +- ❌ A verification is a property of the *guest's payment attempt*, and it must be visible on the guest's `/payments/{ref}` read — which is a payment read. It would also put a COD-only column on every POS and rental row (Fulfil D22's "never read without a source guard" trap, again). + +### 2. Where the photo lives + +**Option A — a dedicated MySQL table, `payment_verification_photos`, `MEDIUMBLOB`, hard-deleted on decision. (Recommended)** +- ✅ **Atomic with the decision.** Approve, reject, cancel and expiry already run inside `BeginTransaction`; the `DELETE` joins that transaction, so "decided but photo still stored" cannot exist — the AC's storage promise is enforced by the database, not by a best-effort cleanup. +- ✅ No new infrastructure on a single-VPS deployment (`docs/trd-vps-deployment-automation.md`), no new credentials, no new backup target. +- ✅ A separate table keeps ~250 KB blobs out of every `SELECT payments.*` — the guest status poll and the history list never touch it. +- ❌ BLOBs in MySQL are usually a smell. Here the volume is bounded by *orders awaiting verification right now* (typically 0–5 rows, each ≤ 1 MiB) because every row is deleted within `COD_VERIFICATION_EXPIRY_SECONDS`; InnoDB reuses freed pages. It is a queue, not an archive. + +**Option B — files on the VPS disk (`/var/lib/gatherloop-pos/cod/…`).** +- ✅ Idiomatic for binary data. +- ❌ Not transactional: a crash between the DB commit and `os.Remove` leaves an orphan file; the reverse leaves a row pointing at nothing. Needs a reconciler to keep the AC's promise. +- ❌ New operational surface: a directory, permissions under the systemd unit (`apps/api/gatherloop-pos-api.service`), and a path the next deploy must preserve. + +**Option C — object storage (S3 / R2 / GCS).** +- ✅ Scales without thought; lifecycle rules can auto-delete. +- ❌ A new vendor, credentials and network dependency on the checkout path — for a trial whose steady state is a handful of short-lived images. + +### 3. How "camera only, never the gallery" is enforced + +**Option A — an in-app live viewfinder via `navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })`, captured to a canvas. (Recommended)** +- ✅ The only browser mechanism that *guarantees* the image came from the camera at that moment — there is no file picker in the flow at all, so there is no gallery to pick from. +- ✅ The canvas step downsizes and re-encodes (≤ 1280 px, JPEG q≈0.7), which both caps upload size and strips EXIF (including GPS) for privacy. +- ❌ Requires a camera permission prompt, HTTPS (already true) and a supported browser. In-app browsers (WhatsApp, Instagram) and very old WebViews may not support it — FR-7 falls back to "use QRIS", never to the gallery. + +**Option B — ``.** +- ✅ Zero UI to build; opens the native camera on most phones. +- ❌ `capture` is a *hint*: several Android browsers show a chooser including the gallery, and desktop ignores it entirely. It fails the AC as written ("must be camera, not pick up from gallery"). + +**Option C — geolocation / Wi-Fi-based presence instead of a photo.** +- ✅ No human review. +- ❌ Not what the AC asks for; browser geolocation indoors is tens of metres off, and a permission prompt for location is a harder sell than for a camera. Recorded as a possible *complement* (Out of Scope). + +### 4. How the photo reaches the API + +**Option A — base64 inside the existing checkout JSON. (Recommended)** +- ✅ One request, one DB transaction: the order and its evidence are created together or not at all. No orphan uploads. +- ✅ Stays inside the OpenAPI-generated JSON clients on both sides; no multipart handling in the Go handlers or the TS client. +- ❌ ~33% encoding overhead — ~300 KB becomes ~400 KB. Irrelevant at one photo per order. + +**Option B — `multipart/form-data` checkout.** ❌ Changes the content type of an existing endpoint every client uses, and the generated clients handle multipart awkwardly. + +**Option C — upload first (`POST /verification-photos` → id), then checkout with the id.** ❌ Two requests, and a guest who uploads then abandons leaves an orphan the system must garbage-collect — the very storage problem the AC forbids. + +--- + +## Proposed Solution + +### Acceptance criteria → requirements + +| AC | Requirement | +|---|---| +| Customer must open the camera (not gallery) and photograph their surroundings | FR-7, FR-8 | +| A transaction is created with payment method COD and status "need confirmation" | FR-1, FR-2, FR-12 | +| Barista opens the transaction item menu and chooses Verify | FR-12 | +| The image is shown with Approve and Reject | FR-4, FR-12 | +| Approved → preparing; rejected → rejected, payment and transaction deleted | FR-4, FR-13 | +| After approval the barista prepares the order | FR-4, FR-9 | +| Mark ready → WhatsApp sent → customer picks up and pays | FR-10 (existing ready flow) | +| Barista pays (chooses wallet) → transaction success and paid | FR-5 (existing pay flow + guard) | +| Verification image removed after it is confirmed (approved or rejected) | FR-3, FR-4, FR-6 | +| Trial feature flag; cash hidden so only QRIS and COD are shown | FR-11 | + +### FR-1 — COD crosses the contract (API) + +- `PaymentCheckoutRequest.method` enum becomes `[qris, cash, cod]`; `ParsePaymentMethod` accepts `cod`. +- `PaymentCheckoutRequest.verificationPhoto` — optional `string` (base64, no data-URL prefix). The **handler** rejects `method = cod` without it, and any other method *with* it, with `400` — the use case never sees an inconsistent pair. +- `Payment.verificationStatus`, `PaymentSummary.verificationStatus` — `enum [awaiting, approved]`, nullable. `cancelReason` enum gains `rejected`. +- `Transaction.paymentVerificationStatus` — same enum, nullable, `null` for POS rows. +- `Payment.RequiresGateway()` is unchanged (`method == qris`), so COD takes the non-gateway branch everywhere cash does — expiry on the clock, no DOKU query — without a new condition (Cash D2's "fail closed" rule doing its job). + +### FR-2 — The COD checkout branch (API) + +`PaymentUsecase.Checkout` keeps every step before payment creation. For `method = cod`: + +| Step | Behaviour | +|---|---| +| Gate | `ORDER_COD_PAYMENT_ENABLED` off ⇒ `400 "payment method is not available"` before any write. | +| Photo | `ValidateVerificationPhoto` — decoded ≤ `codVerificationPhotoMaxBytes` (1 MiB), `http.DetectContentType` ∈ {`image/jpeg`, `image/webp`}; else `400`. Runs **before** `BeginTransaction`, like the WhatsApp check (WA-validation D5). | +| Payment | `status = pending`, `verification_status = awaiting`, `expired_at = now + COD_VERIFICATION_EXPIRY_SECONDS`, `qr_content = ''`. | +| Photo row | `PaymentVerificationRepository.Create` inside the same transaction. | +| KDS | `EnqueueForTransaction(kind = cod_verification)` — see FR-9 — and `TriggerDispatch` after commit, as the cash branch does (`payment_usecase.go:279`). | +| Idempotency | Unchanged and method-agnostic (QRIS D11): a live pending payment on the cart is returned as-is, **and its stored photo is not replaced** — the barista verifies the photo the order was created with. | + +The checkout route gains `http.MaxBytesReader` at 2 MiB. Nothing in `presentation/restapi` bounds a request body today; the photo is what makes that matter. + +### FR-3 — The photo store (API) + +`payment_verification_photos` (see migration) behind `PaymentVerificationRepository`: + +```go +Create(ctx, photo PaymentVerificationPhoto) *Error +GetByPaymentId(ctx, paymentId int64) (PaymentVerificationPhoto, *Error) +DeleteByPaymentId(ctx, paymentId int64) *Error // NotFound is not an error +DeleteOrphaned(ctx, limit int) (int64, *Error) // photos whose payment is not pending+awaiting +``` + +**Invariant (D5): a photo row exists if and only if its payment is `cod`, `pending` and `awaiting`.** Every transition out of that state deletes the photo in the same DB transaction (FR-4, FR-6). `DeleteOrphaned` is a backstop, run on the existing maintenance sweeper tick (`KDS_DISPATCH_INTERVAL_SECONDS`), that should always delete zero rows; a non-zero count is logged at `warn` because it means some path broke the invariant. + +The photo is **never** logged, never included in a guest response, and never included in a `Transaction` or list response — only `GET /transactions/{id}/verification` returns it. + +### FR-4 — Verify, approve, reject (API) + +New `PaymentVerificationUsecase` in `domain/payment_verification_usecase.go`. All three methods resolve the payment with `GetPaymentByTransactionIdForUpdate` — the same row lock `PayTransaction` takes (`transaction_usecase.go:236`), so approve, reject, a guest cancel and the sweeper serialise instead of racing (Cancel D6). + +**`GetVerification(ctx, transactionId)`** → `{ photo, capturedAt }`. `404` if there is no linked payment, it is not COD, or the photo is gone (already decided). + +**`Approve(ctx, transactionId)`** — guards: linked payment exists, `method = cod`, `status = pending`, `verification_status = awaiting`; otherwise `400` naming the state ("order already verified", "order was cancelled"). Then, in one transaction: +1. `verification_status = approved`, `verified_at = now`; +2. `DeleteByPaymentId` — the AC's storage promise; +3. cart → `converted`, so the guest can start a new order while this one is made (the cart was frozen since checkout, Cash D12); +4. `EnqueueForTransaction(kind = order_paid)` — the bar's existing "start making it" signal (D6); +5. `TriggerDispatch` after commit. + +A payment whose `expired_at` has passed but which the sweeper has not yet reached (≤ 15 s) **is still approvable**: the lock makes it a clean race, and the barista's decision beats a sweeper that is late by a tick (Cash D23's spirit). + +**`Reject(ctx, transactionId)`** — same guards. Then `finalizeUncollectedPayment(ctx, payment, PaymentStateCancelled, &PaymentCancelReasonRejected, …)`, which already moves the payment to `cancelled`, releases availability and soft-deletes the transaction (`payment_usecase.go:418`) — and, from FR-6, deletes the photo. `verified_at = now` is set as well so the decision time is recorded. No KDS retraction: the `cod_verification` push asked a barista to look, and a barista just did. + +"All payment and transaction will be deleted" in the AC is implemented as the codebase's existing meaning of deleted: the transaction is **soft-deleted** (absent from every POS list and report, `deleted_at` set) and the payment reaches a terminal state. Hard-deleting them would break the guest's status page — which must be able to say *"ditolak"* — and the audit trail every other terminal path keeps (D7). + +### FR-5 — The lifecycle guards an approved order needs (API) + +An approved COD payment is `pending` (Option A) but must behave as *committed*: + +| Path | Today, for any pending non-gateway payment | For COD | +|---|---|---| +| Sweeper `GetExpirablePayments` (`payment_repo.go:117`) | expires when `expired_at < now` | adds `AND (verification_status IS NULL OR verification_status <> 'approved')` | +| Guest poll `refreshPendingCashPaymentStatus` (`payment_usecase.go:832`) | expires on the clock | returns unchanged when `!payment.IsExpirable()` | +| Guest cancel `CanBeCancelledBy` (`payment_entity.go`) | any pending payment of the session | `false` once `approved` — the bar is making it | +| Cashier pay `PayTransaction` (`transaction_usecase.go:224`) | pays any unpaid transaction | `400 "verify the order before taking payment"` while `awaiting` | +| `Mark as Ready` `CompleteTransaction` | any order transaction | `400` while `awaiting` — nothing should be made before approval | + +`Payment.IsExpirable()` = `status == pending && verification_status != approved`, and is the single predicate the domain-side paths above ask. + +Paying an approved COD order runs the existing path unchanged: `payTransaction` credits the wallet the barista chose, and its `order_paid` enqueue is a no-op because approval already inserted that `(transaction_id, kind)` row (KDS D4 / Cash D8's unique key) — so paying does **not** buzz the bar a second time. `settleOrderPayment` flips the payment to `paid`; the cart is already converted, which it tolerates. + +### FR-6 — Every terminal path deletes the photo (API) + +`finalizeUncollectedPayment` gains the `PaymentVerificationRepository` and calls `DeleteByPaymentId` for `method = cod`. That one edit covers reject (FR-4), guest cancel (`CancelPayment`), sweeper expiry (`expireOne` → `expirePayment`), guest-poll expiry, and supersede (`supersedeLivePayments`) — every way a COD order can end without approval — because they already share this function (Cash FR-4's "one definition of what giving up does"). + +### FR-7 — Camera capture (frontend) + +A new base component `presentation/views/components/base/CameraCapture/` with the platform split already used by `base/Chart/index.native.ts`: + +- `index.tsx` (web): requests `getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false })`, renders a live `