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:
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,43 +32,90 @@ import (
|
|||||||
// 'failed' with no automatic resolution — the money would otherwise be lost
|
// 'failed' with no automatic resolution — the money would otherwise be lost
|
||||||
// in limbo (MINOR-R3). Reconciliation is deliberately minimal: status +
|
// in limbo (MINOR-R3). Reconciliation is deliberately minimal: status +
|
||||||
// updated_at only, no split/VAT recomputation (that is the handler's job; the
|
// 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
|
// Only online/till card payments can be pending — cash/giftcard/on_the_house
|
||||||
// are committed synchronously and never enter this state. Both the payments
|
// are committed synchronously and never enter this state. Both the payments
|
||||||
// table and till_sales carry pending card-sale rows and are swept here.
|
// table and till_sales carry pending card-sale rows and are swept here.
|
||||||
const stalePendingPaymentAge = 24 * time.Hour
|
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) {
|
func SweepStalePendingPayments(ctx context.Context) (int, error) {
|
||||||
cutoff := clock.Now().Add(-stalePendingPaymentAge)
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// till_sales rows for card payments (stored as 'online_square' or
|
// Pass 2 (legacy 24h cutoff): rows WITH a square_payment_id are reconciled
|
||||||
// 'in_person_card' in the payment_method enum — saved_card/online_square/
|
// by payment id; rows with neither a payment id nor a stored key cannot be
|
||||||
// card_machine requests all persist as one of those) can also be pending.
|
// reconciled and are failed directly (WARN — the charge outcome is unknown).
|
||||||
// Sweep them too — a lost-response till sale would otherwise stay pending
|
payCount, payCompleted, err := sweepStaleRows(ctx, "payments", cutoff)
|
||||||
// and a retry after key retention would reuse the stored key → Square sees
|
if err != nil {
|
||||||
// an expired key → second charge (R3). Cash / on_the_house are committed
|
return 0, err
|
||||||
// synchronously and never pending.
|
}
|
||||||
tillCount, tillCompleted, err := sweepStaleRows(ctx, "till_sales", cutoff)
|
tillCount, tillCompleted, err := sweepStaleRows(ctx, "till_sales", cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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 {
|
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
|
// 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
|
// 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
|
// 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
|
// admin doing a periodic money reconciliation — but this fires on every
|
||||||
// normal run and must not be elevated to CRITICAL (which is reserved for
|
// 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 {
|
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)
|
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 {
|
type staleRow struct {
|
||||||
ID string
|
ID string
|
||||||
SquarePaymentID 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
|
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)
|
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)
|
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:
|
default:
|
||||||
return 0, 0, fmt.Errorf("sweep: unknown stale table %q", table)
|
return 0, 0, fmt.Errorf("sweep: unknown stale table %q", table)
|
||||||
}
|
}
|
||||||
// The legacy till_sales sweep only touched card methods — cash and
|
stale, err := fetchStaleRows(ctx, table, cutoff, false)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
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 {
|
for _, r := range stale {
|
||||||
if r.SquarePaymentID != "" {
|
if r.SquarePaymentID != "" {
|
||||||
switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) {
|
switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) {
|
||||||
case staleReconcileCompleted:
|
case staleReconcileCompleted:
|
||||||
if tag, upErr := db.Conn.Exec(ctx, `
|
if rescueStaleRowCompleted(ctx, table, r.ID) {
|
||||||
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 {
|
|
||||||
resolved++
|
resolved++
|
||||||
completed++
|
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
|
// (no square_payment_id — the charge outcome is unknown) NEVER claws
|
||||||
// back: the money may have landed at Square.
|
// back: the money may have landed at Square.
|
||||||
if table == "till_sales" && r.SquarePaymentID != "" && r.HasGiftCard {
|
if table == "till_sales" && r.SquarePaymentID != "" && r.HasGiftCard {
|
||||||
action := "topup"
|
if clawbackTillSaleFunding(ctx, r) {
|
||||||
if r.IsCreate {
|
resolved++
|
||||||
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)
|
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
if tag, upErr := db.Conn.Exec(ctx, `
|
if failStaleRow(ctx, table, r.ID) {
|
||||||
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++
|
resolved++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resolved, completed, nil
|
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
|
// staleReconcileResult is the tri-state outcome of reconciling one stale
|
||||||
// pending row against Square. Only a definitively-resolved outcome touches the
|
// pending row against Square. Only a definitively-resolved outcome touches the
|
||||||
// row: an ambiguous answer (transport error / 5xx) leaves it pending so a
|
// row: an ambiguous answer (transport error / 5xx) leaves it pending so a
|
||||||
|
|||||||
@@ -237,6 +237,363 @@ func (c *completedTerminalClient) GetCheckout(ctx context.Context, checkoutID st
|
|||||||
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
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
|
// TestSweepStaleTerminalCheckouts_CancelsStalePending locks the F4 fix: a
|
||||||
// terminal checkout still PENDING at Square after an hour is cancelled and its
|
// 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
|
// 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user