Complete Square GDPR erasure: customer deletion and stale-guest scrub
Account deletion now snapshots card and customer IDs before the local anonymize transaction and dispatches the Square cleanup goroutine only after the tx commits, deleting each distinct Square customer once and skipping any customer still referenced by another user's card. AnonymizeStaleGuestAccounts also disables cards and deletes the guest's Square customer profile (PII) before NULLing references locally. Token redaction applied to all error logs.
This commit is contained in:
@@ -2,6 +2,7 @@ package scheduling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/square"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
|
||||
@@ -413,6 +416,80 @@ func CleanupOldReservations(ctx context.Context) (int, error) {
|
||||
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
|
||||
// Active/pending bookings are excluded so the salon can still contact the guest.
|
||||
func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
|
||||
// Best-effort: disable stale-guests' saved cards at Square BEFORE the SQL
|
||||
// below NULLs square_card_id, so those cards can't keep accepting ccof:
|
||||
// charges after anonymization (GDPR erasure completeness). A Square failure
|
||||
// is logged and ignored — the local anonymization must never be blocked by
|
||||
// Square. Card IDs are selected with the same stale-guest predicate the
|
||||
// users UPDATE uses, and only when a Square client is configured.
|
||||
if payments.SquareClient != nil {
|
||||
// Snapshot the stale-guests' saved cards AND their Square customer IDs
|
||||
// BEFORE the SQL below NULLs square_card_id/square_customer_id, so the
|
||||
// external Square references are still available for cleanup (GDPR
|
||||
// erasure completeness: the local scrub must never strand PII at
|
||||
// Square). Best-effort: a Square failure is logged and ignored — the
|
||||
// local anonymization must never be blocked by Square. Rows are
|
||||
// selected with the same stale-guest predicate the users UPDATE uses.
|
||||
rows, err := db.Conn.Query(ctx, `
|
||||
SELECT usc.square_card_id, usc.square_customer_id
|
||||
FROM user_saved_cards usc
|
||||
JOIN users u ON u.id = usc.user_id
|
||||
WHERE u.account_role = 'guest'
|
||||
AND NOT EXISTS (SELECT 1 FROM bookings WHERE user_id = u.id AND status IN ('pending', 'confirmed'))
|
||||
AND EXISTS (SELECT 1 FROM bookings WHERE user_id = u.id GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months')
|
||||
AND usc.square_card_id IS NOT NULL
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to query stale-guest saved cards for Square cleanup: %v", err)
|
||||
} else {
|
||||
var cardIDs []string
|
||||
// Distinct non-null customer IDs only: a guest's saved cards share
|
||||
// one provisioned Square customer, so DeleteCustomer runs once per
|
||||
// customer. NULL customer IDs (guests with no provisioned Square
|
||||
// customer) are skipped.
|
||||
customerSeen := map[string]bool{}
|
||||
var customerIDs []string
|
||||
for rows.Next() {
|
||||
var cardID, customerID sql.NullString
|
||||
if err := rows.Scan(&cardID, &customerID); err != nil {
|
||||
log.Printf("Warning: Failed to scan stale-guest saved card: %v", err)
|
||||
continue
|
||||
}
|
||||
if cardID.Valid && cardID.String != "" {
|
||||
cardIDs = append(cardIDs, cardID.String)
|
||||
}
|
||||
if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] {
|
||||
customerSeen[customerID.String] = true
|
||||
customerIDs = append(customerIDs, customerID.String)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Warning: Row iteration error querying stale-guest saved cards: %v", err)
|
||||
}
|
||||
for _, cardID := range cardIDs {
|
||||
if err := payments.SquareClient.DeleteCardOnFile(ctx, cardID); err != nil {
|
||||
// TokenPrefix redacts the ccof: card token — the full ID
|
||||
// must never reach logs.
|
||||
log.Printf("Warning: Failed to disable stale-guest Square card %s at Square: %v", square.TokenPrefix(cardID), err)
|
||||
}
|
||||
}
|
||||
// GDPR erasure completeness: the guest's Square customer profile
|
||||
// holds their real name + email PII. Disabling the saved cards and
|
||||
// NULLing square_customer_id locally is NOT enough — the Square
|
||||
// customer profile must be deleted too, or the PII persists at
|
||||
// Square indefinitely after anonymization. Distinct IDs only, so a
|
||||
// guest with multiple cards on one customer triggers one delete.
|
||||
for _, customerID := range customerIDs {
|
||||
if err := payments.SquareClient.DeleteCustomer(ctx, customerID); err != nil {
|
||||
// TokenPrefix redacts the customer ID — the full ID must
|
||||
// never reach logs.
|
||||
log.Printf("Warning: Failed to delete stale-guest Square customer %s at Square: %v", square.TokenPrefix(customerID), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
|
||||
@@ -22,10 +22,13 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
@@ -1322,6 +1325,169 @@ func TestAnonymizeStaleGuestAccounts_ScrubsSavedCards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// recordingDisableClient records every DeleteCardOnFile and DeleteCustomer call
|
||||
// so tests can assert which cards/customers the anonymization cleaned up at
|
||||
// Square.
|
||||
type recordingDisableClient struct {
|
||||
square.SquareClient
|
||||
mu sync.Mutex
|
||||
disabled []string
|
||||
deletedCustomers []string
|
||||
}
|
||||
|
||||
func (c *recordingDisableClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
||||
c.mu.Lock()
|
||||
c.disabled = append(c.disabled, cardID)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *recordingDisableClient) DeleteCustomer(ctx context.Context, customerID string) error {
|
||||
c.mu.Lock()
|
||||
c.deletedCustomers = append(c.deletedCustomers, customerID)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *recordingDisableClient) disabledIDs() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.disabled...)
|
||||
}
|
||||
|
||||
func (c *recordingDisableClient) deletedCustomerIDs() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.deletedCustomers...)
|
||||
}
|
||||
|
||||
// TestAnonymizeStaleGuestAccounts_DisablesCardsAtSquare verifies the
|
||||
// best-effort Square disable: stale-guests' saved cards are disabled at Square
|
||||
// BEFORE square_card_id is NULLed locally (GDPR erasure completeness).
|
||||
// Deliberately NOT t.Parallel: it swaps the package-level payments.SquareClient
|
||||
// and must not overlap the parallel Anonymize tests that read it.
|
||||
func TestAnonymizeStaleGuestAccounts_DisablesCardsAtSquare(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if _, err := tx.Exec(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, 'ccof:stale_card_123', 'cus_stale_123', 'Visa', '4242', 12, 2030, 'fp1', true)
|
||||
`, guestID); err != nil {
|
||||
t.Fatalf("failed to insert saved card: %v", err)
|
||||
}
|
||||
|
||||
origSquare := payments.SquareClient
|
||||
rec := &recordingDisableClient{}
|
||||
payments.SquareClient = rec
|
||||
defer func() { payments.SquareClient = origSquare }()
|
||||
|
||||
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
got := rec.disabledIDs()
|
||||
if len(got) != 1 || got[0] != "ccof:stale_card_123" {
|
||||
t.Fatalf("expected the stale guest's card to be disabled at Square, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeStaleGuestAccounts_DeletesCustomersAtSquare verifies the
|
||||
// best-effort Square customer deletion: stale-guests' Square customer profiles
|
||||
// (real name + email PII) are deleted at Square once per DISTINCT customer ID
|
||||
// BEFORE square_customer_id is NULLed locally (GDPR erasure completeness).
|
||||
// Deliberately NOT t.Parallel: it swaps the package-level payments.SquareClient
|
||||
// and must not overlap the parallel Anonymize tests that read it.
|
||||
func TestAnonymizeStaleGuestAccounts_DeletesCustomersAtSquare(t *testing.T) {
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
// Guest A: two saved cards sharing one provisioned Square customer.
|
||||
guestAID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create guest A: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestAID); err != nil {
|
||||
t.Fatalf("failed to set guest A role: %v", err)
|
||||
}
|
||||
|
||||
// Guest B: one saved card on a different Square customer.
|
||||
guestBID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create guest B: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestBID); err != nil {
|
||||
t.Fatalf("failed to set guest B role: %v", err)
|
||||
}
|
||||
|
||||
// Stale bookings (> 6 months) for both guests.
|
||||
for _, uid := range []string{guestAID, guestBID} {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
||||
`, uid); err != nil {
|
||||
t.Fatalf("failed to create stale booking: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Guest A's two cards share 'cus_stale_a'; guest B's card is 'cus_stale_b'.
|
||||
if _, err := tx.Exec(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, 'ccof:stale_card_a1', 'cus_stale_a', 'Visa', '4242', 12, 2030, 'fp_a1', true),
|
||||
($1, 'ccof:stale_card_a2', 'cus_stale_a', 'Mastercard', '1111', 12, 2030, 'fp_a2', false)
|
||||
`, guestAID); err != nil {
|
||||
t.Fatalf("failed to insert guest A saved cards: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(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, 'ccof:stale_card_b1', 'cus_stale_b', 'Visa', '9999', 12, 2030, 'fp_b1', true)
|
||||
`, guestBID); err != nil {
|
||||
t.Fatalf("failed to insert guest B saved card: %v", err)
|
||||
}
|
||||
|
||||
origSquare := payments.SquareClient
|
||||
rec := &recordingDisableClient{}
|
||||
payments.SquareClient = rec
|
||||
defer func() { payments.SquareClient = origSquare }()
|
||||
|
||||
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
||||
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
got := rec.deletedCustomerIDs()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected DeleteCustomer to run once per DISTINCT customer id (2), got %v", got)
|
||||
}
|
||||
gotSet := map[string]bool{}
|
||||
for _, id := range got {
|
||||
if gotSet[id] {
|
||||
t.Errorf("DeleteCustomer called more than once for customer %q: %v", id, got)
|
||||
}
|
||||
gotSet[id] = true
|
||||
}
|
||||
if !gotSet["cus_stale_a"] || !gotSet["cus_stale_b"] {
|
||||
t.Errorf("expected both distinct Square customer ids to be deleted, got %v", got)
|
||||
}
|
||||
|
||||
// The customer delete runs after the card-disable loop, so all cards were
|
||||
// disabled too.
|
||||
disabled := rec.disabledIDs()
|
||||
if len(disabled) != 3 {
|
||||
t.Errorf("expected all 3 stale cards to be disabled, got %v", disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for CleanupExpiredFinancialRecords ---
|
||||
|
||||
// TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a
|
||||
|
||||
Reference in New Issue
Block a user