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
This commit is contained in:
2026-07-01 09:57:03 +02:00
parent 266e9b0027
commit 018328a877
8 changed files with 121 additions and 4 deletions
+5 -2
View File
@@ -19,6 +19,7 @@ import { rootRoute } from "./router.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
@@ -428,8 +429,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<SnapshotStrip identity={identity} />
{/* Tender — shown for any payable case (transient, overstay, OR a
subscriber window charge that's still unpaid). */}
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
subscriber window charge that's still unpaid). Card is hidden until a
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED) — see
lib/features.ts + wiki/concepts/card-payments.md. */}
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && CARD_PAYMENTS_ENABLED && (
<div className="flex items-center gap-2">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => (
+6 -1
View File
@@ -25,6 +25,7 @@ import {
type SubscriptionPlan,
type SubscriptionQuote,
} from "./api.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { Modal } from "./ui/Modal.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
@@ -537,7 +538,11 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
)}
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
signed payment so the money shows in the feed/drawer/Z-report. */}
{form.planId.trim() !== "" && editing === "new" && (
{/* Tender picker — only meaningful when there's a choice. Card is hidden until a
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED); with cash-only there's
nothing to pick, so the whole row is suppressed (form.tender stays "cash").
See lib/features.ts + wiki/concepts/card-payments.md. */}
{form.planId.trim() !== "" && editing === "new" && CARD_PAYMENTS_ENABLED && (
<>
<label className="label">{t("subs.tender")}</label>
<span className="flex items-center gap-3">
+16
View File
@@ -0,0 +1,16 @@
// Client-side feature flags. Small, hand-flipped switches for capabilities the app
// SUPPORTS in code but that aren't provisioned on-site yet — so the UI doesn't offer an
// action the site can't fulfil.
/**
* CARD payments. The app models a `card` tender end-to-end (server, shift accounting,
* reports), but a card sale needs a bank-certified **P2PE POS terminal** on-site, and we
* have NONE yet (2026-07-01). Until one is procured + configured, the booth/subscription
* tender pickers show CASH only — offering "Card" would let an operator record a card
* payment that never actually cleared a terminal, corrupting the till reconciliation.
*
* Flip to `true` (and add the POS device config) once a terminal is on-site. Nothing about
* the `Tender` type or historical `card` events changes — this only gates the UI *offer*.
* See wiki/concepts/card-payments.md (future POS device requirements).
*/
export const CARD_PAYMENTS_ENABLED = false;