Files
julian 018328a877 feat(booth): disable card tender until a P2PE POS is on-site (cash-only)
No card processor / POS terminal on any site yet. Offering "Card" would let an
operator record a card payment that never cleared a terminal, corrupting the
till reconciliation — a fraud/error surface on an operator-adversary system.

Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both
tender pickers (BoothPayModal, SubscriptionManager). With card off there's
nothing to choose, so the tender row is suppressed and payment defaults to
cash. UI-only gate: the Tender type, payment events, shift accounting, and
reports still understand `card`, so historical card events and a future
re-enable stay coherent.

Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open
barrier" with no tender/cash/card row.

Wiki: new concepts/card-payments.md records the current cash-only state, the
PCI-scope-out-of-app constraint, the future-POS device requirements, and the
re-enable path (flip the flag once a bank-certified P2PE terminal is
provisioned). Linked from index, parking-session, open-questions #3.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 09:57:03 +02:00

4.6 KiB

type, tags, sources, updated, status
type tags sources updated status
concept
parking
payment
pci
p2pe
pos
threat-model
parking-system-architecture
2026-07-01 open

Card payments (P2PE POS) — disabled until a terminal is on-site

The app models a card tender end-to-end, but as of 2026-07-01 there is no card processor / POS terminal on any site, so the card option is disabled in the UI. This page records why, what a future POS needs, and how to re-enable — so the gap isn't rediscovered as "why can't I take a card?".

Current state — CASH ONLY (2026-07-01)

  • The booth pay/exit modal and the subscription-sale form show cash only. The tender picker is suppressed entirely when there's nothing to choose (payment silently defaults to cash).
  • Nothing about the data model changed: Tender = "cash" | "card" still exists ([[technology-stack| shared]] packages/shared), the server/payment events, shift accounting, and reporting-analytics all still understand card — this is only a UI gate, so any historical card events (or a future re-enable) stay coherent.
  • Flag: apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED = false. Both tender pickers (BoothPayModal.tsx, SubscriptionManager.tsx) render card only when it is true.

Why disable rather than leave it? Offering "Card" with no terminal lets an operator record a card payment that never actually cleared — money that isn't in the drawer and didn't hit the bank — which silently corrupts the shift and the reconciliation. On a system whose adversary is the threat-model, a tender the site can't fulfil is a fraud/error surface, not a convenience. Cash-only is the honest state until hardware exists.

The constraint a POS must satisfy — PCI scope stays OUT of the app

This is a standing architectural rule (bom, open-questions #3, parking-session): card capture goes through a standalone, bank-certified P2PE (point-to-point encryption) terminal — the application must never see card data (PAN, track, CVV). The app only records that a payment's tender was card; the terminal does the capture, encryption, and settlement against the acquiring bank. This keeps the whole appliance out of PCI-DSS scope, which is a hard requirement (a booth PC in PCI scope is a non-starter).

The terminal model is dictated by the acquiring bank (not our choice) — verify local availability (Albania/EU) when the bank is chosen. See bom "Payment".

Future POS device — what has to be configured (open)

When a terminal is procured, this is the outline (details TBD — flag on open-questions #3):

  1. Hardware: a bank-certified standalone P2PE terminal beside the booth PC + the cash drawer.
  2. Integration boundary: decide how the app learns a card sale succeeded WITHOUT touching card data — options range from manual (operator runs the card on the terminal, then confirms in the app → a card payment event) to a terminal-integration (the app requests an amount, the terminal returns an approved/declined result over a local link). The manual path keeps PCI scope trivially out; an integration must preserve the same boundary (no PAN ever reaches the app).
  3. Device model: if integrated, the terminal becomes a device-registry behind an interface (like reader/printer/relay) — a payment-terminal capability — so a hardware swap is a new adapter, nothing else. A manual terminal needs no adapter (it's off-system; the app just records the tender).
  4. Reconciliation: card takings must reconcile against the terminal's/bank's settlement report, separately from the cash drawer (card money never enters the drawer). shift Z-reports already split cash vs card totals — wire the card side to the terminal batch.

Re-enabling

  1. Provision + configure the terminal (per above).
  2. Flip CARD_PAYMENTS_ENABLED = true in apps/web/src/lib/features.ts. The tender pickers reappear.
  3. If integrated, add the payment-terminal adapter + wire the approved-result → card payment event. If manual, no code beyond the flag.
  4. Update this page (→ status: settled) and open-questions #3.

Relates

  • bom — the payment subsystem line (certified P2PE terminal + cash drawer, PCI-out-of-scope).
  • open-questions #3 — payment subsystem (manned booth P2PE vs unmanned pay station).
  • parking-session / shift — where tender is recorded and reconciled.
  • threat-model — why a tender the site can't fulfil is a fraud surface.