Sweep lost-response charges by idempotency-key replay (22h cutoff, rescue/fail/WARN)

The stale-pending sweep now runs two passes. Pass 1 (22h cutoff — deliberately
2h earlier than the 24h legacy cutoff) targets pending rows with 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 received. Each row
is reconciled at Square by replaying the key (ReplayPaymentByKey): a COMPLETED
charge is rescued to 'completed' with the real square_payment_id written back,
a charge Square proves never happened is failed (till-sale gift-card clawback
included), an ambiguous answer leaves the row pending for the next run. The
earlier cutoff keeps the replay inside Square's ~24h key-retention window;
replaying at exactly 24h risks an expired key misreading as 'never charged'.

Pass 2 (legacy 24h cutoff) reconciles rows WITH a square_payment_id by payment
id; rows with neither payment id nor stored key (no reconcile possible) are
failed with a WARN that the charge outcome is unknown. Late retries on all
swept rows are rejected (409), preventing a second Square charge.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 01ac211408
commit 39d914417f
2 changed files with 962 additions and 88 deletions
+339 -81
View File
@@ -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,6 +135,18 @@ func SweepStalePendingPayments(ctx context.Context) (int, error) {
type staleRow struct {
ID string
SquarePaymentID string
// 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)
@@ -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,6 +199,228 @@ 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 {
if clawbackTillSaleFunding(ctx, r) {
resolved++
}
continue
}
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"
@@ -201,24 +428,55 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv
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
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)
continue
return false
}
resolved++
continue
return true
}
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 {
resolved++
// 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, ""
}
return resolved, completed, nil
}
// staleReconcileResult is the tri-state outcome of reconciling one stale
+616
View File
@@ -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)
}
}