Scrub Square card and customer references on anonymization (GDPR)
anonymize_user, delete_guest_user, and AnonymizeStaleGuestAccounts now NULL square_card_id and square_customer_id on user_saved_cards (7-year retained_until soft-delete kept for financial records). Make square_card_id nullable in the schema. GDPR export refunds join fixed to include gift-card-purchase refunds. Add scrub assertions to the GDPR and stale-guest test suites.
This commit is contained in:
@@ -505,6 +505,31 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
totalRows += int(tag.RowsAffected())
|
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)
|
return totalRows, tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ---
|
// --- Tests for CleanupExpiredFinancialRecords ---
|
||||||
|
|
||||||
// TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a
|
// TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
rows, err := db.Conn.Query(ctx,
|
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 {
|
if err != nil {
|
||||||
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
|
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -260,13 +260,13 @@ func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) {
|
|||||||
t.Fatalf("anonymize_user failed: %v", err)
|
t.Fatalf("anonymize_user failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var deletedAt, fingerprint interface{}
|
var deletedAt, fingerprint, squareCardID interface{}
|
||||||
var last4 string
|
var last4 string
|
||||||
var expMonth, expYear int
|
var expMonth, expYear int
|
||||||
err = tx.QueryRow(ctx, `
|
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
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to query saved card after anonymization: %v", err)
|
t.Fatalf("failed to query saved card after anonymization: %v", err)
|
||||||
}
|
}
|
||||||
@@ -280,6 +280,9 @@ func TestAnonymizeUser_SoftDeletesSavedCards(t *testing.T) {
|
|||||||
if fingerprint != nil {
|
if fingerprint != nil {
|
||||||
t.Errorf("expected fingerprint to be NULL, got %v", fingerprint)
|
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 {
|
if expMonth != 1 {
|
||||||
t.Errorf("expected exp_month to be 1, got %d", expMonth)
|
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) {
|
func TestAnonymizeUser_ExpiresVerificationCodes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|||||||
@@ -936,14 +936,18 @@ BEGIN
|
|||||||
-- Scrub social login identities (immutable_id is PII from OAuth providers)
|
-- Scrub social login identities (immutable_id is PII from OAuth providers)
|
||||||
DELETE FROM user_social_logins WHERE user_id = target_id;
|
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
|
UPDATE user_saved_cards
|
||||||
SET deleted_at = NOW(),
|
SET deleted_at = NOW(),
|
||||||
retained_until = NOW() + INTERVAL '7 years',
|
retained_until = NOW() + INTERVAL '7 years',
|
||||||
last_4 = 'XXXX',
|
last_4 = 'XXXX',
|
||||||
fingerprint = NULL,
|
fingerprint = NULL,
|
||||||
exp_month = 1,
|
exp_month = 1,
|
||||||
exp_year = 2000
|
exp_year = 2000,
|
||||||
|
square_card_id = NULL,
|
||||||
|
square_customer_id = NULL
|
||||||
WHERE user_id = target_id;
|
WHERE user_id = target_id;
|
||||||
|
|
||||||
-- Expire all pending verification codes
|
-- Expire all pending verification codes
|
||||||
@@ -998,6 +1002,9 @@ BEGIN
|
|||||||
fingerprint = NULL,
|
fingerprint = NULL,
|
||||||
exp_month = 1,
|
exp_month = 1,
|
||||||
exp_year = 2000,
|
exp_year = 2000,
|
||||||
|
-- Scrub Square references (external-system identifiers) before unlinking
|
||||||
|
square_card_id = NULL,
|
||||||
|
square_customer_id = NULL,
|
||||||
user_id = NULL
|
user_id = NULL
|
||||||
WHERE user_id = target_id;
|
WHERE user_id = target_id;
|
||||||
|
|
||||||
@@ -1116,8 +1123,10 @@ BEGIN
|
|||||||
)
|
)
|
||||||
ORDER BY p.created_at DESC), '[]'::json)
|
ORDER BY p.created_at DESC), '[]'::json)
|
||||||
FROM payments p
|
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
|
WHERE b.user_id = target_user_id
|
||||||
|
OR gc.created_by = target_user_id
|
||||||
),
|
),
|
||||||
'patch_tests', (
|
'patch_tests', (
|
||||||
SELECT COALESCE(json_agg(
|
SELECT COALESCE(json_agg(
|
||||||
@@ -1208,8 +1217,10 @@ BEGIN
|
|||||||
) ORDER BY r.created_at DESC), '[]'::json)
|
) ORDER BY r.created_at DESC), '[]'::json)
|
||||||
FROM refunds r
|
FROM refunds r
|
||||||
JOIN payments p ON r.payment_id = p.id
|
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
|
WHERE b.user_id = target_user_id
|
||||||
|
OR gc.created_by = target_user_id
|
||||||
),
|
),
|
||||||
'social_logins', (
|
'social_logins', (
|
||||||
SELECT COALESCE(json_agg(json_build_object(
|
SELECT COALESCE(json_agg(json_build_object(
|
||||||
@@ -1980,7 +1991,10 @@ $$ LANGUAGE plpgsql;
|
|||||||
CREATE TABLE user_saved_cards (
|
CREATE TABLE user_saved_cards (
|
||||||
id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(),
|
id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(),
|
||||||
user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
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
|
-- Square customer profile id (P14): populated lazily the first time the
|
||||||
-- user SAVES a card, then reused for every subsequent card save. NULL for
|
-- user SAVES a card, then reused for every subsequent card save. NULL for
|
||||||
-- rows created before provisioning was introduced. One-off (non-save)
|
-- rows created before provisioning was introduced. One-off (non-save)
|
||||||
|
|||||||
Reference in New Issue
Block a user