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 not-found check is primary // (square.IsNotFound: NOT_FOUND code / HTTP 404 status / plain "HTTP 404" body); // the message fallback covers only the dev mock's plain "payment not found" // error, which carries no structured code. func squarePaymentErrorIsNotFound(err error) bool { if err == nil { return false } if square.IsNotFound(err) { return true } // Any other structured Square error code is authoritative — never // substring-match its message. if square.ErrorCode(err) != "" { return false } msg := strings.ToUpper(err.Error()) return strings.Contains(msg, "NOT_FOUND") || strings.Contains(msg, "NOT FOUND") } // squareHasCode reports whether err carries a structured Square error code // equal to any of codes. Errors without a structured code (the dev mock's // plain errors) match nothing. func squareHasCode(err error, codes ...string) bool { if err == nil { return false } code := square.ErrorCode(err) for _, c := range codes { if code == c { return true } } return false } // 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 is CANCELED (not COMPLETED)" error; an expired // checkout returns a structured NOT_FOUND API error (the mock uses a plain // "checkout not found"). Any other error (timeout, 5xx) leaves the money state // ambiguous, so the checkout must stay in flight. // // Structured codes are authoritative when present: NOT_FOUND (via // square.IsNotFound) and an explicit CANCELED / CANCEL_REQUESTED code classify // as terminal. The formatted-message match applies only to errors that carry // no structured code — the dev mock's plain errors and the real client's // client-side "is (not COMPLETED)" error, which it synthesizes // without a squareAPIError. func isTerminalCheckoutError(err error) bool { if err == nil || errors.Is(err, square.ErrCheckoutPending) { return false } if square.IsNotFound(err) || squareHasCode(err, "CANCELED", "CANCEL_REQUESTED") { return true } // Any other structured Square error code is authoritative — never // substring-match its message. if square.ErrorCode(err) != "" { 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 CANCEL_REQUESTED-only classification (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. // // Structured codes are authoritative when present: CANCELED and NOT_FOUND are // definitive, a bare CANCEL_REQUESTED code is not. The formatted-message match // applies only to errors that carry no structured code (dev mock plain errors, // and the real client's client-side "is (not COMPLETED)" error). func isCheckoutDefinitivelyDead(err error) bool { if err == nil || errors.Is(err, square.ErrCheckoutPending) { return false } if code := square.ErrorCode(err); code != "" { return code == "CANCELED" || code == "NOT_FOUND" } // Non-structured errors: an HTTP 404 / plain 404 body is definitive // (IsNotFound); the message match covers the client-side CANCELED status // error and the mock's plain not-found errors. if square.IsNotFound(err) { return true } 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 }