- 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)
347 lines
14 KiB
Go
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(¬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)
|
|
}
|
|
}
|