//go:build test && dev package payments import ( "strings" "testing" "time" "crussell/clock" "crussell/testutils" "crussell/testutils/fixtures" ) // ============================================================================= // ROUND 9 — minor money bugs in the cancellation-refund loop // ============================================================================= // TestAggRefundKeySuffix_Widened_DistinctChargeIDs_NoCollision pins FIX 1: the // charge-level aggregate idempotency key suffix is the FULL SHA-256 hex // trimmed to the "-square-agg" budget (34 chars / 136 bits), never the old // 12-hex-char (48-bit) truncation. Two distinct over-length charge IDs must // produce distinct keys — a collision on Square's global idempotency-key dedup // would silently swallow the second charge's refund (lost money, no refund // row). The prefix structure (`-square-agg`) and determinism are kept // intact so existing pending rows still resolve. func TestAggRefundKeySuffix_Widened_DistinctChargeIDs_NoCollision(t *testing.T) { t.Parallel() // Two long, DISTINCT charge IDs that exceed the verbatim budget (a // verbatim key needs chargeID <= 45-len("-square-agg") = 34 chars). longChargeA := "sqp_charge_alpha_payment_id_which_is_quite_long_001" longChargeB := "sqp_charge_beta_payment_id_which_is_quite_long_002" if len(longChargeA) <= maxIdempotencyKeyLength-len("-square-agg") { t.Fatal("precondition: charge A must exceed the verbatim key budget") } if longChargeA == longChargeB { t.Fatal("precondition: the two charge IDs must be distinct") } keyA := chargeAggKey(longChargeA) keyB := chargeAggKey(longChargeB) if keyA == keyB { t.Errorf("FIX 1: distinct charge IDs %q and %q produced the SAME key %q — Square's idempotency dedup would swallow one refund", longChargeA, longChargeB, keyA) } if len(keyA) > maxIdempotencyKeyLength || len(keyB) > maxIdempotencyKeyLength { t.Errorf("keys must stay within Square's %d-char idempotency-key limit, got %q (%d) and %q (%d)", maxIdempotencyKeyLength, keyA, len(keyA), keyB, len(keyB)) } // The prefix structure is intact. if !strings.HasSuffix(keyA, "-square-agg") || !strings.HasSuffix(keyB, "-square-agg") { t.Errorf("expected keys to keep the '-square-agg' structure, got %q and %q", keyA, keyB) } // The suffix is widened to the full budget (34 hex chars = 136 bits). if suffix := aggRefundKeySuffix([]string{longChargeA}); len(suffix) <= 12 { t.Errorf("FIX 1: suffix is only %d hex chars — expected the widened full-budget form", len(suffix)) } // Same charge -> same key (deterministic retries still dedup at Square). if chargeAggKey(longChargeA) != keyA { t.Error("the same charge must produce the same key") } } // TestProcessCancellationRefund_RedeemedGiftCard_CreditsUserBalance pins // FIX 2: when a booking's gift-card payment is refunded after the card was // REDEEMED (redeemed_by set — the terminal path rejects redeemed cards and // RedeemGiftCard refuses a second redemption), the credit must land on the // booking user's gift-card account balance, NOT on the card itself. Crediting // amount_remaining onto a redeemed card would strand the refunded money // permanently. func TestProcessCancellationRefund_RedeemedGiftCard_CreditsUserBalance(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } // A card redeemed to the user's account balance: amount_remaining zeroed, // redeemed_by set — the exact state RedeemGiftCard leaves behind after the // user redeemed the residual balance. var giftCardID string if err := tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, expiry_date, last_used_at) VALUES (50, 0, $1, NOW(), $1, false, NULL, NOW()) RETURNING id `, userID).Scan(&giftCardID); err != nil { t.Fatalf("failed to create redeemed gift card: %v", err) } // The booking's £30 was paid FROM this card while it was still unredeemed. var paymentID string if err := tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at) VALUES ($1, 'full', 'giftcard', 'completed', 30, $2, NOW(), NOW()) RETURNING id `, bookingID, giftCardID).Scan(&paymentID); err != nil { t.Fatalf("failed to create giftcard payment: %v", err) } farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund(ctx, bookingID, 100, 30, farFuture, clock.Now(), "client_cancelled", &userID) if err != nil { t.Fatalf("ProcessCancellationRefund failed: %v", err) } if result == nil || result.RefundableAmount != 30 { t.Fatalf("expected refundable 30, got %+v", result) } // The redeemed card must NOT be credited. var amountRemaining float64 if err := tx.QueryRow(ctx, "SELECT amount_remaining FROM gift_cards WHERE id = $1", giftCardID).Scan(&amountRemaining); err != nil { t.Fatalf("failed to query gift card balance: %v", err) } if amountRemaining != 0 { t.Errorf("FIX 2: redeemed card must NOT be credited — expected amount_remaining 0, got %.2f", amountRemaining) } // The credit lands on the booking user's gift-card account balance. var balance float64 if err := tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance); err != nil { t.Fatalf("failed to query balance: %v", err) } if balance != 30 { t.Errorf("FIX 2: expected user gift-card balance credited 30, got %.2f", balance) } // No refund-to-card transaction may be recorded (the money did not move // onto the card). var txCount int if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'refund'", giftCardID).Scan(&txCount); err != nil { t.Fatalf("failed to query gift card transactions: %v", err) } if txCount != 0 { t.Errorf("expected NO refund-to-card transaction for a redeemed card, got %d", txCount) } // The refund record is 'completed' — the money DID move (to the balance). var status string if err := tx.QueryRow(ctx, "SELECT status FROM refunds WHERE payment_id = $1", paymentID).Scan(&status); err != nil { t.Fatalf("failed to query refund status: %v", err) } if status != "completed" { t.Errorf("expected refund record status 'completed' (money credited to balance), got %q", status) } } // TestProcessCancellationRefund_RedeemedGiftCard_Guest_FailedRow pins FIX 2's // guest/ownerless handling: with a redeemed card and a genuine guest booking // there is no account balance to credit, so the refund record must be 'failed' // (never 'completed' — money never moved) for admin reconciliation, mirroring // the cash branch's guest handling. func TestProcessCancellationRefund_RedeemedGiftCard_Guest_FailedRow(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } var giftCardID string if err := tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, expiry_date, last_used_at) VALUES (50, 0, $1, NOW(), $1, false, NULL, NOW()) RETURNING id `, userID).Scan(&giftCardID); err != nil { t.Fatalf("failed to create redeemed gift card: %v", err) } _, err = tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at) VALUES ($1, 'full', 'giftcard', 'completed', 30, $2, NOW(), NOW()) `, bookingID, giftCardID) if err != nil { t.Fatalf("failed to create giftcard payment: %v", err) } farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund(ctx, bookingID, 100, 30, farFuture, clock.Now(), "client_cancelled", &userID) if err != nil { t.Fatalf("ProcessCancellationRefund failed: %v", err) } if result == nil || result.RefundableAmount != 30 { t.Fatalf("expected refundable 30, got %+v", result) } var status string if err := tx.QueryRow(ctx, "SELECT status FROM refunds WHERE booking_id = $1", bookingID).Scan(&status); err != nil { t.Fatalf("failed to query refund status: %v", err) } if status != "failed" { t.Errorf("FIX 2: guest redeemed-card refund must be 'failed' (no balance credit possible), got %q", status) } // The guest must NOT receive a balance credit. var balance float64 if err := tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance); err != nil { balance = 0 } if balance != 0 { t.Errorf("expected guest balance 0 (guests do not receive balance credits), got %.2f", balance) } } // TestProcessCancellationRefund_GuestCash_FailedRow_AdminNotified pins FIX 3: // a genuine-guest cash refund records a 'failed' row (money never moved — the // guest has no balance to credit) and surfaces a 'refund_failed' admin // notification via the post-commit pre-pass. Because the row is 'failed', not // 'completed', the over-refund guard stays open: a subsequent refund attempt // for the same amount is allowed and re-runs dedup cleanly (no second row, no // double notification). func TestProcessCancellationRefund_GuestCash_FailedRow_AdminNotified(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } _, err = tx.Exec(ctx, "UPDATE users SET account_role = 'guest' WHERE id = $1", userID) if err != nil { t.Fatalf("failed to set guest role: %v", err) } serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create booking: %v", err) } _, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) if err != nil { t.Fatalf("failed to confirm booking: %v", err) } paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 30, "cash", "full", "completed") if err != nil { t.Fatalf("failed to create cash payment: %v", err) } farFuture := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) result, err := ProcessCancellationRefund(ctx, bookingID, 100, 30, farFuture, clock.Now(), "client_cancelled", &userID) if err != nil { t.Fatalf("ProcessCancellationRefund failed: %v", err) } if result == nil || result.RefundableAmount != 30 { t.Fatalf("expected refundable 30, got %+v", result) } // FIX 3: the row must be 'failed' — recording 'completed' would let the // over-refund guard permanently block re-issuance if the admin never // hands out the cash. var status string if err := tx.QueryRow(ctx, "SELECT status FROM refunds WHERE payment_id = $1", paymentID).Scan(&status); err != nil { t.Fatalf("failed to query refund status: %v", err) } if status != "failed" { t.Errorf("FIX 3: guest cash refund must be 'failed' (no money moved), got %q", status) } // The post-commit pre-pass surfaces a 'refund_failed' admin notification // so the pending payout stays visible. var notifCount int if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'", bookingID).Scan(¬ifCount); err != nil { t.Fatalf("failed to query admin_notifications: %v", err) } if notifCount < 1 { t.Errorf("FIX 3: expected at least 1 'refund_failed' admin notification for the guest cash refund, got %d", notifCount) } // The over-refund guard stays open: failed rows are excluded from // GetAlreadyRefundedAmount, so the full £30 is still refundable. svc := NewPaymentService() alreadyRefunded, err := svc.GetAlreadyRefundedAmount(ctx, paymentID) if err != nil { t.Fatalf("failed to query already-refunded amount: %v", err) } if alreadyRefunded != 0 { t.Errorf("FIX 3: expected 0 already-refunded (failed row must not block re-issuance), got %d pence", alreadyRefunded) } // A subsequent refund attempt for the same amount is allowed: it recomputes // the full residual, dedups onto the same failed row (no money moves // twice), and does not double-notify. if _, err := ProcessCancellationRefund(ctx, bookingID, 100, 30, farFuture, clock.Now(), "client_cancelled", &userID); err != nil { t.Fatalf("subsequent ProcessCancellationRefund failed: %v", err) } var rowCount int if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&rowCount); err != nil { t.Fatalf("failed to count refund rows: %v", err) } if rowCount != 1 { t.Errorf("expected exactly 1 refund row after the re-run (idempotency dedup), got %d", rowCount) } if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'", bookingID).Scan(¬ifCount); err != nil { t.Fatalf("failed to re-query admin_notifications: %v", err) } if notifCount != 1 { t.Errorf("expected the (reason, booking_id) dedup to keep exactly 1 notification after the re-run, got %d", notifCount) } }