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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user