Files
Crussell/backend/handlers/payments/refunds_round9_fixes_test.go
T
popertotsandSisyphus 1d6d3e2f8d test: round-9/10 adversarial suites — sync-path vs webhook completion, gift-card double-refund, sweep VAT, till lock, account lockout + erasure
- handlers_round9/round10: status-guarded flips, split-key hashing, cross-booking key 409, existingCount refund exclusion, SCA save-card exemption, routeNonCompletedPayment, no phantom split rows
- giftcards_round10: saved-card SCA buy, cancel resume reconcile (pending blocks, diff-only re-issue, no over-refund)
- sweep/till_round10: split-accurate VAT, all-tip VAT-free, status/key-changed skip, final-key lock held across charge
- webhooks_round8/9: booking gate + M2, payable side-effects, unknown-event 503, refund-before-row 503, no double-complete after sync
- account_round9: password lockout budgets, S3 erasure outbox, DAV in-tx deletion

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

347 lines
14 KiB
Go

//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 (`<suffix>-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(&notifCount); 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(&notifCount); 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)
}
}