diff --git a/backend/handlers/payments/sweep.go b/backend/handlers/payments/sweep.go index 4d65c27..cfecd90 100644 --- a/backend/handlers/payments/sweep.go +++ b/backend/handlers/payments/sweep.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log" + "math" "strings" "time" @@ -31,43 +32,90 @@ import ( // 'failed' with no automatic resolution — the money would otherwise be lost // in limbo (MINOR-R3). Reconciliation is deliberately minimal: status + // updated_at only, no split/VAT recomputation (that is the handler's job; the -// row is >24h stale and this is a reconciliation rescue). +// row is >24h stale and this is a reconciliation rescue). A row with NO +// square_payment_id but a STORED idempotency key (the lost-response case — the +// charge may have completed at Square with the response never received) is +// reconciled at Square by replaying the key (ReplayPaymentByKey) at an EARLIER +// 22h cutoff, while the key is still inside Square's ~24h retention window: a +// COMPLETED charge is rescued to 'completed' with the real square_payment_id +// written back, and a charge Square proves never happened is failed. Rows with +// neither a square_payment_id nor a stored key cannot be reconciled and are +// failed with a WARN exactly as the legacy sweep did. // // Only online/till card payments can be pending — cash/giftcard/on_the_house // are committed synchronously and never enter this state. Both the payments // table and till_sales carry pending card-sale rows and are swept here. const stalePendingPaymentAge = 24 * time.Hour +// stalePendingKeyedAge is how old a pending row with a stored idempotency key +// (but no square_payment_id — the lost-response case) must be before the sweep +// reconciles it at Square by replaying the key. It is deliberately 2h EARLIER +// than stalePendingPaymentAge so the replay lands comfortably inside Square's +// ~24h idempotency-key retention window: replaying at exactly 24h risks the key +// already having expired, and an expired key would make the probe rejection +// look like "never charged" even when the charge actually landed. Rows older +// than stalePendingPaymentAge when swept can no longer be replayed +// trustworthily and fall back to the legacy blind-fail + WARN. +const stalePendingKeyedAge = 22 * time.Hour + func SweepStalePendingPayments(ctx context.Context) (int, error) { cutoff := clock.Now().Add(-stalePendingPaymentAge) + keyedCutoff := clock.Now().Add(-stalePendingKeyedAge) - payCount, payCompleted, err := sweepStaleRows(ctx, "payments", cutoff) + // Pass 1 (earlier cutoff): rows with a stored idempotency key but NO + // square_payment_id are the lost-response case — the charge may have landed + // at Square with the response lost. They are reconciled at Square by + // replaying the key (ReplayPaymentByKey) while the key is still inside + // Square's ~24h retention window: a COMPLETED charge is rescued to + // 'completed' with the real square_payment_id, a charge Square proves never + // happened is failed (and a till sale's funded gift card clawed back), an + // ambiguous answer leaves the row pending for the next run, and a row + // already past the retention window is blind-failed with a WARN exactly as + // the legacy sweep did (replaying an expired key would misread the probe + // rejection as "never charged"). + payKeyedCount, payKeyedCompleted, payKeyedUnverified, err := sweepKeyedStaleRows(ctx, "payments", keyedCutoff) + if err != nil { + return 0, err + } + tillKeyedCount, tillKeyedCompleted, tillKeyedUnverified, err := sweepKeyedStaleRows(ctx, "till_sales", keyedCutoff) if err != nil { return 0, err } - // till_sales rows for card payments (stored as 'online_square' or - // 'in_person_card' in the payment_method enum — saved_card/online_square/ - // card_machine requests all persist as one of those) can also be pending. - // Sweep them too — a lost-response till sale would otherwise stay pending - // and a retry after key retention would reuse the stored key → Square sees - // an expired key → second charge (R3). Cash / on_the_house are committed - // synchronously and never pending. + // Pass 2 (legacy 24h cutoff): rows WITH a square_payment_id are reconciled + // by payment id; rows with neither a payment id nor a stored key cannot be + // reconciled and are failed directly (WARN — the charge outcome is unknown). + payCount, payCompleted, err := sweepStaleRows(ctx, "payments", cutoff) + if err != nil { + return 0, err + } tillCount, tillCompleted, err := sweepStaleRows(ctx, "till_sales", cutoff) if err != nil { return 0, err } - total := payCount + tillCount + total := payKeyedCount + tillKeyedCount + payCount + tillCount + payTotal := payKeyedCount + payCount + tillTotal := tillKeyedCount + tillCount + completed := payKeyedCompleted + tillKeyedCompleted + payCompleted + tillCompleted if total > 0 { - log.Printf("[SWEEP] Resolved %d stale pending payments (%d payments, %d till sales) older than %s — late retries will be rejected, preventing a second Square charge; %d reconciled to completed against Square (%d payments, %d till sales)", total, payCount, tillCount, stalePendingPaymentAge, payCompleted+tillCompleted, payCompleted, tillCompleted) + log.Printf("[SWEEP] Resolved %d stale pending payments (%d payments, %d till sales) older than %s — late retries will be rejected, preventing a second Square charge; %d reconciled to completed against Square (%d payments, %d till sales)", total, payTotal, tillTotal, stalePendingPaymentAge, completed, payKeyedCompleted+payCompleted, tillKeyedCompleted+tillCompleted) } // Routine sweep bookkeeping, not an incident: failing stale pending rows is // the sweep's DESIGNED behaviour. A row swept to failed may still have been // charged at Square with a lost response, so note it at WARN level for an // admin doing a periodic money reconciliation — but this fires on every // normal run and must not be elevated to CRITICAL (which is reserved for - // genuinely unrecoverable post-charge branches). + // genuinely unrecoverable post-charge branches). Rows the keyed reconcile + // PROVED never charged are deliberately excluded from the WARN (they cannot + // have moved money); only rows failed without a reconcile proof — keyless + // rows and keyed rows past the retention window — are counted. + if payKeyedUnverified > 0 { + log.Printf("[SWEEP] WARN: %d pending payments with a stored idempotency key but no square_payment_id were marked failed without a replay reconcile (key retention window already closed) — may have been charged at Square with a lost response — verify before refunding/charging", payKeyedUnverified) + } + if tillKeyedUnverified > 0 { + log.Printf("[SWEEP] WARN: %d pending till sales with a stored idempotency key but no square_payment_id were marked failed without a replay reconcile (key retention window already closed) — may have been charged at Square with a lost response", tillKeyedUnverified) + } if payCount > 0 { log.Printf("[SWEEP] WARN: %d pending payments marked failed may have been charged at Square with a lost response — verify before refunding/charging", payCount-payCompleted) } @@ -87,7 +135,19 @@ func SweepStalePendingPayments(ctx context.Context) (int, error) { type staleRow struct { ID string SquarePaymentID string - ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card) + // IdempotencyKey is the deterministic Square idempotency key stored on the + // pending row ("" when absent). Rows WITH a key but NO square_payment_id are + // the lost-response case: the sweep replays the key at Square to learn the + // true charge outcome before declaring failure. + IdempotencyKey string + // AmountPence is the row's charge amount in pence — the amount the original + // CreatePayment used. The replay-by-key must repeat it so Square's + // idempotency dedup returns the original payment. + AmountPence int64 + // CreatedAt is the pending row's creation time; rows already past Square's + // idempotency-key retention window cannot be replayed trustworthily. + CreatedAt time.Time + ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card) RedeemToUserID *string // gift_cards.redeemed_by — user credited by a create-with-redeem IsCreate bool // true when this sale created the gift card (timestamps equal) HasGiftCard bool // false when the LEFT JOIN found no gift_cards row (gc.id IS NULL) @@ -109,70 +169,15 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv default: return 0, 0, fmt.Errorf("sweep: unknown stale table %q", table) } - // The legacy till_sales sweep only touched card methods — cash and - // on_the_house are committed synchronously and never pending, but keep the - // predicate so behaviour is byte-identical for any unexpected row. - methodFilter := "" - if table == "till_sales" { - methodFilter = ` AND payment_method IN ('online_square', 'in_person_card')` - } - var rows pgx.Rows - if table == "till_sales" { - rows, err = db.Conn.Query(ctx, ` - SELECT ts.id, COALESCE(ts.square_payment_id, ''), ts.item_id, gc.redeemed_by, - (ts.created_at = gc.created_at) AS is_create, - (gc.id IS NOT NULL) AS has_gift_card, ts.total_amount - FROM till_sales ts - LEFT JOIN gift_cards gc ON gc.id = ts.item_id - WHERE ts.status = 'pending' AND ts.created_at < $1`+methodFilter+` - `, cutoff) - } else { - rows, err = db.Conn.Query(ctx, ` - SELECT id, COALESCE(square_payment_id, '') - FROM `+table+` - WHERE status = 'pending' AND created_at < $1`+methodFilter+` - `, cutoff) - } + stale, err := fetchStaleRows(ctx, table, cutoff, false) if err != nil { return 0, 0, err } - var stale []staleRow - for rows.Next() { - var r staleRow - if table == "till_sales" { - var itemID, redeemedBy sql.NullString - var isCreate *bool - var hasGiftCard bool - if err := rows.Scan(&r.ID, &r.SquarePaymentID, &itemID, &redeemedBy, &isCreate, &hasGiftCard, &r.TotalAmount); err != nil { - log.Printf("Failed to scan stale pending row from %s: %v", table, err) - continue - } - r.ItemID = itemID.String - if redeemedBy.Valid && redeemedBy.String != "" { - r.RedeemToUserID = &redeemedBy.String - } - r.IsCreate = isCreate != nil && *isCreate - r.HasGiftCard = hasGiftCard - } else { - if err := rows.Scan(&r.ID, &r.SquarePaymentID); err != nil { - log.Printf("Failed to scan stale pending row from %s: %v", table, err) - continue - } - } - stale = append(stale, r) - } - rows.Close() - for _, r := range stale { if r.SquarePaymentID != "" { switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) { case staleReconcileCompleted: - if tag, upErr := db.Conn.Exec(ctx, ` - UPDATE `+table+` SET status = 'completed', updated_at = NOW() - WHERE id = $1 AND status = 'pending' - `, r.ID); upErr != nil { - log.Printf("Failed to rescue stale pending row %s to completed: %v", r.ID, upErr) - } else if n := int(tag.RowsAffected()); n > 0 { + if rescueStaleRowCompleted(ctx, table, r.ID) { resolved++ completed++ } @@ -194,33 +199,286 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv // (no square_payment_id — the charge outcome is unknown) NEVER claws // back: the money may have landed at Square. if table == "till_sales" && r.SquarePaymentID != "" && r.HasGiftCard { - action := "topup" - if r.IsCreate { - action = "create" + if clawbackTillSaleFunding(ctx, r) { + resolved++ } - if revErr := revertGiftCardFunding(ctx, action, r.ItemID, r.TotalAmount, r.RedeemToUserID, r.ID); revErr != nil { - if errors.Is(revErr, errTillSaleNotPending) { - log.Printf("Stale pending till sale %s was already resolved (not pending) — skipping funding clawback", r.ID) - continue - } - log.Printf("CRITICAL: failed to claw back gift card %s funding for stale till sale %s: %v — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", r.ItemID, r.ID, revErr) - continue - } - resolved++ continue } - if tag, upErr := db.Conn.Exec(ctx, ` - UPDATE `+table+` SET status = 'failed', updated_at = NOW() - WHERE id = $1 AND status = 'pending' - `, r.ID); upErr != nil { - log.Printf("Failed to mark stale pending row %s failed: %v", r.ID, upErr) - } else if n := int(tag.RowsAffected()); n > 0 { + if failStaleRow(ctx, table, r.ID) { resolved++ } } return resolved, completed, nil } +// sweepKeyedStaleRows resolves stale pending rows that carry a stored +// idempotency key but no square_payment_id — the lost-response case, where the +// charge may have completed at Square with the response never reaching the +// app. Such rows are reconciled at Square by replaying the key while it is +// still inside Square's ~24h retention window (the earlier stalePendingKeyedAge +// cutoff guarantees this): +// +// - a COMPLETED payment under the key → rescued to 'completed' with the real +// square_payment_id written back (the customer WAS charged); +// - a payment Square proves never happened (unknown key / FAILED status) → +// marked 'failed', and a till sale's funded gift card is clawed back +// (reconcile PROVED the funding has no charge behind it); +// - an ambiguous answer (transport/5xx) → left pending for the next run; +// - a row already older than the retention window when swept → cannot be +// replayed trustworthily (an expired key would misread the probe rejection +// as "never charged" even when the charge landed), so it is blind-failed +// with a WARN exactly as the legacy sweep did. +// +// Returns the total rows resolved, how many were rescued to 'completed', and +// how many were marked failed WITHOUT a reconcile proof (blind-fail WARNs). +func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (resolved, completed, unverifiable int, err error) { + switch table { + case "payments", "till_sales": + default: + return 0, 0, 0, fmt.Errorf("sweep: unknown stale table %q", table) + } + stale, err := fetchStaleRows(ctx, table, cutoff, true) + if err != nil { + return 0, 0, 0, err + } + // Square's idempotency-key retention window closes at stalePendingPaymentAge; + // a row older than that when swept can no longer be replayed trustworthily. + replayExpired := clock.Now().Add(-stalePendingPaymentAge) + for _, r := range stale { + if r.CreatedAt.Before(replayExpired) { + // Key retention window already closed — replaying would misread an + // expired key as "never charged". Blind-fail + WARN exactly as the + // legacy sweep did; a till sale's funded gift card is NOT clawed + // back (the charge outcome is unknown, the money may have landed). + if failStaleRow(ctx, table, r.ID) { + resolved++ + unverifiable++ + } + log.Printf("Stale pending %s row %s has a stored idempotency key but is already past Square's key retention window — marked failed without a replay reconcile (may have been charged with a lost response)", table, r.ID) + continue + } + switch res, sqPayID := reconcileStalePaymentByKey(ctx, table, r.IdempotencyKey, r.AmountPence); res { + case staleReconcileCompleted: + if rescueKeyedStaleRowCompleted(ctx, table, r.ID, sqPayID) { + resolved++ + completed++ + } + case staleReconcileLeavePending: + // Ambiguous replay — the charge may still be in flight. Leave the + // row pending; the next sweep run reconciles it again. + log.Printf("Stale pending %s row %s left pending (replay-by-key reconcile ambiguous) — will retry next sweep", table, r.ID) + case staleReconcileDefinitivelyFailed: + // Square proved the charge never happened (key unknown / FAILED) — + // a till sale's funded gift card is clawed back atomically with the + // failed mark, unlike the blind-fail path where the outcome is unknown. + if table == "till_sales" && r.HasGiftCard { + if clawbackTillSaleFunding(ctx, r) { + resolved++ + } + continue + } + if failStaleRow(ctx, table, r.ID) { + resolved++ + } + } + } + return resolved, completed, unverifiable, nil +} + +// fetchStaleRows reads the stale pending rows of one table that are older than +// cutoff. keyedOnly restricts the query to rows that can be reconciled by +// replaying a stored idempotency key: those with a key but no square_payment_id +// (rows WITH a square_payment_id are reconciled by payment id in the main +// pass). The legacy till_sales sweep only touches card methods — cash and +// on_the_house are committed synchronously and never pending, but the +// predicate is kept so behaviour is byte-identical for any unexpected row. +func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOnly bool) ([]staleRow, error) { + keyedPredicate := "" + if keyedOnly { + keyedPredicate = ` AND idempotency_key IS NOT NULL AND square_payment_id IS NULL` + } + methodFilter := "" + if table == "till_sales" { + methodFilter = ` AND payment_method IN ('online_square', 'in_person_card')` + } + var rows pgx.Rows + var err error + if table == "till_sales" { + rows, err = db.Conn.Query(ctx, ` + SELECT ts.id, COALESCE(ts.square_payment_id, ''), COALESCE(ts.idempotency_key, ''), + ts.created_at, ts.item_id, gc.redeemed_by, + (ts.created_at = gc.created_at) AS is_create, + (gc.id IS NOT NULL) AS has_gift_card, ts.total_amount + FROM till_sales ts + LEFT JOIN gift_cards gc ON gc.id = ts.item_id + WHERE ts.status = 'pending' AND ts.created_at < $1`+methodFilter+keyedPredicate+` + `, cutoff) + } else { + rows, err = db.Conn.Query(ctx, ` + SELECT id, COALESCE(square_payment_id, ''), COALESCE(idempotency_key, ''), + created_at, amount + FROM `+table+` + WHERE status = 'pending' AND created_at < $1`+keyedPredicate+` + `, cutoff) + } + if err != nil { + return nil, err + } + defer rows.Close() + var stale []staleRow + for rows.Next() { + r, scanErr := scanStaleRow(table, rows) + if scanErr != nil { + log.Printf("Failed to scan stale pending row from %s: %v", table, scanErr) + continue + } + stale = append(stale, r) + } + return stale, rows.Err() +} + +// scanStaleRow scans one row produced by fetchStaleRows into a staleRow, +// deriving the replay amount (in pence) from the stored pound figure — the +// original CreatePayment amount the replay-by-key must repeat. +func scanStaleRow(table string, rows pgx.Rows) (staleRow, error) { + var r staleRow + if table == "till_sales" { + var itemID, redeemedBy sql.NullString + var isCreate *bool + var hasGiftCard bool + if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.CreatedAt, + &itemID, &redeemedBy, &isCreate, &hasGiftCard, &r.TotalAmount); err != nil { + return r, err + } + r.ItemID = itemID.String + if redeemedBy.Valid && redeemedBy.String != "" { + r.RedeemToUserID = &redeemedBy.String + } + r.IsCreate = isCreate != nil && *isCreate + r.HasGiftCard = hasGiftCard + r.AmountPence = int64(math.Round(r.TotalAmount * 100)) + return r, nil + } + var amount float64 + if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.CreatedAt, &amount); err != nil { + return r, err + } + r.AmountPence = int64(math.Round(amount * 100)) + return r, nil +} + +// failStaleRow marks one stale pending row 'failed'. Returns true when the row +// was updated (status was still 'pending'). +func failStaleRow(ctx context.Context, table, id string) bool { + tag, err := db.Conn.Exec(ctx, ` + UPDATE `+table+` SET status = 'failed', updated_at = NOW() + WHERE id = $1 AND status = 'pending' + `, id) + if err != nil { + log.Printf("Failed to mark stale pending row %s failed: %v", id, err) + return false + } + return int(tag.RowsAffected()) > 0 +} + +// rescueStaleRowCompleted marks one stale pending row (whose square_payment_id +// already resolves to a COMPLETED charge) 'completed'. Returns true when the +// row was updated. +func rescueStaleRowCompleted(ctx context.Context, table, id string) bool { + tag, err := db.Conn.Exec(ctx, ` + UPDATE `+table+` SET status = 'completed', updated_at = NOW() + WHERE id = $1 AND status = 'pending' + `, id) + if err != nil { + log.Printf("Failed to rescue stale pending row %s to completed: %v", id, err) + return false + } + return int(tag.RowsAffected()) > 0 +} + +// rescueKeyedStaleRowCompleted marks a keyed stale pending row 'completed' with +// the square_payment_id returned by the replay — the lost-response rescue. The +// reconcile is deliberately minimal (status + square_payment_id + updated_at +// only, no split/VAT recomputation): the row is >22h stale and this is a +// reconciliation rescue, mirroring the F3 by-id rescue's minimality. Returns +// true when the row was updated. +func rescueKeyedStaleRowCompleted(ctx context.Context, table, id, squarePaymentID string) bool { + tag, err := db.Conn.Exec(ctx, ` + UPDATE `+table+` SET status = 'completed', square_payment_id = $1, updated_at = NOW() + WHERE id = $2 AND status = 'pending' + `, squarePaymentID, id) + if err != nil { + log.Printf("Failed to rescue stale pending row %s to completed: %v", id, err) + return false + } + return int(tag.RowsAffected()) > 0 +} + +// clawbackTillSaleFunding claws back a stale pending till sale's funded gift +// card (atomically with the failed mark) after a reconcile PROVED the charge +// never completed — the funding has no charge behind it. A sale with no gift +// card is only marked failed. Returns true when the sale was resolved to +// failed; false when it was already resolved by someone else, had no gift card, +// or the clawback failed (CRITICAL logged). +func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool { + action := "topup" + if r.IsCreate { + action = "create" + } + if revErr := revertGiftCardFunding(ctx, action, r.ItemID, r.TotalAmount, r.RedeemToUserID, r.ID); revErr != nil { + if errors.Is(revErr, errTillSaleNotPending) { + log.Printf("Stale pending till sale %s was already resolved (not pending) — skipping funding clawback", r.ID) + return false + } + log.Printf("CRITICAL: failed to claw back gift card %s funding for stale till sale %s: %v — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", r.ItemID, r.ID, revErr) + return false + } + return true +} + +// reconcileStalePaymentByKey asks Square for the authoritative status of the +// charge made under a stale pending row's idempotency key and returns the +// tri-state result. The replay returns the ORIGINAL payment for a retained key +// (Square's documented idempotency behavior — never a second charge); a +// COMPLETED payment rescues the row to 'completed' with its real payment id. A +// definitive rejection (ErrReplayKeyNotRetained — Square has no payment under +// the key, so the charge never happened) or a FAILED/CANCELED payment proves +// the charge never completed and fails the row. Any OTHER error (transport / +// 5xx / ambiguous) leaves the row pending — the charge may still have +// completed at Square. The second return value is the Square payment id of the +// completed payment ("" otherwise), written back on a rescue. +func reconcileStalePaymentByKey(ctx context.Context, table, idempotencyKey string, amountPence int64) (staleReconcileResult, string) { + pr, err := SquareClient.ReplayPaymentByKey(ctx, idempotencyKey, amountPence) + if err != nil { + if errors.Is(err, square.ErrReplayKeyNotRetained) { + log.Printf("Stale pending %s reconcile by key: Square has no payment under the stored idempotency key (replay probe rejected) — marking failed; the charge provably never happened", table) + return staleReconcileDefinitivelyFailed, "" + } + log.Printf("Stale pending %s reconcile by idempotency key hit an ambiguous error (%v) — leaving pending for a later sweep run", table, err) + return staleReconcileLeavePending, "" + } + // Square's terminal payment states are COMPLETED, CANCELED, FAILED; + // APPROVED (authorization-only, delayed capture) and PENDING are + // NON-terminal — both can still transition to COMPLETED, so clawing back + // the funded gift card on either would risk reversing a charge that later + // lands. This app always creates payments with autocomplete (default true), + // so it never produces APPROVED/PENDING rows today, but the classification + // must match Square's documented state machine. + switch pr.Status { + case "COMPLETED": + return staleReconcileCompleted, pr.ID + case "CANCELED", "FAILED": + log.Printf("Stale pending %s is %q at Square (replay by key) — marking failed", table, pr.Status) + return staleReconcileDefinitivelyFailed, "" + case "APPROVED", "PENDING": + log.Printf("Stale pending %s is %q at Square (replay by key, non-terminal) — leaving pending for a later sweep run", table, pr.Status) + return staleReconcileLeavePending, "" + default: + log.Printf("Stale pending %s is %q at Square (replay by key) — marking failed", table, pr.Status) + return staleReconcileDefinitivelyFailed, "" + } +} + // staleReconcileResult is the tri-state outcome of reconciling one stale // pending row against Square. Only a definitively-resolved outcome touches the // row: an ambiguous answer (transport error / 5xx) leaves it pending so a diff --git a/backend/handlers/payments/sweep_test.go b/backend/handlers/payments/sweep_test.go index 5206e1f..2205ba5 100644 --- a/backend/handlers/payments/sweep_test.go +++ b/backend/handlers/payments/sweep_test.go @@ -237,6 +237,363 @@ func (c *completedTerminalClient) GetCheckout(ctx context.Context, checkoutID st return c.SquareClient.GetCheckout(ctx, checkoutID) } +// ============================================================================= +// SweepStalePendingPayments — lost-response reconcile by idempotency key +// ============================================================================= + +// staleReplayClient forces ReplayPaymentByKey to return a fixed result/error so +// the keyed-reconcile branches can be exercised deterministically. +type staleReplayClient struct { + square.SquareClient + result *square.PaymentResult + err error +} + +func (c *staleReplayClient) ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*square.PaymentResult, error) { + if c.err != nil { + return nil, c.err + } + if c.result != nil { + return c.result, nil + } + return c.SquareClient.ReplayPaymentByKey(ctx, idempotencyKey, amount) +} + +// TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued locks the +// lost-response gap: a pending payment with a stored idempotency key but no +// square_payment_id whose charge actually COMPLETED at Square (the response +// was lost) is rescued to 'completed' with the real square_payment_id written +// back by replaying the key, instead of being blind-failed. +func TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued(t *testing.T) { + 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) + } + + staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") + if err != nil { + t.Fatalf("failed to create stale pending payment: %v", err) + } + // 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) but + // still inside Square's 24h idempotency-key retention window (so the replay + // returns the original payment instead of being blind-failed). + const key = "key-lost-response-completed" + if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1 WHERE id = $2", key, staleID); err != nil { + t.Fatalf("failed to age the stale payment: %v", err) + } + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + // Seed the completed charge at Square under the SAME idempotency key the + // pending row stores — the lost-response state the sweep must recover from. + pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ + Amount: 200000, + Currency: "GBP", + SourceID: "cnon:test-card", + IdempotencyKey: key, + }) + if err != nil { + t.Fatalf("failed to seed completed Square payment: %v", err) + } + SquareClient = mock + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + freshCtx := context.Background() + if _, err := SweepStalePendingPayments(freshCtx); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status, sqPayID string + if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil { + t.Fatalf("failed to query payment: %v", err) + } + if status != "completed" { + t.Errorf("expected lost-response payment with a completed Square charge rescued to 'completed', got %q", status) + } + if sqPayID != pay.SquarePayID { + t.Errorf("expected square_payment_id %s written back on the rescue, got %q", pay.SquarePayID, sqPayID) + } +} + +// TestSweepStalePendingPayments_KeyedLostResponse_NoPayment_Failed locks the +// mirror case: a pending payment with a stored idempotency key but no +// square_payment_id whose charge Square proves NEVER happened (no payment under +// the key) is marked failed — the keyed reconcile runs before the fail, so no +// row with a key is ever failed without checking Square first. +func TestSweepStalePendingPayments_KeyedLostResponse_NoPayment_Failed(t *testing.T) { + 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) + } + + staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") + if err != nil { + t.Fatalf("failed to create stale pending payment: %v", err) + } + if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-lost-response-never' WHERE id = $1", staleID); err != nil { + t.Fatalf("failed to age the stale payment: %v", err) + } + + // A fresh mock has no payment under the key → ReplayPaymentByKey returns + // ErrReplayKeyNotRetained → the charge provably never happened → failed. + origClient := SquareClient + SquareClient = square.NewDevClient() + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + freshCtx := context.Background() + if _, err := SweepStalePendingPayments(freshCtx); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status string + if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil { + t.Fatalf("failed to query payment: %v", err) + } + if status != "failed" { + t.Errorf("expected keyed payment with no charge at Square marked 'failed', got %q", status) + } +} + +// TestSweepStalePendingPayments_KeyedLostResponse_Ambiguous_LeavesPending locks +// the conservative keyed-reconcile rule: an ambiguous replay (transport error) +// leaves the keyed row pending — the charge may still be in flight at Square. +func TestSweepStalePendingPayments_KeyedLostResponse_Ambiguous_LeavesPending(t *testing.T) { + 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) + } + + staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") + if err != nil { + t.Fatalf("failed to create stale pending payment: %v", err) + } + if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-lost-response-ambiguous' WHERE id = $1", staleID); err != nil { + t.Fatalf("failed to age the stale payment: %v", err) + } + + origClient := SquareClient + SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), err: fmt.Errorf("network error: connection reset by peer")} + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + freshCtx := context.Background() + if _, err := SweepStalePendingPayments(freshCtx); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status string + if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil { + t.Fatalf("failed to query payment: %v", err) + } + if status != "pending" { + t.Errorf("expected ambiguous keyed replay to leave the payment pending, got %q", status) + } +} + +// TestSweepStalePendingPayments_KeyedTillLostResponse_CompletedRescued locks the +// keyed lost-response rescue for till_sales: a stale pending till sale with a +// stored idempotency key but no square_payment_id whose charge completed at +// Square is rescued to 'completed' with the square_payment_id written back. +func TestSweepStalePendingPayments_KeyedTillLostResponse_CompletedRescued(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ + Amount: 5000, + Currency: "GBP", + SourceID: "cnon:test-card", + IdempotencyKey: "key-lost-till-completed", + }) + if err != nil { + t.Fatalf("failed to seed completed Square payment: %v", err) + } + SquareClient = mock + defer func() { SquareClient = origClient }() + + var saleID string + err = tx.QueryRow(ctx, ` + INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, idempotency_key, created_by, created_at, updated_at) + VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, $2, NOW() - INTERVAL '23 hours', NOW()) + RETURNING id + `, "key-lost-till-completed", adminID).Scan(&saleID) + if err != nil { + t.Fatalf("failed to seed stale pending till sale: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID) + }) + + freshCtx := context.Background() + if _, err := SweepStalePendingPayments(freshCtx); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status, sqPayID string + if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil { + t.Fatalf("failed to query till sale: %v", err) + } + if status != "completed" { + t.Errorf("expected lost-response till sale with a completed Square charge rescued to 'completed', got %q", status) + } + if sqPayID != pay.SquarePayID { + t.Errorf("expected square_payment_id %s written back on the till sale rescue, got %q", pay.SquarePayID, sqPayID) + } +} + +// TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks +// locks the keyed clawback: a stale pending till sale with a stored idempotency +// key whose charge Square PROVES never happened (no payment under the key) is +// marked failed AND its funded gift card is clawed back — unlike the blind-fail +// path, the reconcile proved the funding has no charge behind it. +func TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + pool := context.Background() + + saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true) + // Move both the sale and its created gift card inside the key window (23h, + // created_at equality preserved → is_create stays true) and add the key. + if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-lost-till-never' WHERE id = $1", saleID); err != nil { + t.Fatalf("failed to age the till sale: %v", err) + } + if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil { + t.Fatalf("failed to age the gift card: %v", err) + } + + // A fresh mock has no payment under the key → ReplayPaymentByKey returns + // ErrReplayKeyNotRetained → definitively failed → clawback. + origClient := SquareClient + SquareClient = square.NewDevClient() + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit setup tx: %v", err) + } + + if _, err := SweepStalePendingPayments(pool); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status string + if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil { + t.Fatalf("failed to query till sale: %v", err) + } + if status != "failed" { + t.Errorf("expected keyed till sale with no charge at Square marked failed, got %q", status) + } + + // The reconcile PROVED the charge never happened, so the created gift card + // must have been clawed back (deleted). + var cardCount int + if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil { + t.Fatalf("failed to count gift cards: %v", err) + } + if cardCount != 0 { + t.Errorf("expected the clawed-back created gift card deleted after keyed proof, got %d cards", cardCount) + } +} + // TestSweepStaleTerminalCheckouts_CancelsStalePending locks the F4 fix: a // terminal checkout still PENDING at Square after an hour is cancelled and its // till_sales row moved to the terminal 'failed' state (the payment_status enum @@ -1065,3 +1422,262 @@ func TestSweepStaleTerminalCheckouts_TillCancelRequested_NoClawback(t *testing.T t.Errorf("expected card untouched after CANCEL_REQUESTED-only, got count=%d remaining=%.2f", cardCount, remaining) } } + +// ============================================================================= +// SweepStaleTerminalCheckouts — intermediate states via the dev mock's +// ForceCheckoutState (mirrors the real Square API, not a fake client) +// ============================================================================= + +// TestSweepStaleTerminalCheckouts_MockCanceled_Clawbacks exercises the +// isCheckoutDefinitivelyDead CANCELED direction through the DEV MOCK's own +// forced state: a CANCELED checkout is provably dead, so the stale till-sale +// is failed AND its funded gift card is clawed back. Before ForceCheckoutState +// the mock could only auto-complete or stay PENDING, so this sweep branch was +// only reachable via a fake client. +func TestSweepStaleTerminalCheckouts_MockCanceled_Clawbacks(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + pool := context.Background() + + // Force the checkout into the terminal CANCELED state — the mock's + // GetCheckout then emits the same plain "is CANCELED (not COMPLETED)" + // error the real client surfaces, which the sweep classifies as dead. + mock := square.NewDevClient().(*square.MockClient) + mock.ForceCheckoutState = "CANCELED" + checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{ + Amount: 5000, + Currency: "GBP", + IdempotencyKey: "chk-mock-canceled", + }) + if err != nil { + t.Fatalf("failed to create forced-CANCELED checkout: %v", err) + } + if checkout.Status != "CANCELED" { + t.Fatalf("expected forced CANCELED checkout, got %q", checkout.Status) + } + + saleID, giftCardID := seedStaleTerminalTillSale(t, ctx, tx, adminID, true) + if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_checkout_id = $1 WHERE id = $2`, checkout.ID, saleID); err != nil { + t.Fatalf("failed to set checkout id: %v", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'purchase', 50.00, 'till_sale', $2) + `, giftCardID, saleID); err != nil { + t.Fatalf("failed to seed gift card transaction: %v", err) + } + + origClient := SquareClient + SquareClient = mock + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit setup tx: %v", err) + } + + // Drop any other stale terminal rows left by parallel tests so the count is + // deterministic. + if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil { + t.Fatalf("failed to clean leftover stale terminal sales: %v", err) + } + if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil { + t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err) + } + + n, err := SweepStaleTerminalCheckouts(pool) + if err != nil { + t.Fatalf("sweep failed: %v", err) + } + if n != 1 { + t.Errorf("expected exactly 1 resolved stale terminal checkout, got %d", n) + } + + var status string + if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil { + t.Fatalf("failed to query till sale: %v", err) + } + if status != "failed" { + t.Errorf("expected definitively-cancelled till sale marked failed, got %q", status) + } + + // The created gift card must have been clawed back (deleted). + var cardCount int + if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil { + t.Fatalf("failed to count gift cards: %v", err) + } + if cardCount != 0 { + t.Errorf("expected the clawed-back created gift card deleted, got %d cards", cardCount) + } +} + +// TestSweepStaleTerminalCheckouts_MockNotFound_Clawbacks exercises the +// isCheckoutDefinitivelyDead NOT_FOUND direction through the dev mock: a +// square_checkout_id that references a checkout Square has never seen (expired +// checkout, e.g.) resolves to the mock's plain "checkout not found" error, +// which the sweep classifies as definitively dead and claws back. +func TestSweepStaleTerminalCheckouts_MockNotFound_Clawbacks(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + pool := context.Background() + + const checkoutID = "chk_never_created_mock" + saleID, giftCardID := seedStaleTerminalTillSale(t, ctx, tx, adminID, true) + if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_checkout_id = $1 WHERE id = $2`, checkoutID, saleID); err != nil { + t.Fatalf("failed to set checkout id: %v", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'purchase', 50.00, 'till_sale', $2) + `, giftCardID, saleID); err != nil { + t.Fatalf("failed to seed gift card transaction: %v", err) + } + + // A fresh mock holds no checkout under checkoutID → GetCheckout returns + // "checkout not found", which isTerminalCheckoutError / isCheckoutDefinitivelyDead + // classify as terminal + definitively dead. + origClient := SquareClient + SquareClient = square.NewDevClient() + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit setup tx: %v", err) + } + + if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil { + t.Fatalf("failed to clean leftover stale terminal sales: %v", err) + } + if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil { + t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err) + } + + n, err := SweepStaleTerminalCheckouts(pool) + if err != nil { + t.Fatalf("sweep failed: %v", err) + } + if n != 1 { + t.Errorf("expected exactly 1 resolved stale terminal checkout, got %d", n) + } + + var status string + if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil { + t.Fatalf("failed to query till sale: %v", err) + } + if status != "failed" { + t.Errorf("expected not-found till sale marked failed, got %q", status) + } + + var cardCount int + if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil { + t.Fatalf("failed to count gift cards: %v", err) + } + if cardCount != 0 { + t.Errorf("expected the clawed-back created gift card deleted, got %d cards", cardCount) + } +} + +// TestSweepStaleTerminalCheckouts_MockCancelRequested_CancelThenClawback +// exercises the cancel-then-recheck path for a CANCEL_REQUESTED checkout via +// the dev mock: GetCheckout folds CANCEL_REQUESTED into ErrCheckoutPending +// (mirroring the real client, which treats it as still-live), the sweep calls +// CancelCheckout (a no-op — Square returns 404 for an already-canceling +// checkout), and the re-check — still ErrCheckoutPending — resolves the sale +// to failed with the funding clawed back. The separate +// CANCEL_REQUESTED-only "not provably dead, no clawback" classification of +// isCheckoutDefinitivelyDead is locked by +// TestSweepStaleTerminalCheckouts_TillCancelRequested_NoClawback, which +// injects a non-pending CANCEL_REQUESTED error the real client never emits. +func TestSweepStaleTerminalCheckouts_MockCancelRequested_CancelThenClawback(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + pool := context.Background() + + mock := square.NewDevClient().(*square.MockClient) + mock.ForceCheckoutState = "CANCEL_REQUESTED" + checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{ + Amount: 5000, + Currency: "GBP", + IdempotencyKey: "chk-mock-cancel-requested", + }) + if err != nil { + t.Fatalf("failed to create forced-CANCEL_REQUESTED checkout: %v", err) + } + if checkout.Status != "CANCEL_REQUESTED" { + t.Fatalf("expected forced CANCEL_REQUESTED checkout, got %q", checkout.Status) + } + + saleID, giftCardID := seedStaleTerminalTillSale(t, ctx, tx, adminID, true) + if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_checkout_id = $1 WHERE id = $2`, checkout.ID, saleID); err != nil { + t.Fatalf("failed to set checkout id: %v", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'purchase', 50.00, 'till_sale', $2) + `, giftCardID, saleID); err != nil { + t.Fatalf("failed to seed gift card transaction: %v", err) + } + + origClient := SquareClient + SquareClient = mock + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit setup tx: %v", err) + } + + if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil { + t.Fatalf("failed to clean leftover stale terminal sales: %v", err) + } + if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil { + t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err) + } + + n, err := SweepStaleTerminalCheckouts(pool) + if err != nil { + t.Fatalf("sweep failed: %v", err) + } + if n != 1 { + t.Errorf("expected exactly 1 resolved stale terminal checkout, got %d", n) + } + + var status string + if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil { + t.Fatalf("failed to query till sale: %v", err) + } + if status != "failed" { + t.Errorf("expected CANCEL_REQUESTED (cancel-recheck) till sale marked failed, got %q", status) + } + + // The sweep believed the cancel landed, so the created gift card is clawed + // back (deleted) — the same path the real Square API produces. + var cardCount int + if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil { + t.Fatalf("failed to count gift cards: %v", err) + } + if cardCount != 0 { + t.Errorf("expected the clawed-back created gift card deleted, got %d cards", cardCount) + } +}