P14 plan: lazily provision Square customers only when a user saves a
card, charge one-off new-card payments via cnon: nonce directly (no
card/customer minted for non-savers or guests), and add a policy
pop-over on the consent checkbox linking to a /privacy-policy route
(mirroring the existing /cancellation-policy pattern).
Draft the Square processor disclosure into the Privacy Policy (2.2
Saved Cards & Square: lawful basis Art 6(1)(b), one-off statement,
retention, removal, Square privacy link) and cross-reference it in
Terms 3.2. Both remain placeholders pending owner review.
Add P14 row to the gap backlog.
@@ -35,6 +35,7 @@ These are things that work fine in dev (with mocks) but need real implementation
| 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. |
| P12 | **Square sandbox smoke test (pre-go-live gate)** | S-M (1d, once credentials available) | E2E | **BLOCKED — no real Square credentials available.** Must exercise the real API path end-to-end: new-card tokenization → payment → saved card → refund → reconcile, against Square's sandbox. Also verifies the M-8 open question (is `card.customer_id` enforced as Required?). | The dev mock cannot exercise Square's real wire contract (key-length limits, `device_options`, refund statuses, error codes). This is the sole remaining item before the production flip. See `plans/p11-square-web-payments-sdk.md` Remaining Items. |
| P13 | **Reconcile deterministically-keyed saved-card charges** | S (2-3h) | Backend | **Deferred — deliberate trade-off (N-OBS-1).** The admin "Charge Saved Card" idempotency key `bookingID-sc-type-amount-cardID` dedups two *identical* repeat charges on one booking. Not UI-reachable today (PaymentModal always sends the current `totalDue`, which changes after each charge). | Revisit if the admin flow ever gains a "charge exact amount twice" path — the key would then need a client nonce or attempt counter. Tracked from the final payment review. |
| P14 | **Square customer provisioning & consent** | S-M (1-2d) | Backend + Frontend + Docs | **PLANNED — gated on P12.** The final payment review flagged that Square's docs mark `card.customer_id` as Required on `POST /v2/cards` and on `ccof:` charges. If enforced, saved-card flows 400 in production. Plan: lazily provision Square customers only when a user saves a card, persist `square_customer_id`, charge one-off new-card payments directly with the `cnon:` nonce (no card/customer minted for non-savers or guests), update the privacy policy (Square as processor) and the card-save checkbox copy. **No standalone consent checkbox is required** — the existing card-save checkbox covers it. Includes a policy pop-over on the consent checkbox linking to `/privacy-policy` (mirrors the existing `/cancellation-policy` pop-over pattern — generalise `policyPopover.svelte`, new `/privacy-policy` route). Privacy Policy §2.2 + Terms §3.2 already drafted into the placeholders (Aug 2026). See `plans/p14-square-customer-provisioning-consent.md`. | The app currently mints a Square card-on-file for *every* new-card payment (even when not saving); if customer provisioning becomes mandatory, that would create customer profiles for all payers including guests. Data-minimisation design avoids this. Must sandbox-test first (P12): Square may not actually enforce `customer_id` (M-8/R2 open question). |
**Status:** DRAFT — Local development (not yet in production)
**Last Updated:**August 2026
**Status:** DRAFT — Local development (not yet in production). **§2.2 (Saved Cards & Square) is drafted from the P14 plan; the placeholder sections below still need to be made 'real' before go-live.**
---
@@ -39,9 +39,23 @@ Email: help@crussell.invalid
- Gift card codes and balances
- Account balances
- Payment transaction records (processed via Square, not stored by us)
- Saved-card references (tokenised, stored with our payment provider Square — see §2.3)
- Dormant balance records (Account ID only, no PII)
### 2.2 Special Category Data (Health Data)
### 2.2 Saved Cards & Payment Provider (Square)
When you choose to **save a card for next time**, we store a tokenised reference to your card with our payment processor, **Square** (a data processor), rather than on our own systems.
- **What Square stores:** a tokenised reference to your card (never your full card number or CVV), plus the name and email address we already hold on your account, grouped into a Square customer profile.
- **Lawful basis:** UK GDPR Article 6(1)(b) — necessary for the performance of the contract (you asked to save your card for future payments).
- **Why:** so you can pay for future bookings, tips, or gift-card purchases without re-entering your card details.
- **One-off payments:** if you do not tick "save this card", **no card is stored and no Square customer profile is created** for you — your card is used only for that single payment.
- **Retention & removal:** the reference remains stored until you delete the card from your account (Account → Saved Cards) or your account is deleted. You can remove a saved card at any time.
- **Square's privacy policy:** [Square Privacy Policy](https://squareup.com/gb/en/legal/privacy-no-account) applies to data Square holds on our behalf.
We never store full card numbers, card security codes (CVV), or card expiry data on our own systems at any point.
### 2.3 Special Category Data (Health Data)
We collect health-related information with your **explicit consent**:
- Allergy records
@@ -64,6 +78,7 @@ We collect health-related information with your **explicit consent**:
| **Inactive accounts (no balance)** | 2 years idle | GDPR storage limitation |
| **Inactive accounts (with balance)** | 5 years idle | Scottish prescriptive period |
| **Financial records** | 7 years | HMRC requirement |
| **Saved-card references (Square)** | Until user deletes card or account is deleted (Square-side) | Contract performance (Art 6(1)(b)); card-network card-on-file rules |
| **Allergy/health records** | 7 years | Insurance requirement |
| **Marketing preferences** | Until withdrawn | Consent |
@@ -78,6 +93,8 @@ We collect health-related information with your **explicit consent**:
5. Financial records retained 7 years (HMRC) then aggregated.
6. Allergy records retained 7 years (insurance) then deleted.
**Saved cards:** Deleting your account also removes your saved-card references from our system and disables the corresponding card tokens at Square (see §2.2). Card transaction records for payments already made are retained per the HMRC schedule above.
**Inactive account deletion (automatic):**
1. Warning emails sent at 18/23 months (no balance) or 4/59 months (with balance).
@@ -104,6 +104,7 @@ All warning emails include your Account ID for future balance recovery.
- Card payments processed securely via Square.
- We do not store full card details.
- Refunds processed to original payment method within 5-10 business days.
- **Saving a card for next time** stores a tokenised reference with our payment provider, Square (see the checkbox in the payment flow and our [[Privacy Policy]] §2.2). You can remove saved cards any time from your account. Cards are only stored when you explicitly tick "save this card".
### 3.3 Split Payments
- You may split payment across multiple methods (e.g., gift card + cash).
**Estimated effort:** S-M (1-2 days backend/frontend + privacy policy copy)
**Backlog reference:**`Future Work - Gap Backlog.md` item P14 (added alongside this plan)
---
## Executive Summary
The final payment review (Aug 2026) flagged a P0: Square's current API docs mark **`customer_id` as Required** on (a) `POST /v2/cards` (the `card` object) and (b) `CreatePayment`**when `source_id` is a Cards-API card-on-file (`ccof:`)**. The app deliberately sends **no `customer_id`** (no Square customer provisioning — linkage uses `reference_id`; see commit 80bad01 and P11 plan Remaining Item R2 / M-8).
This plan covers **what to do if `customer_id` is enforced**: provision Square customers, persist the id, and — the question this plan answers — **what consent/agreement/checkbox layer is required**. Answer: **no new standalone "Square customer" consent checkbox**; instead three transparency items (privacy policy disclosure, explicit card-save checkbox copy, data-minimisation by not minting customers for one-off payers).
---
## Background
- The app creates a Square card-on-file for **every new-card payment** (even `save_card=false`), via `CreateCardOnFile` before the charge (`handlers.go``CreateBookingPayment`/`CreateTipPayment`; `square_http_client.go``createCardOnFileHTTP` → `POST /v2/cards`). The card is intentionally left as a Square-side orphan when not saved (retry idempotency via deterministic sha256 key).
- If `POST /v2/cards` enforces `customer_id`, **every new-card payer — including guests — would get a Square customer profile** (name + email from the users/guest record) even when they never opted to save anything. That is the central data-protection problem this plan designs around.
- A Square Customer profile is not a consumer-facing account: it is a merchant-side grouping container holding name, email, and tokenized card references (no PANs — PCI-safe).
| Standalone "create a Square customer profile" checkbox | **NOT required** | The profile is created as a *consequence* of the user's card-save action. The existing card-save checkbox is the consent point; the profile is bookkeeping that rides the same consent. |
| New user agreement / Terms page | **NOT required** | The existing account Terms already govern the account; saved-card storage is disclosed via the privacy policy + checkbox copy below. |
| Consent checkbox for *processing* name/email at Square | **NOT required** | Lawful basis is Article 6(1)(b) — necessary for performance of the contract (the user chose card-on-file for future payments). Consent is not the basis, so no checkbox. |
### What IS required (transparency layer, not checkboxes)
- Currently §2.1 states only "Payment transaction records (processed via Square, not stored by us)" — add: when a user saves a card, Square stores the card-on-file reference **and** a customer profile containing the user's name + email (Square is a data processor).
- State the lawful basis for card storage (contract performance / Article 6(1)(b)) and link to Square's privacy policy.
- State retention: card persists until the user deletes it (DeletePaymentMethod) or Square's card-expiry policy applies.
- Change `Save this card for next time` → `Save this card securely with our payment provider (Square) for next time`.
- Ensures informed consent per card-network card-on-file rules and Square's own requirements. Not pre-checked (already the case).
3.**Data minimisation** (design change, see below): do **not** create a Square customer (or card-on-file) for one-off payers or guests who don't save. Charge the `cnon:` nonce directly.
> ⚠️ Legal note: this is a UK card-on-file assessment consistent with ICO guidance and Visa/Mastercard card-on-file rules, not formal legal advice. Sanity-check the privacy policy wording with a professional before go-live.
---
## Design Decisions
### D1 — Provision Square customers ONLY when the user saves a card
- Create the Square customer lazily, at the moment `save_card=true`, via Square's `POST /v2/customers` (name + email from the local user record).
- Persist the returned `customer_id` on `user_saved_cards` (new column) and reuse it for subsequent card saves by the same user.
- One-off payments (`save_card=false`): **do not create a card-on-file at all** — call `CreatePayment` with the `cnon:` nonce as `source_id` directly (Square supports nonce-as-source without a card or customer). This reverses the current create-card-first pattern for non-save flows.
### D2 — Guests never get a customer profile
- Guest bookings have no account and cannot save cards (`canSaveCards` requires `verified_email`, so guests are excluded already). Ensure the backend also refuses to mint a customer for guest users — guest new-card charges must go straight to Square as one-off nonce charges.
### D3 — Retry idempotency without a card-on-file
- The current create-card-first pattern exists so a pending-record retry can re-mint the same card via the deterministic key. For one-off nonce charges, verify the retry path still dedups at Square on the **idempotency key** alone (it should — Square dedups `CreatePayment` by key; a failed first attempt that never charged is replayed safely). If the retry needs a fresh nonce because the old one is consumed, the frontend must re-tokenize on retry (it already caches the nonce per attempt — confirm the cache is cleared when the nonce is marked used).
### D4 — Existing controls stay
- Delete-card flow (`DeletePaymentMethod` → Square `POST /v2/cards/{id}/disable`) remains the "view/manage/delete" control card networks require. No change.
---
## Policy Pop-over for the Consent Checkbox (mirroring `/cancellation-policy`)
The codebase already has a reusable policy pop-over pattern used in 8 places — **the plan reuses it for the card-save consent** so the checkbox links to the relevant policy inline, exactly like the cancellation-policy links today.
### Existing pattern (ground truth — verified)
- **`frontend/src/lib/components/ui/policyPopover.svelte`** — a generic pop-over component:
- Takes a `trigger` snippet (any label/button the caller renders).
- Renders a small pop-over with two actions: **Open** (new tab → `href`) and **Download PDF** (`href?format=pdf`).
- Closes on click-outside (`[data-policy-popover]`) and Escape; `target="_blank" rel="noopener noreferrer external"`.
- **Currently hardcodes** `href="/cancellation-policy"` and the fallback label "cancellation policy".
- **`frontend/src/routes/cancellation-policy/+page.svelte`** — the policy page itself:
- Plain HTML sections (no auth), `Last updated` date in a header line.
-`?format=pdf` → strips the param from the URL, hides a print notice, triggers `window.print()` → user saves as PDF.
- Print CSS via `@media print` (hides nav + `.no-print`).
Make the component take optional `label` (fallback text) and `href` props, defaulting to the current `/cancellation-policy` + "cancellation policy" so the 8 existing call sites keep working unchanged:
-`href` used for both the **Open** link and the **Download PDF** action (`${href}?format=pdf`).
- No behavioural change for existing callers (defaults preserve current output).
### Step 6b — Add the pop-over to the card-save consent
In **`CardSelection.svelte`** next to the consent checkbox (`:135-148`), wrap the label's "Save this card securely with our payment provider (Square)" text (or the label span) so a small **"Privacy Policy"** link opens the pop-over:
```svelte
<labelfor={consentId}class="...">
<input...bind:checked={saveCard}/>
<span>Save this card securely with our payment provider (Square)
- **Only rendered when the checkbox is shown** (`canSaveCards && squareCardReady`), so the link appears exactly at the point of consent — the same "at the moment of data collection" placement UK GDPR Art 13 expects.
- Same component in any other flow that renders a save-card checkbox (UserBookingModal tip modal if it uses `CardSelection`, etc.) — **single source of truth: reuse `CardSelection`** rather than duplicating labels.
### Step 6c — Create `/privacy-policy` route (the 'real' policy)
- **Gate**: the pop-over ships only after the Privacy Policy placeholder is made 'real' (cleaned up + owner-approved; see Docs step 8). Until then, keep the current behaviour (no privacy pop-over / no link) or point the link at the placeholder page marked DRAFT — **decision: ship the route with the DRAFT content but label it "DRAFT — for review"**, so the consent flow is fully walkable end-to-end in dev while the copy is still being finalised.
- New route **`frontend/src/routes/privacy-policy/+page.svelte`**, copying the `/cancellation-policy` structure:
- Content from `obsidian/Crussell/Privacy Policy.md` (incl. the new §2.2 Saved Cards & Square).
- Footer link back to `/contact` (match the cancellation page).
- **Terms route** (optional, same pattern): if the Terms placeholder is also made real, add `/terms` + a pop-over near account sign-up. **Out of scope for P14 unless the owner asks** — the consent checkbox only needs the Privacy Policy link.
### Step 6d — Account page "Policies" block
Add a second `PolicyPopover` (Privacy Policy) next to the existing cancellation-policy button in `account/+page.svelte:2356-2378` so users can reach the privacy policy from their account, matching the current presentation.
---
## Implementation Steps (when unblocked by P12)
### Backend
1.**Schema**: add `square_customer_id TEXT` to `user_saved_cards` (nullable) + `init-script.sql`; document the manual `ALTER TABLE` for existing deployments (README migration section).
- Add `CreateCustomer(ctx, name, email) (*CustomerResult, error)` and `CustomerID` on `CardOnFile`.
-`createCardOnFileHTTP`: when `req.CustomerID != ""`, send `card.customer_id` (only for save flows).
-`createPaymentHTTP`: when `source_id` is `ccof:` (saved card), include `customer_id` from the local saved-card row.
3.**Handlers** (`handlers.go``CreateBookingPayment`/`CreateTipPayment`, `service.go``CreatePaymentMethodFromToken`, till `online_square`):
- Restructure to **charge the nonce directly** when `save_card=false` (skip card mint).
- When `save_card=true`: ensure customer exists (lazy create + cache per user), pass `customer_id`, persist on the saved-card row.
4.**Tests**: update mock (`square_dev.go`) + unit tests to cover: nonce-direct charge, customer creation on save, customer reuse, guest one-off (no customer), and the existing `_NonExact`/retry regressions.
6.**Policy pop-over on the consent checkbox** (mirror the existing `/cancellation-policy` pattern — see below).
7.**Retry nonce handling**: verify `SquareCardInput`/flow caches the nonce per attempt and re-tokenizes after a consumed nonce (confirm current behaviour; fix if it re-sends a used nonce).
8.**Account page**: display a small "cards are stored securely with our payment provider, Square" caption near the saved-cards section (optional but recommended for transparency).
### Docs
8.**Privacy Policy** (`obsidian/Crussell/Privacy Policy.md`): **already drafted in the placeholder** — §2.2 "Saved Cards & Payment Provider (Square)" now contains the processor disclosure, lawful basis (Art 6(1)(b)), one-off-payment statement, retention, and Square privacy-policy link; Financial Data list + retention table + deletion process updated to match. The rest of the document remains a DRAFT placeholder and must be made 'real' (owner-approved copy, live date) before the `/privacy-policy` route ships without its DRAFT banner.
9.**Terms & Conditions** (`Terms & Conditions - Overall App.md`): §3.2 Payment Processing now cross-references the saved-card consent + Privacy Policy §2.2. (Placeholder — same 'make real' gate as above.)
10.**README / Technical Manual**: update the payments section to reflect customer provisioning + nonce-direct one-off charges.
---
## Policy Pop-over Status (already-in-plan vs done)
| `policyPopover.svelte` generalisation (`label`/`href` props) | ⏳ In plan (Step 6a) — not started |
| Pop-over on consent checkbox in `CardSelection.svelte` | ⏳ In plan (Step 6b) — not started |
| `/privacy-policy` route (HTML + PDF, mirrors `/cancellation-policy`) | ⏳ In plan (Step 6c) — not started; gated on policy being 'real' (or ships DRAFT-bannered) |
| Account page Policies block second pop-over | ⏳ In plan (Step 6d) — not started |
---
## Verification
- P12 sandbox smoke test **must run first**: confirm `POST /v2/cards` without `customer_id` actually 400s (it may not — integrations report cards can be created without it; the review marked this "unverified-high-risk"). Only proceed with customer provisioning if enforced.
- Sandbox E2E: one-off nonce charge (no card, no customer created) → save-card charge (customer created once, reused) → guest charge (no customer) → delete card (profile card disabled).
- Backend suite green; frontend build + ESLint clean.
- Frontend: pop-over renders on the consent checkbox when `canSaveCards && squareCardReady`; **Open** and **Download PDF** both work against `/privacy-policy` (incl. `?format=pdf` print path); existing 8 `/cancellation-policy` call sites unchanged (defaults preserved).
- Privacy policy diff reviewed by owner.
---
## Open Questions
1. Does Square enforce `customer_id` on `POST /v2/cards` at runtime? (P12 / R2 / M-8 — the gate.)
2. Can `CreatePayment` with a `cnon:` nonce as `source_id` be retried safely on a consumed nonce, or must the frontend re-tokenize? (Determines D3.)
3. Should the Square customer profile include phone number (Square allows it)? Decide during provisioning — email-only is the default recommendation (data minimisation).
---
## What this plan explicitly does NOT do
- No standalone "I agree to a Square customer account" checkbox.
- **No `/terms` route** (only `/privacy-policy`; a Terms page can reuse the same pattern later if the owner asks).
- No marketing use of Square customer data (out of scope; would need separate PECR/GDPR consent if ever added).
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.