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:
@@ -19,6 +19,7 @@ import { rootRoute } from "./router.js";
|
|||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
|
|
||||||
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
// 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} />
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
{/* Tender — shown for any payable case (transient, overstay, OR a
|
{/* Tender — shown for any payable case (transient, overstay, OR a
|
||||||
subscriber window charge that's still unpaid). */}
|
subscriber window charge that's still unpaid). Card is hidden until a
|
||||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||||
{(["cash", "card"] as const).map((tn) => (
|
{(["cash", "card"] as const).map((tn) => (
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
type SubscriptionQuote,
|
type SubscriptionQuote,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
|
||||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
// 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
|
{/* 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. */}
|
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>
|
<label className="label">{t("subs.tender")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, payment, pci, p2pe, pos, threat-model]
|
||||||
|
sources: [parking-system-architecture]
|
||||||
|
updated: 2026-07-01
|
||||||
|
status: 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|reports]] 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|till reconciliation]] and the [[reconciliation|financial audit]].
|
||||||
|
On a system whose adversary is the [[threat-model|booth operator]], 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|device adapter]] 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.
|
||||||
@@ -148,6 +148,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
|||||||
`graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
|
`graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
|
||||||
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
|
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
|
||||||
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
|
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
|
||||||
|
(Card is currently **disabled in the UI** — no POS on-site yet; cash-only. See [[card-payments]].)
|
||||||
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
|
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
|
||||||
session closed, `verifyChain` ok.
|
session closed, `verifyChain` ok.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ procurement. (See [[parking-system-architecture]] §10.)
|
|||||||
[[fail-state-safety]].
|
[[fail-state-safety]].
|
||||||
3. **Payment subsystem.** Manned booth (P2PE terminal + cash drawer) vs unmanned pay station;
|
3. **Payment subsystem.** Manned booth (P2PE terminal + cash drawer) vs unmanned pay station;
|
||||||
confirm **PCI scope is kept out of the application** via a standalone certified terminal
|
confirm **PCI scope is kept out of the application** via a standalone certified terminal
|
||||||
(see [[bom]]).
|
(see [[bom]]). **No POS on any site yet (2026-07-01)** → card tender is **disabled in the UI**
|
||||||
|
(cash-only); the future-POS requirements + re-enable path are in [[card-payments]].
|
||||||
4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot,
|
4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot,
|
||||||
manager visit) to reconcile the signed log against an external authority — the real anti-fraud
|
manager visit) to reconcile the signed log against an external authority — the real anti-fraud
|
||||||
control. See [[reconciliation]].
|
control. See [[reconciliation]].
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
|
|||||||
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
||||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||||
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts.
|
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts.
|
||||||
|
- [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable.
|
||||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||||
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
||||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||||
|
|||||||
+14
@@ -2074,3 +2074,17 @@ DATABASE_URL=/data/parking.sqlite); the script ships in the deploy bundle next t
|
|||||||
(@parking/db has no `files` allowlist → whole pkg copied). Verified on throwaway dev-DB copies (both
|
(@parking/db has no `files` allowlist → whole pkg copied). Verified on throwaway dev-DB copies (both
|
||||||
gates refuse correctly; each flag wipes/keeps the right tables; real dev DB never touched). Recorded
|
gates refuse correctly; each flag wipes/keeps the right tables; real dev DB never touched). Recorded
|
||||||
in local-dev-workflow.md + appliance-provisioning.md §7d.
|
in local-dev-workflow.md + appliance-provisioning.md §7d.
|
||||||
|
|
||||||
|
## [2026-07-01] feat | Card tender DISABLED until a P2PE POS is on-site (cash-only)
|
||||||
|
|
||||||
|
No card processor / POS terminal on any site yet, so offering "Card" would let an operator record a
|
||||||
|
card payment that never cleared → corrupts till reconciliation ([[threat-model]] surface). Disabled
|
||||||
|
the card option in the UI: new apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false gates both
|
||||||
|
tender pickers (BoothPayModal.tsx, SubscriptionManager.tsx); with card off there's nothing to choose,
|
||||||
|
so the tender row is suppressed entirely and payment silently defaults to cash. UI-only gate — the
|
||||||
|
Tender="cash"|"card" type, payment events, shift accounting, and reports still understand card (so
|
||||||
|
historical card events + a future re-enable stay coherent). Verified via Playwright: an unpaid-ticket
|
||||||
|
modal shows Total + "Pay + open barrier" with NO tender/cash/card row. Re-enable = flip the flag once
|
||||||
|
a bank-certified P2PE terminal is provisioned (PCI scope stays out of the app — the terminal captures
|
||||||
|
card data, not the app). New page concepts/card-payments.md documents current state + future-POS
|
||||||
|
device requirements + re-enable path; linked from index, parking-session, open-questions #3.
|
||||||
|
|||||||
Reference in New Issue
Block a user