Fix silent tip/gift-card money loss on pending retry; terminal checkout wire; sentinel error; docs

CRITICAL — same-amount tip retry silently never charged:
- CreateTipPayment idempotency check now only short-circuits when the
  existing record is 'completed'. A 'pending' record (previous Square call
  failed) is REUSED and the charge re-attempted with the same key (Square
  dedups safely), instead of returning the stale pending record as 200 with
  a success toast and no charge.
- Same fix in BuyGiftCard: pending records trigger a re-attempt, not a
  false-success response. Unique idempotency_key constraint means the
  pending record must be reused, not re-inserted.
- Fixes the savepoint/rollback interaction: the nested tx (savepoint) is
  now committed in the reuse path so the deferred rollback doesn't undo the
  later status UPDATE on the same connection.
- Regression test: TestTipPayment_RetryPending_ReattemptsCharge verifies a
  pending record + same-key retry re-attempts and completes, reusing the
  record (count stays 1).

MAJOR — terminal checkout wire contract:
- device_id now sent as checkout.device_options.device_id (Square's required
  shape), not a top-level field which Square rejects with 400.
- 'checkout pending' detection now uses typed sentinel ErrCheckoutPending
  with errors.Is in both handlers, matching mock and real HTTP client.

MAJOR — exp_month/exp_year omitted from card creation payload when unset
(now *int with omitempty) — Square would 400 on 0/0; expiry comes from the
tokenized source.

Docs:
- README payments/infrastructure sections corrected (Web Payments SDK claim
  replaced with accurate P11-backlog note; dev mock parity described)
- Future Work P11 updated to reflect raw-PAN rejection is now enforced in
  both mock and prod (new-card flows are a documented dead end)
- Added plans/p11-square-web-payments-sdk.md: full implementation plan +
  handoff prompt for the agent picking up P11 (Web Payments SDK nonces)
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent bbb55dae82
commit 3db8b54923
9 changed files with 342 additions and 76 deletions
@@ -35,7 +35,7 @@ These are things that work fine in dev (with mocks) but need real implementation
| P8 | **Social auth stubs (Google/Microsoft/Facebook)** | L (2-3d) | Backend + Frontend | `handlers/auth/social.go` is 1 line (`package auth`). Frontend login page has 3 social buttons that show `toast.info("${provider} login coming soon")`. The `user_social_logins` table and `account_type` enum values exist from early schema design. | The schema was designed for social auth from the start (table + enum values). The OAuth flow itself was never implemented. Buttons exist as UI placeholders. |
| P9 | **Tip payments: replace placeholder card tokens** | S (1d) | Frontend | ✅ COMPLETED July 2026 — `card_token: 'placeholder'` replaced with real saved card selection + CardInput with Luhn/expiry/CVC validation across all 3 tip pages. | |
| P10 | **No automated database backups** | M (1d) | Infrastructure | PostgreSQL volume is persistent in Docker but no `pg_dump` cron, no point-in-time recovery. | Standard production DB setup task. |
| P11 | **Square Web Payments SDK: replace CardInput with nonce-based flow** | M (3-5d) | Frontend + Backend | Frontend still sends raw PAN, expiry, and CVC as `new_card_token` for all card entry flows (tips, booking payment, gift cards, account add card, till purchases). In production, Square's API requires a `cnon:xxx` nonce generated by the Web Payments SDK. The mock (`square_dev.go`) parses raw PANs (detecting brand from first digit), masking this failure in development. | **Action plan:** 1) Load Square Web Payments SDK (script tag in `app.html` or via `@square/web-payments-sdk` npm). 2) Replace `CardInput.svelte` (hand-rolled inputs) with Square's native card form (`payments.card()`). 3) Call `card.tokenize()` to get `cnon:xxx` nonce client-side. 4) Send only the nonce as `new_card_token`. 5) Remove `card_expiry`/`card_cvc` from request bodies (already removed from tip flows). 6) Remove `CreateCardOnFileRaw` from production paths. |
| P11 | **Square Web Payments SDK: replace CardInput with nonce-based flow** | M (3-5d) | Frontend + Backend | Frontend still sends raw PAN, expiry, and CVC as `new_card_token` for all card entry flows (tips, booking payment, gift cards, account add card, till purchases). In production, Square's API requires a `cnon:xxx` nonce generated by the Web Payments SDK. The mock now mirrors production and rejects raw PANs (`CreateCardOnFileRaw` is blocked; `CreateCardOnFile` accepts only `cnon:`/`ccof:` tokens) — so these new-card flows are currently a dead end in BOTH dev and prod until this is landed or the UI is gated. | **Action plan:** 1) Load Square Web Payments SDK (script tag in `app.html` or via `@square/web-payments-sdk` npm). 2) Replace `CardInput.svelte` (hand-rolled inputs) with Square's native card form (`payments.card()`). 3) Call `card.tokenize()` to get `cnon:xxx` nonce client-side. 4) Send only the nonce as `new_card_token`. 5) Remove `card_expiry`/`card_cvc` from request bodies (already removed from tip flows). 6) Remove `CreateCardOnFileRaw` from production paths. |
---
@@ -0,0 +1,144 @@
# P11 — Square Web Payments SDK Implementation Plan
**Status:** READY TO PICK UP (deferred from July 2026 session)
**Owner:** Agent implementing P11 (Square Web Payments SDK)
**Estimated effort:** 3-5 days
**Backlog reference:** `Future Work - Gap Backlog.md` item P11
---
## Executive Summary
Every "enter a new card" flow in the app is currently a **dead end**. The frontend sends raw PAN, expiry, and CVC as `new_card_token` with no Square Web Payments SDK tokenization. The backend mock AND production both now reject raw PANs (PCI-DSS parity — `CreateCardOnFileRaw` is blocked, `CreateCardOnFile` accepts only `cnon:`/`ccof:` tokens). So a user entering a new card gets a guaranteed 500. **This plan makes new-card payments actually work** by integrating Square's Web Payments SDK client-side to generate `cnon:xxx` nonces.
---
## Current State (verified July 2026)
### Frontend — 6 flows send raw PAN as `new_card_token`:
1. `frontend/src/routes/tip/+page.svelte` (~line 283) — `body.new_card_token = newCardNumber.replace(/\s/g, '')`
2. `frontend/src/routes/pay-tip/[id]/+page.svelte` (~line 331) — same
3. `frontend/src/lib/components/account/UserBookingModal.svelte` (~line 353) — same (tip modal)
4. `frontend/src/lib/components/payments/UserPaymentModal.svelte` (~line 348) — booking payment
5. `frontend/src/lib/components/booking/BookingFlow.svelte` (~line 359) — deposit
6. `frontend/src/routes/account/+page.svelte` (~line 332) — Buy a Gift Card
Plus raw PAN + CVC to add-card (`account/+page.svelte:577``CreatePaymentMethodFromDetails``CreateCardOnFileRaw`) and admin till (`GiftCardsManagement.svelte:655``till.go:439``CreateCardOnFileRaw`).
### The reusable `CardSelection.svelte` component:
`frontend/src/lib/components/payments/CardSelection.svelte` — the standard card-selection UI (saved card list + "Use a new card" + `CardInput` with blur-based Luhn/expiry/CVC validation). **Currently applied to only 1 of 5 card UIs** (UserPaymentModal). The tip flows, account Buy Gift Card, account Add Card, and BookingFlow still have ~100 duplicated lines each.
### `CardInput.svelte`:
`frontend/src/lib/components/payments/CardInput.svelte` — the hand-rolled card entry form (number/expiry/CVC inputs, formatNumber/formatExpiry, onfieldblur/onfieldinput callbacks). **This is what gets replaced by Square's native card form.**
### Backend (already P11-ready):
- `backend/internal/square/square_http_client.go``createCardOnFileHTTP` accepts a `source_id` token and calls `POST /v2/cards`. Works with `cnon:xxx` nonces.
- `backend/handlers/payments/handlers.go``CreateTipPayment` accepts `new_card_token` and passes it as the source. Works with nonces.
- `CreatePaymentMethodFromDetails` (service.go:496) calls `CreateCardOnFileRaw`**needs migration to the nonce path**.
- Till `online_square` (till.go:439) calls `CreateCardOnFileRaw`**needs migration to the nonce path**.
---
## Prerequisites
1. **Square application credentials**:
- `SQUARE_APPLICATION_ID` (client-side, public)
- `SQUARE_LOCATION_ID` (already used server-side)
- Frontend needs the application ID in the browser context (e.g. `PUBLIC_SQUARE_APPLICATION_ID` Vite env var)
2. **Square account with Web Payments enabled** and a card processing merchant account.
3. Frontend must be HTTPS (or localhost) for the SDK to load.
---
## Implementation Steps
### Step 1 — Load the Square Web Payments SDK
Two options (pick one):
- **npm**: `@square/web-payments-sdk` — provides `Square.payments(appId, locationId)`
- **script tag**: `<script src="https://sandbox.web.squarecdn.com/v1/square.js" type="text/javascript"></script>` in `app.html` (sandbox) or `https://web.squarecdn.com/v1/square.js` (prod)
Load based on `SQUARE_ENVIRONMENT` so sandbox/prod use the right URL.
### Step 2 — Create a Square card form component
Replace `CardInput.svelte`'s hand-rolled inputs with Square's native card form:
```js
const payments = window.Square.payments(appId, locationId);
const card = await payments.card();
await card.attach('#square-card-container');
// ...on submit:
const tokenResult = await card.tokenize();
// tokenResult.token → "cnon:xxx"
```
**Design decision**: Either:
- (a) Embed the Square form inside `CardInput.svelte` (keep the `bind:cardNumber` etc. API surface but use Square's iframe internally — the inputs become read-only display), OR
- (b) Create a new `SquareCardInput.svelte` and swap it into `CardSelection.svelte` when a Square app ID is configured, falling back to the hand-rolled form when `SQUARE_APPLICATION_ID` is absent (keeps dev/testing working without Square).
**Recommendation**: (b) — a `SquareCardInput.svelte` with a fallback. This keeps local dev usable when no Square app ID is configured, while production uses real tokenization.
### Step 3 — Update the 6 payment flows to send nonces
For each flow, `new_card_token` must become the `cnon:xxx` token from `card.tokenize()`, NOT the raw PAN. Since all 6 flows go through `CardSelection.svelte` (or the fallback path), the cleanest approach:
1. First **extend `CardSelection.svelte` to all 5 remaining card UIs** (tip ×3, account Buy Gift Card, account Add Card, BookingFlow) — this centralises the card-selection logic so P11's change is one place, not six.
2. Then swap the card entry inside `CardSelection.svelte` to use `SquareCardInput` (Step 2b).
3. Remove `card_expiry`/`card_cvc` from all request bodies (already removed from tip flows).
### Step 4 — Migrate the two raw-PAN backend paths
- `CreatePaymentMethodFromDetails` (account add-card): change `CreateCardOnFileRaw` call to use the nonce path (`CreateCardOnFile` with the `cnon:xxx` token).
- Till `online_square` (`till.go:439`): same migration — `CreateCardOnFileRaw``CreateCardOnFile` with a nonce. The frontend `GiftCardsManagement.svelte` till flow must send the nonce instead of raw PAN.
### Step 5 — Remove `CreateCardOnFileRaw` entirely
Once both callers are migrated:
- Delete `CreateCardOnFileRaw` from the `SquareClient` interface (`backend/internal/square/types.go:140`)
- Delete from all 3 implementations (MockClient, ProdClient, devProdClient)
- Delete the PCI-block error stubs
- Update the `square_dev_test.go` tests that assert the raw-PAN rejection (`TestDevClient_CreateCardOnFileRaw_Rejected_ProdParity` → the method no longer exists)
- Update handler tests that assert the 500 on raw-PAN add-card
### Step 6 — Update docs
- `README.md` line 9: remove the "P11 — production nonce integration is backlog item P11" caveat once landed
- `Future Work - Gap Backlog.md` P11: mark completed
- `Feature Catalog.md` and `Technical Manual.md`: update "Web Payments SDK (online)" claims if they reference the old state
---
## Testing Plan
1. **Unit tests**:
- `square_http_client_test.go`: add request-shape tests via `httptest.Server` for `createCardOnFileHTTP` (verify `source_id` is the token, `idempotency_key` deterministic, no raw PAN in body) — the HTTP client currently has only 2 tests (both `paymentFromSquare`).
- Error-path tests for the HTTP client (non-2xx, malformed body).
2. **Integration tests**:
- Handler tests using `cnon:` tokens through the mock (the mock accepts `cnon:` nonces) — restore success-path coverage for `TestCreatePaymentMethod_HappyPath` etc. (currently rewritten to assert the raw-PAN 500).
- Add a test: card created with nonce → payment with saved card works.
3. **Manual/sandbox tests** (requires Square sandbox credentials):
- Each of the 6 flows: enter new card → tokenize → pay → verify charge in Square dashboard.
- Saved-card flow still works.
- Refund still works.
---
## Risks / Gotchas
- **Square iframe requires HTTPS** — localhost is exempt, but any non-local dev URL needs TLS.
- **Tokenization is one-shot** — a `cnon:` nonce is single-use. The idempotency key logic (cached per payment attempt) handles retries, but a retry must NOT re-tokenize if the first tokenize succeeded and the payment failed — the backend's idempotency dedup handles this, but the frontend should reuse the cached token on retry if the payment record is pending.
- **The hand-rolled `CardInput.svelte` Luhn/expiry validation becomes cosmetic** — Square's iframe does the real validation. Keep the display validation for UX, but don't block submission on it alone.
- **PCI-DSS scope**: with nonces, PAN never touches our server. The `CreateCardOnFileRaw` migration is essential — do NOT leave it in place.
---
## Definition of Done
- [ ] Square Web Payments SDK loads (sandbox + prod URLs, env-gated)
- [ ] `SquareCardInput` (or embedded form) tokenizes cards → `cnon:xxx`
- [ ] All 6 payment flows send nonces, not raw PANs
- [ ] `CardSelection.svelte` used by all 5 card UIs
- [ ] `CreateCardOnFileRaw` deleted from interface + all implementations
- [ ] All handler tests pass with nonce-based flows
- [ ] Sandbox smoke test: new-card payment succeeds end-to-end
- [ ] Docs updated