diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index b60e5d3..50c97d4 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -505,6 +505,31 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) { } totalRows += int(tag.RowsAffected()) + // Scrub Square saved-card references for stale guests and soft-delete any + // active cards (7-year financial retention; Square ids are external-system + // identifiers and must be removed for GDPR storage limitation). + // COALESCE keeps the original timestamps for cards soft-deleted by an + // earlier run, so a re-run never extends the retention window. + tag, err = tx.Exec(ctx, ` + UPDATE user_saved_cards + SET square_card_id = NULL, + square_customer_id = NULL, + last_4 = 'XXXX', + fingerprint = NULL, + deleted_at = COALESCE(deleted_at, NOW()), + retained_until = COALESCE(retained_until, NOW() + INTERVAL '7 years') + WHERE user_id IN ( + SELECT id FROM users + WHERE account_role = 'guest' + AND n_first_name = 'Guest' + AND n_last_name = 'Anonymized' + ) + `) + if err != nil { + return 0, err + } + totalRows += int(tag.RowsAffected()) + return totalRows, tx.Commit(ctx) } diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 54c3bfc..9708f2e 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -1259,6 +1259,69 @@ func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) { } } +// TestAnonymizeStaleGuestAccounts_ScrubsSavedCards verifies the saved-card +// scrub for stale guests: square_card_id / square_customer_id are NULLed +// (external-system references removed for GDPR storage limitation), the card +// is soft-deleted, last_4 is masked, and the fingerprint is NULLed. +func TestAnonymizeStaleGuestAccounts_ScrubsSavedCards(t *testing.T) { + t.Parallel() + ctx, tx := resetTestData(t) + + guestID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create guest user: %v", err) + } + if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID); err != nil { + t.Fatalf("failed to set guest role: %v", err) + } + + // Stale booking: completed > 6 months ago. + if _, err := tx.Exec(ctx, ` + INSERT INTO bookings (user_id, start_time, status, deposit_required) + VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false) + `, guestID); err != nil { + t.Fatalf("failed to create stale booking: %v", err) + } + + var cardID string + if err := tx.QueryRow(ctx, ` + INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default) + VALUES ($1, 'sq_card_stale', 'sq_customer_stale', 'Visa', '4242', 12, 2030, 'fp_stale123', true) + RETURNING id + `, guestID).Scan(&cardID); err != nil { + t.Fatalf("failed to insert saved card: %v", err) + } + + if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil { + t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err) + } + + var deletedAt, fingerprint, squareCardID, squareCustomerID interface{} + var last4 string + if err := tx.QueryRow(ctx, ` + SELECT deleted_at, last_4, fingerprint, square_card_id, square_customer_id + FROM user_saved_cards WHERE id = $1 + `, cardID).Scan(&deletedAt, &last4, &fingerprint, &squareCardID, &squareCustomerID); err != nil { + t.Fatalf("failed to query saved card after anonymization: %v", err) + } + + if deletedAt == nil { + t.Error("expected deleted_at to be set after anonymization") + } + if last4 != "XXXX" { + t.Errorf("expected last_4 to be 'XXXX', got %q", last4) + } + if fingerprint != nil { + t.Errorf("expected fingerprint to be NULL, got %v", fingerprint) + } + if squareCardID != nil { + t.Errorf("expected square_card_id to be NULL (external-system reference), got %v", squareCardID) + } + if squareCustomerID != nil { + t.Errorf("expected square_customer_id to be NULL, got %v", squareCustomerID) + } +} + // --- Tests for CleanupExpiredFinancialRecords --- // TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a diff --git a/backend/handlers/user/account.go b/backend/handlers/user/account.go index ab1340c..d91f941 100644 --- a/backend/handlers/user/account.go +++ b/backend/handlers/user/account.go @@ -80,7 +80,7 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() rows, err := db.Conn.Query(ctx, - `SELECT square_card_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID) + `SELECT square_card_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL AND square_card_id IS NOT NULL`, userID) if err != nil { log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err) return diff --git a/backend/handlers/user/gdpr_test.go b/backend/handlers/user/gdpr_test.go index b1a7948..627422d 100644 --- a/backend/handlers/user/gdpr_test.go +++ b/backend/handlers/user/gdpr_test.go @@ -260,13 +260,13 @@ func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) { t.Fatalf("anonymize_user failed: %v", err) } - var deletedAt, fingerprint interface{} + var deletedAt, fingerprint, squareCardID interface{} var last4 string var expMonth, expYear int err = tx.QueryRow(ctx, ` - SELECT deleted_at, last_4, fingerprint, exp_month, exp_year + SELECT deleted_at, last_4, fingerprint, exp_month, exp_year, square_card_id FROM user_saved_cards WHERE user_id = $1 - `, userID).Scan(&deletedAt, &last4, &fingerprint, &expMonth, &expYear) + `, userID).Scan(&deletedAt, &last4, &fingerprint, &expMonth, &expYear, &squareCardID) if err != nil { t.Fatalf("failed to query saved card after anonymization: %v", err) } @@ -280,6 +280,9 @@ func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) { if fingerprint != nil { t.Errorf("expected fingerprint to be NULL, got %v", fingerprint) } + if squareCardID != nil { + t.Errorf("expected square_card_id to be NULL after anonymization (external-system reference), got %v", squareCardID) + } if expMonth != 1 { t.Errorf("expected exp_month to be 1, got %d", expMonth) } @@ -288,6 +291,72 @@ func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) { } } +func TestDeleteGuestUser_ScrubsSavedCards(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestGuestUser(tx) + if err != nil { + t.Fatalf("failed to create test guest user: %v", err) + } + + var cardID string + err = tx.QueryRow(ctx, ` + INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default) + VALUES ($1, 'sq_card_guest', 'sq_customer_guest', 'Visa', '4242', 12, 2030, 'fp_guest123', true) + RETURNING id + `, userID).Scan(&cardID) + if err != nil { + t.Fatalf("failed to insert saved card: %v", err) + } + + _, err = tx.Exec(ctx, `SELECT delete_guest_user($1)`, userID) + if err != nil { + t.Fatalf("delete_guest_user failed: %v", err) + } + + // The guest user row is fully deleted. + var userCount int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE id = $1`, userID).Scan(&userCount); err != nil { + t.Fatalf("failed to count users: %v", err) + } + if userCount != 0 { + t.Errorf("expected guest user to be deleted, found %d rows", userCount) + } + + // The saved card is soft-deleted and scrubbed. delete_guest_user UNLINKS + // the card (user_id = NULL), so query by the card id captured at insert. + var deletedAt, fingerprint, squareCardID, squareCustomerID interface{} + var last4 string + var userIDCol interface{} + err = tx.QueryRow(ctx, ` + SELECT user_id, deleted_at, last_4, fingerprint, square_card_id, square_customer_id + FROM user_saved_cards WHERE id = $1 + `, cardID).Scan(&userIDCol, &deletedAt, &last4, &fingerprint, &squareCardID, &squareCustomerID) + if err != nil { + t.Fatalf("failed to query saved card after deletion: %v", err) + } + + if userIDCol != nil { + t.Errorf("expected user_id to be NULL (card unlinked), got %v", userIDCol) + } + if deletedAt == nil { + t.Error("expected deleted_at to be set after delete_guest_user") + } + if last4 != "XXXX" { + t.Errorf("expected last_4 to be 'XXXX', got %q", last4) + } + if fingerprint != nil { + t.Errorf("expected fingerprint to be NULL, got %v", fingerprint) + } + if squareCardID != nil { + t.Errorf("expected square_card_id to be NULL after delete_guest_user (external-system reference), got %v", squareCardID) + } + if squareCustomerID != nil { + t.Errorf("expected square_customer_id to be NULL after delete_guest_user, got %v", squareCustomerID) + } +} + func TestAnonymizeUser_ExpiresVerificationCodes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 7609399..f3276b9 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -936,14 +936,18 @@ BEGIN -- Scrub social login identities (immutable_id is PII from OAuth providers) DELETE FROM user_social_logins WHERE user_id = target_id; - -- Soft-delete all saved cards and clear PCI data + -- Soft-delete all saved cards, clear PCI data, and scrub Square references + -- (square_card_id / square_customer_id are external-system identifiers and + -- MUST be NULLed for GDPR right-to-erasure — a scrubbed card has no Square id). UPDATE user_saved_cards SET deleted_at = NOW(), retained_until = NOW() + INTERVAL '7 years', last_4 = 'XXXX', fingerprint = NULL, exp_month = 1, - exp_year = 2000 + exp_year = 2000, + square_card_id = NULL, + square_customer_id = NULL WHERE user_id = target_id; -- Expire all pending verification codes @@ -998,6 +1002,9 @@ BEGIN fingerprint = NULL, exp_month = 1, exp_year = 2000, + -- Scrub Square references (external-system identifiers) before unlinking + square_card_id = NULL, + square_customer_id = NULL, user_id = NULL WHERE user_id = target_id; @@ -1116,8 +1123,10 @@ BEGIN ) ORDER BY p.created_at DESC), '[]'::json) FROM payments p - JOIN bookings b ON p.booking_id = b.id + LEFT JOIN bookings b ON p.booking_id = b.id + LEFT JOIN gift_cards gc ON p.gift_card_id = gc.id WHERE b.user_id = target_user_id + OR gc.created_by = target_user_id ), 'patch_tests', ( SELECT COALESCE(json_agg( @@ -1208,8 +1217,10 @@ BEGIN ) ORDER BY r.created_at DESC), '[]'::json) FROM refunds r JOIN payments p ON r.payment_id = p.id - JOIN bookings b ON p.booking_id = b.id + LEFT JOIN bookings b ON p.booking_id = b.id + LEFT JOIN gift_cards gc ON p.gift_card_id = gc.id WHERE b.user_id = target_user_id + OR gc.created_by = target_user_id ), 'social_logins', ( SELECT COALESCE(json_agg(json_build_object( @@ -1980,7 +1991,10 @@ $$ LANGUAGE plpgsql; CREATE TABLE user_saved_cards ( id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(), user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, - square_card_id TEXT NOT NULL, + -- NULL once a card is scrubbed by anonymization (anonymize_user, + -- delete_guest_user, AnonymizeStaleGuestAccounts). A scrubbed card has no + -- Square id — the reference must be removable for GDPR right-to-erasure. + square_card_id TEXT, -- Square customer profile id (P14): populated lazily the first time the -- user SAVES a card, then reused for every subsequent card save. NULL for -- rows created before provisioning was introduced. One-off (non-save)