Files
Crussell/backend/handlers/payments/sweep.go
T
popertots 726ac8cb65 Harden money-safety re-review findings: nonce-independent idempotency keys, reconciliation-required logging, terminal-state classification
Re-review (2 Oracle + security + QA + librarian + context-miner) surfaced fixes, all applied: (1) UserPaymentModal and the account-page gift-card buy now key the cached idempotency key on a stable 'new-card' sentinel instead of the cnon: nonce, so clearing the nonce on a failed charge no longer regenerates the key — a lost-response retry now dedups at Square instead of double-charging (the tip flows already keyed on amount only). (2) The create-with-redeem clawback now logs CRITICAL when the guarded balance reversal is blocked (previously silent), and its transaction DELETE is scoped to this sale instead of deleting every transaction on the card. (3) reconcileStalePaymentAtSquare now treats APPROVED/PENDING as non-terminal (leave pending) instead of definitively failed, matching Square's documented state machine. (4) GetTillCheckoutStatus returns 404 for a sale already swept to failed instead of reporting a live state. (5) The critical-payment scan job skips candidates whose booking was hard-deleted, so one orphan can no longer silence all critical alerts.
2026-08-22 00:34:49 +01:00

634 lines
28 KiB
Go

package payments
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"strings"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"github.com/jackc/pgx/v5"
)
// SweepStalePendingPayments resolves pending payment records that are older
// than Square's idempotency-key retention window (~24h). A pending record
// means the DB committed but the Square charge outcome is unknown; it
// normally resolves on a same-key client retry. But if the client abandoned
// the attempt, the record stays pending forever — and retrying it after the
// key expires would ISSUE A SECOND CHARGE (Square no longer dedups). Failing
// stale pendings closes that double-charge window: a late retry finds a
// 'failed' record and stops instead of charging again.
//
// Before failing a row, the sweep reconciles it against Square: a
// genuinely-charged row (Square success, DB post-charge failure) with a
// square_payment_id is rescued to 'completed' instead of being swept to
// '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).
//
// 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
func SweepStalePendingPayments(ctx context.Context) (int, error) {
cutoff := clock.Now().Add(-stalePendingPaymentAge)
payCount, payCompleted, err := sweepStaleRows(ctx, "payments", cutoff)
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.
tillCount, tillCompleted, err := sweepStaleRows(ctx, "till_sales", cutoff)
if err != nil {
return 0, err
}
total := payCount + tillCount
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)
}
// 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).
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)
}
if tillCount > 0 {
log.Printf("[SWEEP] WARN: %d pending till sales marked failed may have been charged at Square with a lost response", tillCount-tillCompleted)
}
return total, nil
}
// staleRow is one stale pending row read by the sweep so it can reconcile
// rows that carry a Square reference BEFORE failing them. For till_sales rows
// the gift-card context needed to claw back funded money on a definitive
// failure is carried alongside: the card id, who it was redeemed to, whether
// this sale created the card (created_at equality — provable because both
// timestamps are the transaction-start NOW() in the same tx), and the sale
// amount (the exact funding this sale added).
type staleRow struct {
ID string
SquarePaymentID string
ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card)
RedeemToUserID *string // gift_cards.redeemed_by — user credited by a create-with-redeem
IsCreate bool // true when this sale created the gift card (timestamps equal)
HasGiftCard bool // false when the LEFT JOIN found no gift_cards row (gc.id IS NULL)
TotalAmount float64 // till_sales.total_amount — the funding this sale added
}
// sweepStaleRows resolves the stale pending rows of one table. Rows with a
// square_payment_id are reconciled at Square first (COMPLETED → 'completed',
// anything else → 'failed' exactly as the legacy bulk UPDATE did); rows
// without one cannot be reconciled and are failed directly. A till_sales row
// whose reconcile PROVES the charge never completed (NOT_FOUND / non-COMPLETED)
// also claws back the funded gift card atomically with the failed mark; the
// blind-fail path (no square_payment_id — the charge may have landed) never
// claws back. Returns the total rows resolved and how many were rescued to
// 'completed'.
func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolved int, completed int, err error) {
switch table {
case "payments", "till_sales":
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)
}
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 {
resolved++
completed++
}
continue
case staleReconcileLeavePending:
// Square's answer was ambiguous (transport/5xx) — the charge
// may still be in flight at Square. Do NOT touch the row: the
// next sweep run reconciles it again, and a same-key retry
// must still be able to reuse the pending row if the charge
// actually completed.
log.Printf("Stale pending %s row %s left pending (Square reconcile ambiguous) — will retry next sweep", table, r.ID)
continue
}
// staleReconcileDefinitivelyFailed falls through to the fail path.
}
// Fail path. A till-sale reconcile that PROVED the charge never
// completed (NOT_FOUND / non-COMPLETED) claws back the funded gift
// card atomically with the failed mark (HIGH-1). The blind-fail path
// (no square_payment_id — the charge outcome is unknown) NEVER claws
// back: the money may have landed at Square.
if table == "till_sales" && r.SquarePaymentID != "" && r.HasGiftCard {
action := "topup"
if r.IsCreate {
action = "create"
}
if revErr := revertGiftCardFunding(ctx, action, r.ItemID, r.TotalAmount, r.RedeemToUserID, r.ID); revErr != nil {
if errors.Is(revErr, errTillSaleNotPending) {
log.Printf("Stale pending till sale %s was already resolved (not pending) — skipping funding clawback", r.ID)
continue
}
log.Printf("CRITICAL: failed to claw back gift card %s funding for stale till sale %s: %v — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", r.ItemID, r.ID, revErr)
continue
}
resolved++
continue
}
if tag, upErr := db.Conn.Exec(ctx, `
UPDATE `+table+` SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`, r.ID); upErr != nil {
log.Printf("Failed to mark stale pending row %s failed: %v", r.ID, upErr)
} else if n := int(tag.RowsAffected()); n > 0 {
resolved++
}
}
return resolved, completed, nil
}
// staleReconcileResult is the tri-state outcome of reconciling one stale
// pending row against Square. Only a definitively-resolved outcome touches the
// row: an ambiguous answer (transport error / 5xx) leaves it pending so a
// same-key retry can still reuse it if the charge actually completed.
type staleReconcileResult int
const (
// staleReconcileLeavePending — Square's answer was ambiguous; the row stays
// pending for the next sweep run.
staleReconcileLeavePending staleReconcileResult = iota
// staleReconcileCompleted — Square confirms the charge completed; rescue
// the row to 'completed'.
staleReconcileCompleted
// staleReconcileDefinitivelyFailed — Square proves the charge never
// completed (payment not found / non-completed status); mark the row
// 'failed' exactly as the legacy bulk sweep did.
staleReconcileDefinitivelyFailed
)
// reconcileStalePaymentAtSquare asks Square for the authoritative status of a
// stale pending charge and returns the tri-state result. A COMPLETED payment
// rescues the row to 'completed'; a NOT_FOUND error or any non-COMPLETED status
// proves the charge never completed and fails the row as the legacy bulk sweep
// did. Any OTHER error (transport / 5xx / ambiguous) is NOT treated as a
// definitive failure — the charge may still have completed at Square, and
// marking the row failed would close the double-charge window (blocking a
// same-key retry with a 409) even though the money moved. Such rows stay
// pending for a later run.
func reconcileStalePaymentAtSquare(ctx context.Context, table, squarePaymentID string) staleReconcileResult {
pr, err := SquareClient.GetPayment(ctx, squarePaymentID)
if err != nil {
if squarePaymentErrorIsNotFound(err) {
log.Printf("Stale pending %s reconcile: Square payment %s not found (%v) — marking failed as the legacy sweep would", table, squarePaymentID, err)
return staleReconcileDefinitivelyFailed
}
log.Printf("Stale pending %s reconcile for Square payment %s hit an ambiguous error (%v) — leaving pending for a later sweep run", table, squarePaymentID, 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 (via
// CompletePayment or delay_action=COMPLETE), 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 (librarian-verified
// 2026-05-20).
switch pr.Status {
case "COMPLETED":
return staleReconcileCompleted
case "CANCELED", "FAILED":
log.Printf("Stale pending %s is %q at Square — marking failed", table, pr.Status)
return staleReconcileDefinitivelyFailed
case "APPROVED", "PENDING":
log.Printf("Stale pending %s is %q at Square (non-terminal) — leaving pending for a later sweep run", table, pr.Status)
return staleReconcileLeavePending
default:
log.Printf("Stale pending %s is %q at Square — marking failed", table, pr.Status)
return staleReconcileDefinitivelyFailed
}
}
// squarePaymentErrorIsNotFound reports whether a GetPayment error proves the
// payment does not exist at Square. The structured Square error code is the
// primary check (square.ErrorCode); the message fallback also covers the dev
// mock (a plain "payment not found" error) and a non-JSON 404 response.
func squarePaymentErrorIsNotFound(err error) bool {
if err == nil {
return false
}
if square.ErrorCode(err) == "NOT_FOUND" {
return true
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "NOT_FOUND") ||
strings.Contains(msg, "NOT FOUND") ||
strings.Contains(msg, "HTTP 404")
}
// staleTerminalCheckoutAge is how old a still-pending terminal checkout must
// be before the sweep cancels it. Terminal checkouts normally complete within
// minutes; an hour is far past any legitimate card-reader interaction while
// still short enough that a completed charge can never be misread as stale.
const staleTerminalCheckoutAge = 1 * time.Hour
// SweepStaleTerminalCheckouts cancels terminal (card-machine) checkouts that
// are still PENDING/IN_PROGRESS long after they were created, so the terminal
// stops waiting on a customer who walked away. A checkout created by
// CreateTerminalPayment / CreateTillSale that is never polled would otherwise
// sit live at Square indefinitely; if it later completes it is an invisible,
// untracked charge.
//
// Two tables track live checkout IDs and are both swept:
// - terminal_checkouts: booking terminal checkouts created by
// CreateTerminalPayment. PENDING/IN_PROGRESS rows older than the cutoff
// are resolved at Square first: a checkout still waiting at Square is
// cancelled and re-checked once — if it completed during the cancel window
// the row is marked 'completed' (the poll handler records the payment),
// otherwise 'failed'; a COMPLETED checkout releases the in-flight guard
// (the poll handler records the payment); a definitively
// cancelled/expired checkout is marked 'failed'; an ambiguous status is
// left for a later run.
// - till_sales.square_checkout_id: card-machine till sales. A checkout still
// waiting at Square is cancelled and the sale marked 'failed' (the
// payment_status enum has no 'cancelled' value, and 'failed' is the same
// terminal state the stale-pending sweep uses, blocking the till
// pending-retry path); a checkout that completed during the cancel window
// leaves the sale pending for the poll handler to record.
//
// Each row is checked at Square FIRST and only cancelled when the checkout is
// provably still waiting (ErrCheckoutPending): a COMPLETED checkout is never
// cancelled, and a checkout whose status is unknown (transport error) is left
// alone for a later run. After a cancel the checkout is re-checked once — the
// customer may have completed the payment in the cancel window, in which case
// the row is resolved to 'completed' rather than 'failed'.
func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
cutoff := clock.Now().Add(-staleTerminalCheckoutAge)
var pending []staleTerminalCheckoutRow
// Booking terminal checkouts (CreateTerminalPayment) live in the
// terminal_checkouts table. A stale PENDING/IN_PROGRESS row means the
// checkout is still live at Square (or was left after a crash / lost poll).
rows, err := db.Conn.Query(ctx, `
SELECT 'terminal_checkout', checkout_id, checkout_id
FROM terminal_checkouts
WHERE status IN ('PENDING', 'IN_PROGRESS')
AND created_at < $1
`, cutoff)
if err != nil {
return 0, err
}
for rows.Next() {
var r staleTerminalCheckoutRow
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil {
log.Printf("Failed to scan stale terminal checkout row: %v", err)
continue
}
pending = append(pending, r)
}
rows.Close()
// Till-sale card-machine checkouts are tracked on the till_sales row.
rows, err = db.Conn.Query(ctx, `
SELECT 'till_sale', id, square_checkout_id
FROM till_sales
WHERE status = 'pending' AND square_checkout_id IS NOT NULL
AND created_at < $1
`, cutoff)
if err != nil {
return 0, err
}
for rows.Next() {
var r staleTerminalCheckoutRow
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil {
log.Printf("Failed to scan stale terminal checkout row: %v", err)
continue
}
pending = append(pending, r)
}
rows.Close()
resolved := 0
for _, r := range pending {
// A provisional (pre-Square) terminal_checkouts row carries a synthetic
// "tmp-" checkout_id (or an empty one) — no checkout was ever created
// at Square for it, so it is PROVABLY not live (R3). Resolve it to
// failed directly without a Square round-trip; a hard crash between the
// row insert and the Square CreateCheckout call is the only way one
// exists.
if r.CheckoutID == "" || strings.HasPrefix(r.CheckoutID, "tmp-") {
if markTerminalCheckoutRowFailed(ctx, r) {
resolved++
}
log.Printf("Provisional (pre-Square) terminal checkout row %s (%s) resolved as failed — no live checkout at Square", r.RowID, r.Kind)
continue
}
// Conservative status check: only cancel a checkout that is provably
// still waiting at Square. A COMPLETED checkout must never be
// cancelled, and an ambiguous status (network error) is left alone for
// the next run.
pr, gErr := SquareClient.GetCheckout(ctx, r.CheckoutID)
switch {
case errors.Is(gErr, square.ErrCheckoutPending):
// Still live at the terminal — cancel it. A customer can complete
// the payment in the small window between the GetCheckout above and
// the cancel, so re-check once before marking the row failed: a
// COMPLETED charge must never be recorded as failed.
if cErr := SquareClient.CancelCheckout(ctx, r.CheckoutID); cErr != nil {
log.Printf("Failed to cancel stale terminal checkout %s (%s %s): %v", r.CheckoutID, r.Kind, r.RowID, cErr)
continue
}
recheck, rErr := SquareClient.GetCheckout(ctx, r.CheckoutID)
switch {
case rErr == nil && recheck.Status == "COMPLETED":
// The customer completed the payment during the cancel window.
// The poll handler records it — mark the row COMPLETED (or
// leave the till sale pending) instead of failed.
if r.Kind == "terminal_checkout" {
if tag, upErr := db.Conn.Exec(ctx, `
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
`, r.RowID); upErr != nil {
log.Printf("Failed to mark terminal checkout %s completed after cancel re-check: %v", r.RowID, upErr)
} else if int(tag.RowsAffected()) > 0 {
resolved++
}
} else {
log.Printf("Terminal checkout %s completed during sweep cancel — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID)
}
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — recorded as completed, payment handled by the poll handler", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
case isTerminalCheckoutError(rErr) || errors.Is(rErr, square.ErrCheckoutPending):
// The cancel landed (CANCELED / cancel-requested / expired /
// still-reporting-pending-but-now-cancelled) — it can never
// complete, so resolve the row to the terminal 'failed' state.
// A till sale's funded gift card is clawed back (a cancel that
// landed cannot later complete); a booking terminal_checkout
// has no gift card.
if r.Kind == "till_sale" {
if clawBackTillSaleFunding(ctx, r.RowID) {
resolved++
}
} else if markTerminalCheckoutRowFailed(ctx, r) {
resolved++
}
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) — marked failed", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
default:
// The cancel succeeded but the re-check itself is ambiguous —
// leave the row for a later run.
log.Printf("Terminal checkout %s was cancelled but its re-check is ambiguous (%v) — leaving %s %s pending for a later sweep", r.CheckoutID, rErr, r.Kind, r.RowID)
}
case gErr == nil && pr.Status == "COMPLETED":
if r.Kind == "terminal_checkout" {
// The payment is recorded by the poll handler; release the
// in-flight guard so a fresh charge can be created.
if tag, upErr := db.Conn.Exec(ctx, `
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
`, r.RowID); upErr != nil {
log.Printf("Failed to mark terminal checkout %s completed: %v", r.RowID, upErr)
} else if int(tag.RowsAffected()) > 0 {
resolved++
}
} else {
// The till poll handler records it — leave the sale pending.
log.Printf("Terminal checkout %s already COMPLETED at Square — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID)
}
case isTerminalCheckoutError(gErr):
// The checkout is cancelled / cancel-requested / expired at Square.
switch {
case r.Kind == "till_sale" && isCheckoutDefinitivelyDead(gErr):
// The error PROVES the checkout can never complete (CANCELED /
// NOT_FOUND, and no bare CANCEL_REQUESTED) — claw back the
// funded gift card along with the failed mark.
if clawBackTillSaleFunding(ctx, r.RowID) {
resolved++
}
log.Printf("Terminal checkout %s is definitively terminal (%v) — marked till sale %s failed, gift-card funding clawed back", r.CheckoutID, gErr, r.RowID)
case r.Kind == "till_sale":
// CANCEL_REQUESTED-only: Square does not promise non-completion
// in that state, so the charge may still land. Mark the sale
// failed but DO NOT claw back the funded gift card.
if markTerminalCheckoutRowFailed(ctx, r) {
resolved++
}
log.Printf("CRITICAL: terminal checkout %s reports only CANCEL_REQUESTED (not provably dead) — till sale %s marked failed WITHOUT clawing back the funded gift card; verify at Square before re-issuing — MANUAL RECONCILIATION REQUIRED", r.CheckoutID, r.RowID)
default:
if markTerminalCheckoutRowFailed(ctx, r) {
resolved++
}
log.Printf("Terminal checkout %s is definitively terminal (%v) — marked %s %s failed", r.CheckoutID, gErr, r.Kind, r.RowID)
}
default:
// Ambiguous transport/unknown status — leave for a later run.
log.Printf("Terminal checkout %s status unknown (%v) — leaving %s %s pending for a later sweep", r.CheckoutID, gErr, r.Kind, r.RowID)
}
}
return resolved, nil
}
// staleTerminalCheckoutRow is one live-checkout row the sweep reads from
// either table so it can resolve the checkout at Square before touching the
// row. Kind is "terminal_checkout" (booking, terminal_checkouts table) or
// "till_sale" (till_sales.square_checkout_id).
type staleTerminalCheckoutRow struct {
Kind string
RowID string
CheckoutID string
}
// markTerminalCheckoutRowFailed moves one tracked row to the terminal 'failed'
// state after its checkout is cancelled or proven terminal at Square. Returns
// true when the row was updated (status was still active).
func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutRow) bool {
var table, where string
if r.Kind == "till_sale" {
table = "till_sales"
where = "id = $1 AND status = 'pending'"
} else {
table = "terminal_checkouts"
where = "checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')"
}
tag, err := db.Conn.Exec(ctx, `
UPDATE `+table+` SET status = 'failed', updated_at = NOW()
WHERE `+where, r.RowID)
if err != nil {
log.Printf("Failed to mark %s %s failed: %v", r.Kind, r.RowID, err)
return false
}
return int(tag.RowsAffected()) > 0
}
// isTerminalCheckoutError reports whether a GetCheckout error proves the
// checkout can never complete. Square's HTTP client returns ErrCheckoutPending
// for a still-live checkout and surfaces a definitively CANCELED status as a
// "square: checkout <id> is CANCELED (not COMPLETED)" error; an expired
// checkout returns a NOT_FOUND API error (the mock uses "checkout not found").
// Any other error (timeout, 5xx) leaves the money state ambiguous, so the
// checkout must stay in flight.
func isTerminalCheckoutError(err error) bool {
if err == nil || errors.Is(err, square.ErrCheckoutPending) {
return false
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "CANCELED") ||
strings.Contains(msg, "CANCEL_REQUESTED") ||
strings.Contains(msg, "NOT_FOUND") ||
strings.Contains(msg, "NOT FOUND")
}
// isCheckoutDefinitivelyDead reports whether a GetCheckout error PROVES the
// checkout can never complete — the condition under which a till sale's funded
// gift card may be clawed back. It is stricter than isTerminalCheckoutError:
// a message that only reports CANCEL_REQUESTED (Square does not promise
// non-completion in that state) is NOT definitive proof, so the charge may
// still land and the funding must stay put. Only an explicit CANCELED or
// NOT_FOUND status is definitive; a CANCEL_REQUESTED message counts only when
// it ALSO carries CANCELED.
func isCheckoutDefinitivelyDead(err error) bool {
if err == nil || errors.Is(err, square.ErrCheckoutPending) {
return false
}
msg := strings.ToUpper(err.Error())
if !strings.Contains(msg, "CANCELED") && !strings.Contains(msg, "NOT_FOUND") && !strings.Contains(msg, "NOT FOUND") {
return false
}
if strings.Contains(msg, "CANCEL_REQUESTED") && !strings.Contains(msg, "CANCELED") {
return false
}
return true
}
// clawBackTillSaleFunding reverts the gift-card funding of a till sale whose
// card-machine checkout is provably dead, marking the sale failed at the same
// time. The terminal-checkout sweep's rows carry no gift-card context, so the
// sale's item/amount/redeem target are looked up here (is_create via the
// created_at equality with the gift card) and handed to the claim-first
// revertGiftCardFunding. A sale with no gift card (future retail product /
// orphaned item) 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, tillSaleID string) bool {
var itemType string
var itemID sql.NullString
var totalAmount float64
var redeemedBy sql.NullString
var isCreate *bool
err := db.Conn.QueryRow(ctx, `
SELECT ts.item_type, ts.item_id, ts.total_amount, gc.redeemed_by,
(ts.created_at = gc.created_at) AS is_create
FROM till_sales ts
LEFT JOIN gift_cards gc ON gc.id = ts.item_id
WHERE ts.id = $1
`, tillSaleID).Scan(&itemType, &itemID, &totalAmount, &redeemedBy, &isCreate)
if err != nil {
log.Printf("CRITICAL: failed to look up till sale %s for funding clawback: %v — MANUAL RECONCILIATION REQUIRED", tillSaleID, err)
return false
}
if itemType != "gift_card" || !itemID.Valid || itemID.String == "" || isCreate == nil {
// No gift card to claw back — mark the sale failed without touching
// any card.
tag, upErr := db.Conn.Exec(ctx, `
UPDATE till_sales SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`, tillSaleID)
if upErr != nil {
log.Printf("Failed to mark till sale %s failed: %v", tillSaleID, upErr)
return false
}
return int(tag.RowsAffected()) > 0
}
action := "topup"
if *isCreate {
action = "create"
}
var redeem *string
if redeemedBy.Valid && redeemedBy.String != "" {
redeem = &redeemedBy.String
}
if revErr := revertGiftCardFunding(ctx, action, itemID.String, totalAmount, redeem, tillSaleID); revErr != nil {
if errors.Is(revErr, errTillSaleNotPending) {
log.Printf("Till sale %s was already resolved (not pending) — skipping funding clawback", tillSaleID)
return false
}
log.Printf("CRITICAL: funding clawback for till sale %s (gift card %s) failed: %v — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", tillSaleID, itemID.String, revErr)
return false
}
return true
}