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:
@@ -2,6 +2,7 @@ package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"crussell/handlers/payments"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/handlers/user"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -247,6 +249,22 @@ func RegisterAll(s *Scheduler) {
|
||||
Concurrency: 1,
|
||||
Handler: ScanCriticalPaymentLogs,
|
||||
})
|
||||
|
||||
// Durable safety net for the GDPR account-deletion Square outbox (Fault
|
||||
// A1): retries the Square card/customer deletions that the
|
||||
// DeleteAccountHandler async cleanup could not finish (process crash or
|
||||
// exhausted retries), using the outbox rows the handler persisted inside
|
||||
// its anonymization tx before it committed. Hourly — the async goroutine
|
||||
// handles the common case within seconds, so this only catches stragglers.
|
||||
// The schedule is deliberately unshared so a long run cannot contend with
|
||||
// the payment sweeps.
|
||||
s.Register(Job{
|
||||
Name: "retry-square-erasures",
|
||||
Schedule: "17 * * * *", // Hourly at :17
|
||||
Timeout: 30 * time.Second,
|
||||
Concurrency: 1,
|
||||
Handler: RetryPendingSquareErasures,
|
||||
})
|
||||
}
|
||||
|
||||
// SweepSquareWebhookEvents deletes square_webhook_events rows older than 90
|
||||
@@ -347,3 +365,185 @@ func ScanCriticalPaymentLogs(ctx context.Context) (int, error) {
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// erasureNotificationKey derives the notification-dedup key for a pending
|
||||
// outbox row: the user id when the row still carries one (registered users
|
||||
// whose deletion tx has not yet run), otherwise a stable row-scoped key (guest
|
||||
// rows are unlinked by delete_guest_user and the account-deletion outbox
|
||||
// NULLs user_id on the rows it writes).
|
||||
func erasureNotificationKey(userID sql.NullString, rowID string) string {
|
||||
if userID.Valid && userID.String != "" {
|
||||
return userID.String
|
||||
}
|
||||
return "row:" + rowID
|
||||
}
|
||||
|
||||
// raiseErasureNotification raises the deduped critical notification once per
|
||||
// key. Repeated failures across job runs collapse to a single alert (the
|
||||
// deterministic admin_notifications id in
|
||||
// user.InsertSquareErasureCriticalNotification does the ON CONFLICT dedup).
|
||||
func raiseErasureNotification(ctx context.Context, key string, notified map[string]bool) {
|
||||
if notified[key] {
|
||||
return
|
||||
}
|
||||
notified[key] = true
|
||||
user.InsertSquareErasureCriticalNotification(ctx, key)
|
||||
}
|
||||
|
||||
// RetryPendingSquareErasures is the durable safety net for the account-deletion
|
||||
// Square outbox (Fault A1). DeleteAccountHandler persists the Square
|
||||
// card/customer erasure targets on the scrubbed, soft-deleted user_saved_cards
|
||||
// rows (last_4 = 'XXXX') inside its anonymization transaction, before it
|
||||
// commits. If the process crashes between that commit and the async cleanup
|
||||
// goroutine finishing, those rows are the only remaining record of the
|
||||
// Square-side PII — this job finds them and retries the Square deletion so the
|
||||
// card/customer is never permanently orphaned at Square. On success (or a
|
||||
// Square NOT_FOUND — the data is already gone) it drains the outbox columns; on
|
||||
// final failure it raises a critical payment notification, deduped per affected
|
||||
// user/row. Returns the number of outbox rows drained.
|
||||
func RetryPendingSquareErasures(ctx context.Context) (int, error) {
|
||||
if payments.SquareClient == nil {
|
||||
// Square not configured: no external erasure is possible, and the
|
||||
// handler only writes outbox entries when a client was configured.
|
||||
return 0, nil
|
||||
}
|
||||
client := payments.SquareClient
|
||||
|
||||
rows, err := db.Conn.Query(ctx, `
|
||||
SELECT id, user_id, square_card_id, square_customer_id
|
||||
FROM user_saved_cards
|
||||
WHERE deleted_at IS NOT NULL
|
||||
AND last_4 = 'XXXX'
|
||||
AND (square_card_id IS NOT NULL OR square_customer_id IS NOT NULL)
|
||||
ORDER BY id
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to query pending Square erasures: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pendingErasure struct {
|
||||
rowID string
|
||||
userID sql.NullString
|
||||
cardID sql.NullString
|
||||
customerID sql.NullString
|
||||
}
|
||||
var pending []pendingErasure
|
||||
for rows.Next() {
|
||||
var p pendingErasure
|
||||
if err := rows.Scan(&p.rowID, &p.userID, &p.cardID, &p.customerID); err != nil {
|
||||
return 0, fmt.Errorf("failed to scan pending Square erasure: %w", err)
|
||||
}
|
||||
pending = append(pending, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, fmt.Errorf("failed to iterate pending Square erasures: %w", err)
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Group the outbox rows: each card id maps to exactly one row (per-user
|
||||
// UNIQUE), each customer id may map to several rows (shared across the
|
||||
// user's saved cards) and may also appear on other deleted accounts' rows.
|
||||
cardRows := map[string][]pendingErasure{}
|
||||
customerRows := map[string][]pendingErasure{}
|
||||
for _, p := range pending {
|
||||
if p.cardID.Valid && p.cardID.String != "" {
|
||||
cardRows[p.cardID.String] = append(cardRows[p.cardID.String], p)
|
||||
}
|
||||
if p.customerID.Valid && p.customerID.String != "" {
|
||||
customerRows[p.customerID.String] = append(customerRows[p.customerID.String], p)
|
||||
}
|
||||
}
|
||||
|
||||
notified := map[string]bool{}
|
||||
drained := map[string]bool{}
|
||||
|
||||
// Cards: each ccof: token is erased once. NOT_FOUND means Square no longer
|
||||
// has the card — the erasure is complete, so the outbox is drained rather
|
||||
// than alerted on.
|
||||
for cardID, cardPend := range cardRows {
|
||||
rowID := cardPend[0].rowID
|
||||
err := user.RetrySquareDeletion(ctx, func(actx context.Context) error {
|
||||
return client.DeleteCardOnFile(actx, cardID)
|
||||
})
|
||||
if err != nil && !square.IsNotFound(err) {
|
||||
raiseErasureNotification(ctx, erasureNotificationKey(cardPend[0].userID, rowID), notified)
|
||||
log.Printf("Error: retry-square-erasures failed to delete Square card %s (outbox row %s): %v", square.TokenPrefix(cardID), rowID, err)
|
||||
slog.Error("square card erasure retry failed after attempts", "row", rowID, "card", square.TokenPrefix(cardID), "error", err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Conn.Exec(ctx, `
|
||||
UPDATE user_saved_cards SET square_card_id = NULL
|
||||
WHERE id = $1 AND deleted_at IS NOT NULL
|
||||
`, rowID); err != nil {
|
||||
return 0, fmt.Errorf("failed to clear card erasure outbox row %s: %w", rowID, err)
|
||||
}
|
||||
drained[rowID] = true
|
||||
}
|
||||
|
||||
// Customers: one DeleteCustomer per distinct id, guarded by the
|
||||
// still-referenced-by-another-account check (a shared Square customer must
|
||||
// survive while any active card of another account references it).
|
||||
for customerID, custPend := range customerRows {
|
||||
stillReferenced := false
|
||||
for _, p := range custPend {
|
||||
var ref bool
|
||||
if err := db.Conn.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM user_saved_cards
|
||||
WHERE square_customer_id = $1 AND deleted_at IS NULL
|
||||
AND user_id IS DISTINCT FROM $2
|
||||
)
|
||||
`, customerID, p.userID).Scan(&ref); err != nil {
|
||||
return 0, fmt.Errorf("failed to check Square customer %s references before deletion: %w", square.TokenPrefix(customerID), err)
|
||||
}
|
||||
if ref {
|
||||
stillReferenced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if stillReferenced {
|
||||
// Deliberately kept (shared customer) — not a pending erasure.
|
||||
// Drain the outbox rows so the job stops retrying a deletion that
|
||||
// must not happen; the customer is erased when the last referencing
|
||||
// account is itself erased.
|
||||
for _, p := range custPend {
|
||||
if _, err := db.Conn.Exec(ctx, `
|
||||
UPDATE user_saved_cards SET square_customer_id = NULL
|
||||
WHERE id = $1 AND deleted_at IS NOT NULL
|
||||
`, p.rowID); err != nil {
|
||||
return 0, fmt.Errorf("failed to clear skipped customer erasure outbox row %s: %w", p.rowID, err)
|
||||
}
|
||||
drained[p.rowID] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
err := user.RetrySquareDeletion(ctx, func(actx context.Context) error {
|
||||
return client.DeleteCustomer(actx, customerID)
|
||||
})
|
||||
if err != nil && !square.IsNotFound(err) {
|
||||
for _, p := range custPend {
|
||||
raiseErasureNotification(ctx, erasureNotificationKey(p.userID, p.rowID), notified)
|
||||
}
|
||||
log.Printf("Error: retry-square-erasures failed to delete Square customer %s (%d outbox rows): %v", square.TokenPrefix(customerID), len(custPend), err)
|
||||
slog.Error("square customer erasure retry failed after attempts", "customer", square.TokenPrefix(customerID), "rows", len(custPend), "error", err)
|
||||
continue
|
||||
}
|
||||
for _, p := range custPend {
|
||||
if _, err := db.Conn.Exec(ctx, `
|
||||
UPDATE user_saved_cards SET square_customer_id = NULL
|
||||
WHERE id = $1 AND deleted_at IS NOT NULL
|
||||
`, p.rowID); err != nil {
|
||||
return 0, fmt.Errorf("failed to clear customer erasure outbox row %s: %w", p.rowID, err)
|
||||
}
|
||||
drained[p.rowID] = true
|
||||
}
|
||||
}
|
||||
|
||||
if n := len(drained); n > 0 {
|
||||
log.Printf("[ERASURE] retry-square-erasures drained %d pending Square erasure outbox row(s)", n)
|
||||
}
|
||||
return len(drained), nil
|
||||
}
|
||||
|
||||
@@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
|
||||
s := New()
|
||||
RegisterAll(s)
|
||||
|
||||
if got := len(s.registry); got != 25 {
|
||||
t.Fatalf("RegisterAll() registered %d jobs, want 25", got)
|
||||
if got := len(s.registry); got != 26 {
|
||||
t.Fatalf("RegisterAll() registered %d jobs, want 26", got)
|
||||
}
|
||||
|
||||
registered := make(map[string]Job, len(s.registry))
|
||||
@@ -493,6 +493,7 @@ func expectedJobNames() map[string]bool {
|
||||
"sweep-square-webhook-events": true,
|
||||
"apply-default-hours": true,
|
||||
"scan-critical-payment-logs": true,
|
||||
"retry-square-erasures": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user