fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking

Money-safety:
- Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation
- Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged
- CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard)
- Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse
- Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID

GDPR / security:
- Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010
- square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2
- Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel
- Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit

Frontend:
- Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen)
- Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh
- Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy

S3:
- Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific)

Tests/docs:
- 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 9111258461
commit 78e6d00dc5
89 changed files with 7702 additions and 853 deletions
+98 -19
View File
@@ -217,7 +217,10 @@ CREATE TABLE users (
-- GDPR fields
privacy_policy_and_terms_consent BOOLEAN NOT NULL DEFAULT TRUE,
policy_consent_updated_at TIMESTAMPTZ DEFAULT NOW(),
data_retention_consent BOOLEAN NOT NULL DEFAULT TRUE,
-- UK GDPR: consent must be freely given, specific and unambiguous — it is
-- OPT-IN, never a pre-ticked default. New users start with FALSE until they
-- explicitly agree in the GDPR settings UI.
data_retention_consent BOOLEAN NOT NULL DEFAULT FALSE,
data_consent_updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -367,6 +370,10 @@ CREATE TABLE bookings (
user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
start_time TIMESTAMPTZ NOT NULL,
status booking_status NOT NULL DEFAULT 'pending',
-- Free-text notes: treated as ONE medical/safety record. Allergy/access
-- entries are special-category health data (Art 9(1), Art 4(15)).
-- RETAINED at erasure (de-identified) — see RETENTION POLICY in
-- anonymize_user.
notes TEXT,
deposit_required BOOLEAN NOT NULL DEFAULT FALSE,
-- Computed fields (maintained by trigger on booking_services/booking_custom_services)
@@ -940,8 +947,26 @@ GDPR COMPLIANCE NOTES:
-- WHEN: User requests account deletion OR idle-account batch cleanup
-- OUTPUT: Converts personal data to anonymous placeholder
-- NOTE: phone/date_of_birth are NOT NULL so we use placeholder values, not NULL
-- NOTE: Also scrubs 2FA state (two_factor_*) and staff notes (PII) — a row
-- presented as erased keeps no PII at any call site.
-- NOTE: Also scrubs 2FA state (two_factor_*). Free-text notes are RETAINED
-- at erasure — see RETENTION POLICY below.
-- RETENTION POLICY (free-text notes — UK GDPR Art 4(1), Art 9, Recital 26):
-- * Notes are a SINGLE free-text input treated as ONE medical/safety
-- record — colour/preference/lateness content sits alongside
-- allergy/access/disability content and cannot be split.
-- * Allergy/access/disability entries are SPECIAL CATEGORY health data
-- (Art 9(1), Art 4(15)).
-- * At erasure the rest of the user record is fully wiped/anonymised
-- (name, email, phone, DOB, saved cards, 2FA, social logins) and no
-- re-identification map is maintained after anonymisation, so the
-- retained notes are de-identified / effectively anonymised (Recital 26).
-- * Notes are retained while they may be needed for:
-- (a) safe treatment / reasonable adjustments (Equality Act 2010
-- s.20-21, s.29) if the customer returns;
-- (b) legal-claims defence (Art 17(3)(e) / Art 9(2)(f)) e.g. around
-- allergy mistreatment.
-- * Retained notes remain access-controlled and are never exported.
-- A row presented as erased keeps no re-identifiable personal data at any
-- call site; retained notes are de-identified by the wiped surrounding record.
CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12))
RETURNS VOID AS $$
BEGIN
@@ -964,8 +989,8 @@ BEGIN
two_factor_enabled = FALSE, -- live 2FA credential must not survive erasure
two_factor_method = NULL,
two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL,
notes = NULL -- staff notes are PII; scrub at erasure
two_factor_pending_code_expires = NULL
-- notes are retained as a de-identified medical/safety record (see RETENTION POLICY below)
WHERE id = target_id
AND account_role != 'guest';
@@ -986,11 +1011,9 @@ BEGIN
square_customer_id = NULL
WHERE user_id = target_id;
-- Expire all pending verification codes
UPDATE verification_codes
SET used_at = NOW()
WHERE user_id = target_id
AND used_at IS NULL;
-- Delete verification codes: each row holds a plaintext 12-hex auth token
-- (a live credential) — GDPR Art 17 erasure must not leave it behind.
DELETE FROM verification_codes WHERE user_id = target_id;
-- Scrub RESERVATION entries in time_blockers that reference this user
UPDATE time_blockers
@@ -999,15 +1022,10 @@ BEGIN
AND (description LIKE 'RESERVATION:user:%'
OR description LIKE 'RESERVATION:edit_request:%');
-- Scrub notes on booking_edit_requests made by this user (free-text PII)
UPDATE booking_edit_requests
SET notes = NULL
WHERE requested_by = target_id;
-- Scrub notes on this user's bookings (free-text PII written by the user)
UPDATE bookings
SET notes = NULL
WHERE user_id = target_id;
-- Notes on this user's bookings and booking_edit_requests are RETAINED
-- as a de-identified medical/safety record — see RETENTION POLICY above.
-- Edit-request rows whose ONLY content is the note survive because the
-- note IS one of the chk_at_least_one_field fields — no DELETE is needed.
-- Scrub previous-name history (names are PII; leaving them would let
-- "formerly [name]" receipts reveal the erased identity)
@@ -1034,6 +1052,33 @@ BEGIN
-- Revoke stored refresh tokens (auth credentials of an erased identity)
DELETE FROM refresh_tokens WHERE user_id = target_id;
-- Scrub Square CreatePayment request snapshots (payments / till_sales):
-- the stored replay JSON embeds the user's email as BuyerEmail (PII, GDPR
-- Art 17 / Art 5(1)(e)). The financial rows themselves MUST survive the
-- 7-year retention period, so only the snapshot is NULLed — the sweep
-- rebuilds a minimal replay body when the snapshot is missing, so
-- pending-row reconciliation stays money-safe. Scope: payments the user
-- initiated (created_by) OR that were charged against the user's bookings
-- (admin at-the-counter charges also carry the booking user's email in the
-- snapshot); till_sales where the user is the customer (user_id).
UPDATE payments
SET square_request_snapshot = NULL
WHERE created_by = target_id
OR booking_id IN (SELECT id FROM bookings WHERE user_id = target_id);
UPDATE till_sales
SET square_request_snapshot = NULL
WHERE user_id = target_id;
-- NULL the cardholder-typed reason on disputes linked to this user's
-- payments (free-text third-party PII — GDPR Art 17). The dispute row and
-- its financial data survive; only the free-text reason is scrubbed.
UPDATE disputes
SET reason = NULL
WHERE payment_id IN (SELECT id FROM payments
WHERE created_by = target_id
OR booking_id IN (SELECT id FROM bookings WHERE user_id = target_id));
END;
$$ LANGUAGE plpgsql;
@@ -1065,6 +1110,40 @@ BEGIN
-- Revoke stored refresh tokens (auth credentials of an erased identity)
DELETE FROM refresh_tokens WHERE user_id = target_id;
-- Scrub Square CreatePayment request snapshots (payments / till_sales):
-- the stored replay JSON embeds the guest's email as BuyerEmail (PII).
-- The financial rows survive the 7-year retention period — only the
-- snapshot is NULLed (the sweep rebuilds a minimal body when it is
-- missing, keeping reconciliation money-safe). Scope mirrors
-- anonymize_user(): payments the guest initiated (created_by) or charged
-- against the guest's bookings, and till_sales where the guest is the
-- customer (user_id).
UPDATE payments
SET square_request_snapshot = NULL
WHERE created_by = target_id
OR booking_id IN (SELECT id FROM bookings WHERE user_id = target_id);
UPDATE till_sales
SET square_request_snapshot = NULL
WHERE user_id = target_id;
-- Notes (bookings, booking_edit_requests, till_sales,
-- gift_card_transactions) are RETAINED as a de-identified medical/safety
-- record — see RETENTION POLICY above. The guest user row is fully
-- deleted below, so the retained notes cannot be traced back to the
-- individual (Recital 26). Edit-request rows whose ONLY content is the
-- note survive (chk_at_least_one_field — the note IS one of the fields).
-- Unlink no-action FK references to users(id) that would otherwise make
-- the DELETE below fail (each column has REFERENCES users(id) with no
-- ON DELETE clause). The financial/till rows are preserved for the 7-year
-- retention period; only the user link is cleared.
UPDATE gift_card_transactions SET user_id = NULL WHERE user_id = target_id;
UPDATE gift_cards SET redeemed_by = NULL WHERE redeemed_by = target_id;
UPDATE gift_cards SET created_by = NULL WHERE created_by = target_id;
UPDATE admin_notifications SET user_id = NULL WHERE user_id = target_id;
UPDATE admin_audit_log SET target_user_id = NULL WHERE target_user_id = target_id;
DELETE FROM users WHERE id = target_id AND account_role = 'guest';
END;
$$ LANGUAGE plpgsql;