package payments import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "log" "log/slog" "math" "net/http" "strconv" "strings" "crussell/db" "crussell/internal/square" "crussell/internal/twofa" "crussell/internal/validators" "crussell/mw" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) type TillSaleRequest struct { ItemType string `json:"item_type" validate:"required"` Action string `json:"action" validate:"required"` Amount float64 `json:"amount" validate:"required,gt=0"` GiftCardID *string `json:"gift_card_id,omitempty"` PaymentMethod string `json:"payment_method" validate:"required"` UserSavedCardID *string `json:"user_saved_card_id,omitempty"` UserID *string `json:"user_id,omitempty"` // IdempotencyKey is optional; an empty key is replaced with a DETERMINISTIC // fallback derived from the canonical request fields // (deriveTillIdempotencyKey) so a lost-response retry re-derives the SAME // key and reuses the pending till_sale row instead of minting a second // Square charge. Limit 45: this key feeds CreatePayment (Square's /v2/ // payments cap) as well as CreateCheckout, which allows 64 — the stricter // 45 applies because the same key is replayed to /v2/payments. IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` CardToken string `json:"card_token,omitempty"` RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` VerificationToken *string `json:"verification_token,omitempty"` // VerificationCode is the card owner's current 2FA one-time code (B10): an // enforced environment charges a saved card only when this matches the // customer's pending code. VerificationCode string `json:"verification_code,omitempty"` } type TillSaleResponse struct { ID string `json:"id"` ItemType string `json:"item_type"` ItemID *string `json:"item_id,omitempty"` TotalAmount float64 `json:"total_amount"` PaymentMethod string `json:"payment_method"` Status string `json:"status"` CheckoutID *string `json:"checkout_id,omitempty"` } // uniqueChargeKey (handlers.go) remains the random fallback for the TIP flow, // where two identical no-key tips are distinct operations that must diverge. // The till no-key fallback deliberately does NOT use it: a random key would // mint a second Square charge (and second gift-card funding) when a lost- // response create is retried. deriveTillIdempotencyKey (below) derives a // request-stable key instead. // definitivePaymentDeclineCodes are Square payment error codes meaning the // card charge can never succeed (declined / expired / not supported / SCA // verification required). They are matched against the formatted Square API // error message so a DEFINITIVE rejection can claw back a gift card funded // earlier in the same till-sale request. Anything else (transport errors, 5xx, // unknown) is treated as ambiguous: the sale is left pending for the // stale-pending sweep, which may still resolve it. // // A1: this list is the MESSAGE-MATCH fallback ONLY. The authoritative // structured-code classification lives in the square package's // definitivePaymentCodes / IsDefinitivePaymentError // (square_http_client.go:777-818) — the single source of truth that also // carries the SCA buyer-verification codes — and isDefinitiveChargeFailure // delegates to it FIRST. The list below must stay a subset-compatible mirror // for errors that carry no structured code (the dev mock's plain errors, a // non-JSON failure body), where the formatted "[CATEGORY/CODE]" message is the // only signal available. var definitivePaymentDeclineCodes = []string{ "CARD_DECLINED", "CARD_EXPIRED", "INVALID_EXPIRATION", "INVALID_EXPIRATION_DATE", "CARD_NOT_SUPPORTED", "VERIFY_CVV_FAILURE", "AVS_FAILURE", "PAYMENT_CARD_DECLINED", "GENERIC_DECLINE", "INSUFFICIENT_FUNDS", "ADDRESS_VERIFICATION_FAILURE", "TRANSACTION_LIMIT", // Square's specific CARD_DECLINED_* decline reasons. This list must stay a // SUPERSET-MATCHED MIRROR of square_http_client.go:definitivePaymentCodes // (the single source of truth) — every variant the square package treats as // definitive must also match here, so a dev-mock plain error carrying e.g. // CARD_DECLINED_INSUFFICIENT_FUNDS in its formatted message classifies // definitively. Add any new definitivePaymentCodes entry here too. "CARD_DECLINED_CALL_ISSUER", "CARD_DECLINED_AVS_FAILURE", "CARD_DECLINED_CVV_FAILURE", "CARD_DECLINED_INSUFFICIENT_FUNDS", "CARD_DECLINED_INVALID_ACCOUNT", "CARD_DECLINED_INVALID_AMOUNT", "CARD_DECLINED_CARD_EXPIRED", "CARD_DECLINED_PIN_RETRIES_EXCEEDED", // SCA / buyer-verification codes — the buyer must re-verify or the card be // re-tokenized before the charge can succeed; retrying is pointless. Kept // in the message fallback so the dev mock's plain errors classify exactly // like the real client's structured codes. "CARD_DECLINED_VERIFICATION_REQUIRED", "VERIFICATION_TOKEN_EXPIRED", "VERIFICATION_TOKEN_INVALID", "CVV_VERIFICATION_REQUIRED", "ADDRESS_VERIFICATION_REQUIRED", "MISSING_PIN", "MISSING_VERIFICATION_TOKEN", } // isDefinitiveChargeFailure reports whether a Square CreatePayment error is a // definitive business rejection (declined/expired/SCA-required) rather than an // ambiguous transport/server error. // // A1 — single source of truth: the classification delegates FIRST to the // square package's exported IsDefinitivePaymentError (square_http_client.go // definitivePaymentCodes), which is the union of the card decline codes and // the SCA buyer-verification codes. Delegating means the two parallel lists // can never drift again — an SCA rejection (e.g. CVV_VERIFICATION_REQUIRED) // now classifies as definitive here exactly as it does everywhere else, so the // till's gift-card clawback (till.go:1184) reverses the funding on a // verification failure the same way it does on a plain decline. Only errors // that carry NO structured code (the dev mock's plain errors, or a non-JSON // failure body) fall back to the legacy formatted-message match // ("square: POST /v2/payments: [CATEGORY/CODE] ...") against // definitivePaymentDeclineCodes — the only signal available for them. func isDefinitiveChargeFailure(err error) bool { if err == nil { return false } if square.IsDefinitivePaymentError(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()) for _, code := range definitivePaymentDeclineCodes { if strings.Contains(msg, code) { return true } } return false } // deriveTillIdempotencyKey returns the deterministic no-client-key fallback // BASE idempotency key for a till sale: "till-" + sha256 over the canonical // request fields (action, created_by admin, amount in pence, and the gift card // / user / saved card / redeem targets when present), truncated to 16 bytes so // the key stays within Square's 45-char /v2/payments limit. // // The hash is applied UNCONDITIONALLY (never the verbatim candidate): a row // created pre-deploy stores the hashed form (CHAR(12) candidates like // "till:create:...:2500" are ~30 chars and used to derive the hashed key), and // a lost-response retry must re-derive the SAME key to hit the dedup SELECT // and slot scan — the length-conditional truncateIdempotencyKey form would // switch short candidates to a raw key that misses the pre-deploy row and // mints a SECOND Square charge. // // A lost-response retry re-derives the SAME base key, so the idempotency lookup // in CreateTillSale reuses the pending till_sale row and Square dedups on the // key — ONE charge and ONE gift-card funding instead of the old uniqueChargeKey // fallback's second charge on retry. The card nonce (req.CardToken) is // deliberately EXCLUDED — it changes between retries — and no random value // enters the base key, so identical logical sales always collide on the SAME // base key; the handler resolves that collision through the slot scan // (scanTillIdempotencyKeySlot) run under the advisory lock, which diverges // genuinely DISTINCT identical keyless sales onto distinct final keys (F5) // while a lost-response retry of a pending sale keeps re-deriving the same // candidate and reuses the pending row. func deriveTillIdempotencyKey(req TillSaleRequest, adminID string) string { var sb strings.Builder sb.WriteString("till:") sb.WriteString(req.Action) sb.WriteString(":") sb.WriteString(adminID) sb.WriteString(":") sb.WriteString(strconv.FormatInt(int64(math.Round(req.Amount*100)), 10)) if req.GiftCardID != nil && *req.GiftCardID != "" { sb.WriteString(":gc:") sb.WriteString(validators.NormalizeGiftCardCode(*req.GiftCardID)) } if req.UserID != nil && *req.UserID != "" { sb.WriteString(":user:") sb.WriteString(*req.UserID) } if req.UserSavedCardID != nil && *req.UserSavedCardID != "" { sb.WriteString(":card:") sb.WriteString(*req.UserSavedCardID) } if req.RedeemToUserID != nil && *req.RedeemToUserID != "" { sb.WriteString(":redeem:") sb.WriteString(*req.RedeemToUserID) } // Always-hashed, NEVER truncateIdempotencyKey: a pre-deploy row stores the // hashed key, so a raw-key re-derivation would miss the dedup and double- // charge. The slot-scan candidates (tillIdempotencyKeyCandidate) keep their // own conditional truncation, matching the pre-batch code. sum := sha256.Sum256([]byte(sb.String())) return "till-" + hex.EncodeToString(sum[:16]) } // tillIdempotencyKeyCandidate appends the slot-sequence suffix to a derived // base key, hashing back under Square's 45-char /v2/payments limit when the // verbatim form would overflow (the hash stays deterministic). Shared // implementation: nextIdempotencyCandidate (idempotency_helpers.go). func tillIdempotencyKeyCandidate(baseKey string, seq int) string { return nextIdempotencyCandidate(baseKey, seq) } // scanTillIdempotencyKeySlot resolves the FINAL deterministic idempotency key // for a keyless Square-method till sale, mirroring the gift-card slot pattern // (deriveGiftCardIdempotencyKey). Must be called under the crussell:till // advisory lock so the scan-and-insert races no concurrent identical create. // // A COMPLETED (or swept/declined FAILED) sale occupies its candidate slot and // forces the NEXT candidate — two genuine identical keyless sales (e.g. two // £20 walk-in card creations with no user) must diverge onto distinct keys, or // the second is silently swallowed by the first's dedup and its customer gets // NO gift card (F5). A PENDING sale never occupies a slot: a lost-response // retry re-derives the same candidate, the idempotency lookup below reuses the // pending row and adopts its STORED key, and Square dedups the charge onto the // original — ONE charge, ONE gift-card funding. func scanTillIdempotencyKeySlot(ctx context.Context, baseKey string) (string, error) { for seq := 0; ; seq++ { candidate := tillIdempotencyKeyCandidate(baseKey, seq) var status string err := db.Conn.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, candidate).Scan(&status) if errors.Is(err, pgx.ErrNoRows) { return candidate, nil } if err != nil { return "", fmt.Errorf("failed to scan till idempotency-key slot for %s: %w", baseKey, err) } if status == "completed" || status == "failed" { continue // occupied slot — a distinct sale must diverge onto a fresh key. } // Pending (or any other state): the same logical sale is in flight — a // lost-response retry must reuse it, so keep this candidate. return candidate, nil } } // refreshTillSnapshotSource rewrites the source_id field inside the stored // square_request_snapshot JSON on a reused pending till sale so the snapshot // stays consistent with the square_source_id column refreshed for the new // charge attempt. The stale-pending sweep replays the snapshot VERBATIM as the // charge body (sweep.go reconcileStalePaymentByKey); a snapshot whose source // differs from the row's source would make Square return IDEMPOTENCY_KEY_REUSED // for the retained key and strand the row pending forever. Runs inside the same // transaction as the square_source_id refresh so the two columns never diverge. // A row without a stored snapshot (or an unparseable one) is left untouched — // the charge attempt below re-marshals a fresh full body before calling Square. func refreshTillSnapshotSource(ctx context.Context, tx pgx.Tx, tillSaleID, newSource string) { var snap sql.NullString if err := tx.QueryRow(ctx, `SELECT square_request_snapshot FROM till_sales WHERE id = $1`, tillSaleID).Scan(&snap); err != nil { log.Printf("Failed to read square_request_snapshot for reused till sale %s: %v", tillSaleID, err) return } if !snap.Valid || snap.String == "" { return } // The stored snapshot is AES-256-GCM-encrypted at rest in non-mock // deployments (encryptSnapshot's "enc:v1:" marker) and plaintext in // dev/mock — decrypt it first so the JSON mutation below operates on the // request body, then re-encrypt on the way out so the stored form stays // consistent with the other snapshot writes. body := []byte(snap.String) if !IsExplicitDevOrMockEnv() { dec, dErr := decryptSnapshot(body) if dErr != nil { log.Printf("Failed to decrypt square_request_snapshot for reused till sale %s: %v", tillSaleID, dErr) return } body = dec } var req square.CreatePaymentReq if err := json.Unmarshal(body, &req); err != nil { log.Printf("Failed to parse square_request_snapshot for reused till sale %s: %v", tillSaleID, err) return } req.SourceID = newSource updated, err := json.Marshal(req) if err != nil { log.Printf("Failed to re-marshal square_request_snapshot for reused till sale %s: %v", tillSaleID, err) return } stored := updated if !IsExplicitDevOrMockEnv() { enc, eErr := encryptSnapshot(updated) if eErr != nil { log.Printf("Failed to encrypt square_request_snapshot for reused till sale %s: %v", tillSaleID, eErr) return } stored = enc } if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); err != nil { log.Printf("Failed to refresh square_request_snapshot for reused till sale %s: %v", tillSaleID, err) } } // errTillSaleNotPending: the claim-first gating UPDATE matched zero rows, so // the sale is no longer 'pending' and the gift card must be left untouched. var errTillSaleNotPending = errors.New("till sale is not pending") // revertGiftCardFunding is the package-internal wrapper over the shared // RevertGiftCardFunding clawback helper (giftcard_clawback.go). The sweep and // the till handler both call it so every clawback path runs the single // money-reversal implementation. func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error { return RevertGiftCardFunding(ctx, action, giftCardID, amount, redeemToUserID, tillSaleID) } func CreateTillSale(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Defense-in-depth admin check (S-1) — a till sale moves money (charges a // card / funds a gift card), so it must stay admin-only. if !isAdminRequest(r) { http.Error(w, "Admin access required", http.StatusForbidden) return } adminID, _ := ctx.Value(mw.UserIDKey).(string) var req TillSaleRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.ItemType != "gift_card" { http.Error(w, "Unsupported item type", http.StatusBadRequest) return } if req.Action != "create" && req.Action != "topup" { http.Error(w, "Action must be 'create' or 'topup'", http.StatusBadRequest) return } if req.Amount <= 0 { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } // C2: apply the shared £10,000 cap used by every other money entry point // (validators.ValidateAmount). The till amount is pounds float64; validate // the effective pence figure exactly as the Square charge amount below is // derived (math.Round(req.Amount * 100)). amountPence := int64(math.Round(req.Amount * 100)) // Gift-card creates/topups are additionally capped at £250 per transaction // (owner decision — the shared maxAdminGiftCardTransactionPence from // giftcard_limits.go, the single source of the £250 cap). Both the create // and topup branches fund the card from req.Amount and flow through this // single validation point, so one guard covers both. if req.ItemType == "gift_card" && amountPence > maxAdminGiftCardTransactionPence { http.Error(w, "Gift card amount exceeds maximum (£250)", http.StatusBadRequest) return } if err := ValidateAmount(amountPence); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, err.Error(), http.StatusBadRequest) return } if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" && req.PaymentMethod != "on_the_house" { http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', 'online_square', or 'on_the_house'", http.StatusBadRequest) return } if req.PaymentMethod == "saved_card" && (req.UserSavedCardID == nil || *req.UserSavedCardID == "") { http.Error(w, "user_saved_card_id is required when payment method is saved_card", http.StatusBadRequest) return } if req.PaymentMethod == "online_square" && req.CardToken == "" { http.Error(w, "card_token is required when payment method is online_square — use a Square Web Payments nonce", http.StatusBadRequest) return } if req.Action == "topup" && (req.GiftCardID == nil || *req.GiftCardID == "") { http.Error(w, "gift_card_id is required for topup", http.StatusBadRequest) return } if req.Action == "topup" && req.RedeemToUserID != nil && *req.RedeemToUserID != "" { // Redeem is create-only. Topping up an unredeemed card (which can still // carry residual balance) and then redeeming would zero amount_remaining // while crediting the user only the top-up amount — destroying money. // Fail loudly rather than silently ignoring the redeem. http.Error(w, "Cannot redeem a top-up to a user account", http.StatusBadRequest) return } if err := ValidateVerificationToken(req.VerificationToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // C1: when the client supplies no idempotency key, compute a DETERMINISTIC // base key from the canonical request fields instead of a fresh // uniqueChargeKey. A lost-response retry re-derives the SAME base key (the // card nonce is deliberately excluded — it changes between retries), so the // idempotency lookup below reuses the pending till_sale row and Square // dedups on the key: ONE charge, ONE gift-card funding instead of the old // random fallback's second charge + second funding. The base key is also the // advisory-lock key, so concurrent same-sale retries serialize on it; the // FINAL key is resolved by a slot scan under the lock (F5) so two genuinely // DISTINCT keyless sales (same admin, same amount, no user / gift card) // diverge onto different keys instead of the second being swallowed by the // first's dedup. var derivedBaseKey string if req.IdempotencyKey == "" { switch req.PaymentMethod { case "saved_card", "online_square", "card_machine": // Square-charging methods: deterministic base so a lost-response retry // re-derives the SAME base and reuses the pending row — ONE charge, // ONE gift-card funding instead of a second charge. The slot scan // under the lock finalizes the candidate (see // scanTillIdempotencyKeySlot). derivedBaseKey = deriveTillIdempotencyKey(req, adminID) req.IdempotencyKey = derivedBaseKey default: // cash / on_the_house: no Square charge, so a unique key per request is // required — two identical keyless cash gift-card creations are // distinct sales and must both succeed (see // TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed). req.IdempotencyKey = uniqueChargeKey("till-") } } // Serialize till-sale attempts on the idempotency key to prevent concurrent // same-key requests from both passing the idempotency check, both funding // the gift card, and one dying on the till_sales idempotency_key UNIQUE // constraint after the funding already committed. Mirrors the gift-card // advisory-lock pattern (giftcards.go). Lock is keyed on the idempotency // key so distinct sales are unaffected; a missing client key falls back to // the DETERMINISTIC derived key above, so a retry of the same logical sale // serializes on the SAME lock as the original attempt. Bounded try-lock // (R6) so a contended lock never blocks the pool across the Square // round-trip. lockKey := req.IdempotencyKey pinConn, err := db.Conn.Acquire(ctx) if err != nil { log.Printf("Failed to acquire connection for till-sale lock: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer pinConn.Release() lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:till:"+lockKey) if err != nil { log.Printf("Failed to acquire till-sale serialization lock for %s: %v", lockKey, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !lockOK { log.Printf("Till-sale serialization lock for %s not acquired within bound — a sale is already in progress", lockKey) http.Error(w, "Sale in progress, try again", http.StatusConflict) return } defer releasePaymentLock(pinConn, "crussell:till:"+lockKey) // F5: under the advisory lock, resolve the final deterministic key for a // keyless Square-method sale. The slot scan advances past COMPLETED/FAILED // sales occupying a candidate key, so two identical keyless walk-in creates // never collapse onto one key; a PENDING sale never occupies a slot, so a // lost-response retry re-derives the same candidate and the idempotency // lookup below reuses the pending row. Running under the lock makes the // scan-and-insert atomic for concurrent identical creates. if derivedBaseKey != "" { candidate, scanErr := scanTillIdempotencyKeySlot(ctx, derivedBaseKey) if scanErr != nil { log.Printf("Failed to resolve till-sale idempotency key for %s: %v", derivedBaseKey, scanErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } req.IdempotencyKey = candidate } // Idempotency handling. A 'completed' sale is a dedup (return it). A // 'pending' sale means the previous Square charge failed — the gift card // was already funded in the committed transaction, so re-attempt the // Square charge (Square dedups on the same key) and complete the sale. // Mirrors the tip/gift-card pending-reuse pattern. var existingPendingID string var existingPendingGiftCard string var existingPendingMethod string if req.IdempotencyKey != "" { var existingID, existingStatus, existingItemID, existingStoredKey string var existingTotal float64 var existingItemType, existingPaymentMethod string err := db.Conn.QueryRow(ctx, `SELECT id, status, item_id, total_amount, item_type, payment_method, idempotency_key FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus, &existingItemID, &existingTotal, &existingItemType, &existingPaymentMethod, &existingStoredKey) if err == nil { if existingStatus == "completed" { // C4-class: a same-key retry of a COMPLETED sale must report the // STORED sale (amount, item type, method), never the freshly // requested fields — echoing req.Amount on a different-amount // retry misleads the till into believing the new amount was // sold. Guard the amount exactly as the pending path below does: // a different amount is a genuinely different sale, not a retry. if int64(math.Round(existingTotal*100)) != int64(math.Round(req.Amount*100)) { log.Printf("Till-sale dedup amount mismatch: completed record %s has %.2f, request has %.2f", existingID, existingTotal, req.Amount) http.Error(w, "Amount does not match the completed till sale", http.StatusBadRequest) return } if err := json.NewEncoder(w).Encode(TillSaleResponse{ ID: existingID, ItemType: existingItemType, TotalAmount: existingTotal, PaymentMethod: existingPaymentMethod, Status: "completed", }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } if existingStatus == "pending" { // Guard the amount: a retry with a different amount must not // reuse the pending sale — the gift card was already funded at // the old amount, so charging the new amount to Square would // leave the card funded at the wrong value. if int64(math.Round(existingTotal*100)) != int64(math.Round(req.Amount*100)) { log.Printf("Till-sale retry amount mismatch: pending record %s has %.2f, request has %.2f", existingID, existingTotal, req.Amount) http.Error(w, "Amount does not match the pending till sale", http.StatusBadRequest) return } existingPendingID = existingID existingPendingGiftCard = existingItemID existingPendingMethod = existingPaymentMethod // F5: reuse the row's STORED idempotency key rather than a // re-derived one — the stored key is what Square charged (and // dedups on), so a retry must replay it verbatim. if existingStoredKey != "" { req.IdempotencyKey = existingStoredKey } } if existingStatus == "failed" { // Swept as stale (>24h) or definitively rejected — a retry would // risk a second Square charge. Reject cleanly (R2). log.Printf("Till-sale retry rejected: record %s was marked failed", existingID) http.Error(w, "This till sale previously failed and can no longer be retried", http.StatusConflict) return } } } // Defense-in-depth fallback: resolve a pending TOP-UP by the gift card // itself, not just the idempotency key. A lost-response retry carrying a // fresh/absent key (an old frontend, or a key regenerated for a changed // cart) must still reuse the pending till_sale row and adopt its STORED // idempotency key — otherwise the retry would issue a second Square charge // and fund the card twice. Only the row's own key may be charged: adopting // it makes Square dedup the retry against the original charge. An amount // mismatch proves this is a genuinely different sale (same card, new // amount), so the pending row is left untouched and the request proceeds // as a new charge below. 'create' cannot be resolved this way (the request // carries no gift-card id — the card is created by the first attempt), so // create retries rely on the client-supplied key, which the till frontend // caches per cart line. if existingPendingID == "" && req.Action == "topup" && req.GiftCardID != nil && *req.GiftCardID != "" { cardID := validators.NormalizeGiftCardCode(*req.GiftCardID) var existingID, existingItemID, existingKey string var existingTotal float64 err := db.Conn.QueryRow(ctx, ` SELECT id, item_id, total_amount, idempotency_key FROM till_sales WHERE item_id = $1 AND status = 'pending' ORDER BY created_at DESC LIMIT 1 `, cardID).Scan(&existingID, &existingItemID, &existingTotal, &existingKey) if err == nil && existingKey != "" { if int64(math.Round(existingTotal*100)) == int64(math.Round(req.Amount*100)) { log.Printf("Till-sale retry resolved by gift card %s: reusing pending sale %s with stored idempotency key", cardID, existingID) existingPendingID = existingID existingPendingGiftCard = existingItemID req.IdempotencyKey = existingKey } else { log.Printf("Till-sale retry by gift card %s skipped: pending sale %s has %.2f, request has %.2f — treated as a new sale", cardID, existingID, existingTotal, req.Amount) } } } service := NewPaymentService() // squarePaymentID/squareCheckoutID are set inside the payment-method switch // below but must be declared before the deferred cancel-on-error closure // (registered at tx creation) so it can read the live checkout id. var squarePaymentID *string var squareCheckoutID *string tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Cancel-on-error for an orphaned live checkout: a card_machine checkout // created at Square inside this tx is cancelled if the request fails before // commit (e.g. the till_sales INSERT dies on the idempotency_key UNIQUE // constraint) — otherwise the checkout stays live at the terminal as an // invisible, untracked charge. The pending-retry reuse branch never sets // checkoutCreated (it reuses a live checkout and must never cancel it), // and committed=true after a successful commit means a committed sale's // checkout is never cancelled here. var checkoutCreated bool committed := false defer func() { if !committed && checkoutCreated && squareCheckoutID != nil { if cErr := SquareClient.CancelCheckout(context.Background(), *squareCheckoutID); cErr != nil { log.Printf("CRITICAL: till checkout %s created at Square but request failed pre-commit; cancel failed: %v — MANUAL RECONCILIATION REQUIRED", *squareCheckoutID, cErr) } } if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // Pending-retry: the gift card was already created and funded in the prior // committed transaction, so skip the create/top-up and sale-insert blocks. var giftCardID string if existingPendingID != "" { giftCardID = existingPendingGiftCard } else { expiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, tx) if expiryErr != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr) expiryMonths = defaultGiftCardExpiryMonths } if req.Action == "create" { var purchaseVoucherType string err = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if err != nil { log.Printf("Failed to query voucher type: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } // HMRC VAT Notice 700/7: salon-only gift cards are SPV by // definition, so the EFFECTIVE type is written even when the // stored setting is 'MPV' — recording raw 'MPV' here would make // the redemption path defer VAT a second time (VAT is already // collected at sale via the GetVATConfig SPV override). purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType) err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3) RETURNING id `, req.Amount, adminID, purchaseVoucherType, expiryMonths).Scan(&giftCardID) if err != nil { log.Printf("Failed to create gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'purchase', $2, 'till_sale', NULL, $3, NULL) `, giftCardID, req.Amount, req.UserID) if err != nil { log.Printf("Failed to create gift_card_transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // If the gift card should be immediately redeemed to a user's account balance // (e.g. admin selected "add to account" rather than "generate gift code"). // Runs only on the FIRST attempt of a create — a pending retry reuses the // already-funded gift card and must never re-run this, or the user's balance // would be credited a second time (money loss to the business). if req.RedeemToUserID != nil && *req.RedeemToUserID != "" { _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = 0, redeemed_at = NOW(), redeemed_by = $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, *req.RedeemToUserID, giftCardID, expiryMonths) if err != nil { log.Printf("Failed to redeem gift card to user account: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, *req.RedeemToUserID, req.Amount) if err != nil { log.Printf("Failed to update user gift card balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } } else { cardID := validators.NormalizeGiftCardCode(*req.GiftCardID) var redeemedBy sql.NullString err = tx.QueryRow(ctx, "SELECT redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Gift card not found", http.StatusNotFound) return } log.Printf("Failed to check gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if redeemedBy.Valid { http.Error(w, "Cannot top up a card that has been redeemed to an account", http.StatusBadRequest) return } var isInventory bool var previousTotal float64 err = tx.QueryRow(ctx, `SELECT is_inventory, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&isInventory, &previousTotal) if err != nil { log.Printf("Failed to check gift card state: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET total_funds_added = total_funds_added + $1, amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, req.Amount, cardID, expiryMonths) if err != nil { log.Printf("Failed to top up gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } transactionType := "topup" var notes *string if isInventory && previousTotal == 0 { transactionType = "purchase" n := "first top-up on inventory card" notes = &n } _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, $2, $3, 'till_sale', NULL, $4, $5) `, cardID, transactionType, req.Amount, req.UserID, notes) if err != nil { log.Printf("Failed to create gift_card_transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } giftCardID = cardID } } penceAmount := int64(math.Round(req.Amount * 100)) var saleStatus string var dbPaymentMethod string // Post-commit Square payment tracking: saved_card and online_square // call Square AFTER the DB transaction commits, so a tx failure never // leaves a Square charge with no DB record. var needsSquarePayment bool var savedCardSqCardID string var savedCardCustomerID string // tillSquareSourceID records the exact source_id sent to CreatePayment // (the ccof: card id or the cnon: nonce) so the pending row stores it for // the sweep's identical-body replay. var tillSquareSourceID string // Pending-retry for card_machine: the original Square checkout may still be // live at the terminal. If the pending till_sales row already recorded a // square_checkout_id, reuse it instead of creating a second checkout — a // fresh checkout would orphan the original, which can still complete and // become an untracked charge. var existingPendingCheckoutID string if existingPendingID != "" { err = tx.QueryRow(ctx, `SELECT COALESCE(square_checkout_id, ''), payment_method FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID, &existingPendingMethod) if err != nil { log.Printf("Failed to query existing pending sale checkout: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // Method-switch guard on pending-retry (F1): switching the payment method of // a pending sale whose Square charge outcome is unknown must never mint a // NEW checkout or charge that cannot dedup against the original. A pending // card_machine checkout (whether its id is still stored, or was lost with // the lost response) may still be live at the terminal, and a pending // online_square/saved_card charge may have landed — retrying either as a // different method risks TWO charges for one card. Reject the switch // outright: the sale must be completed or refunded first. online_square ↔ // saved_card retries are the one allowed cross-method case: both charge via // Square CreatePayment under the SAME idempotency key, so Square dedups the // retry onto the original charge (no second charge). // NOTE: a future provisional "tmp-" till_sales row (pre-Square, as the // booking path uses) must be treated as "no real checkout" by BOTH this // reuse and the method-switch guard — a "tmp-" id is provably not live at // Square. if existingPendingID != "" { cardMachineInProgress := existingPendingCheckoutID != "" || existingPendingMethod == "in_person_card" if cardMachineInProgress && req.PaymentMethod != "card_machine" { // Pending card-machine sale retried by any other method: the original // terminal checkout may still complete — switching would let the // customer be charged at the terminal AND by the new method. The // terminal checkout cannot be cancelled via this API. log.Printf("Till-sale retry rejected: pending sale %s is tied to a card-machine checkout, cannot switch method to %s", existingPendingID, req.PaymentMethod) http.Error(w, "This pending sale is tied to a card-machine checkout — complete or refund it first, then retry with card machine payment", http.StatusConflict) return } if req.PaymentMethod == "card_machine" && !cardMachineInProgress { // F1: a pending online_square/saved_card charge (outcome unknown) // retried as card_machine would mint a NEW terminal checkout at // Square — CreateCheckout cannot dedup against the original // CreatePayment charge, so the original may land on top of the new // terminal charge (double charge for one card). log.Printf("Till-sale retry rejected: pending sale %s has payment method %s, cannot retry as card_machine (original charge outcome unknown)", existingPendingID, existingPendingMethod) http.Error(w, "This pending sale is already in progress on a different payment method — complete or refund it first", http.StatusConflict) return } } // Reconcile-or-reject on a cash/on_the_house retry of a pending card sale. // The gift card was funded by a Square charge whose outcome is unknown; a // definitive answer from Square decides whether cash may be taken. This // MUST run before the method switch below so a lost-response sale (no // square_payment_id) forces the original card-method retry instead of // taking cash on top of a charge that may have landed. if existingPendingID != "" && (req.PaymentMethod == "cash" || req.PaymentMethod == "on_the_house") { var sqPaymentID string if err := tx.QueryRow(ctx, `SELECT COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1`, existingPendingID).Scan(&sqPaymentID); err != nil { log.Printf("Failed to query pending sale %s square_payment_id: %v", existingPendingID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if sqPaymentID == "" { // Lost response: the charge may have landed at Square and cannot // be looked up. Taking cash risks a double payment. Force the // original card-method retry — Square dedups on the same // idempotency key and resolves the lost response. http.Error(w, "Original card charge outcome is unknown — retry with the original card method", http.StatusConflict) return } pr, gErr := SquareClient.GetPayment(ctx, sqPaymentID) switch { case gErr == nil && pr.Status == "COMPLETED": // Money landed. Rescue the sale, refuse the cash. if _, upErr := db.Conn.Exec(ctx, `UPDATE till_sales SET status='completed', updated_at=NOW() WHERE id=$1 AND status='pending'`, existingPendingID); upErr != nil { log.Printf("CRITICAL: card payment %s for pending till sale %s is COMPLETED but the rescue UPDATE failed: %v — MANUAL RECONCILIATION REQUIRED", sqPaymentID, existingPendingID, upErr) } http.Error(w, "This sale was already paid by card — do not take cash", http.StatusConflict) return case squarePaymentErrorIsNotFound(gErr), gErr == nil && (pr.Status == "FAILED" || pr.Status == "CANCELED"): // Provably no money landed — cash is safe; fall through. case gErr != nil: http.Error(w, "Unable to confirm the card payment status — try again", http.StatusServiceUnavailable) return default: http.Error(w, "Card charge status not definitively failed — do not take cash", http.StatusConflict) return } } // cardUserID is the owner of the charged saved card (till sales are not // user-scoped). Resolved in the saved_card case below; hoisted here because // the post-charge 2FA consumption + audit after the Square call need it. var cardUserID sql.NullString switch req.PaymentMethod { case "cash": saleStatus = "completed" dbPaymentMethod = "cash" case "saved_card": dbPaymentMethod = "online_square" if req.UserID != nil && *req.UserID != "" { _, err = service.GetCardByIDQuerier(ctx, tx, *req.UserSavedCardID, *req.UserID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Saved card not found", http.StatusNotFound) return } log.Printf("Failed to verify saved card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // The till path is not user-scoped, so fetch the card's owner along with // the charge details — the owner is needed to lazily provision a Square // customer if the row predates P14 (R6). cardUserID is declared at // function scope (before the switch) because the post-charge 2FA // consumption + audit need it after the Square call. err = tx.QueryRow(ctx, ` SELECT user_id, COALESCE(square_card_id, ''), COALESCE(square_customer_id, '') FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL `, *req.UserSavedCardID).Scan(&cardUserID, &savedCardSqCardID, &savedCardCustomerID) if err != nil { log.Printf("Failed to get saved card details: %v", err) http.Error(w, "Card not found", http.StatusNotFound) return } // A ccof: source can NEVER be charged without a CustomerID — Square // rejects the payment. Legacy pre-P14 rows have an empty // square_customer_id; provision + persist for the card's owner BEFORE // charging (R6). if savedCardCustomerID == "" { if !cardUserID.Valid { http.Error(w, "Saved card has no owner and cannot be charged", http.StatusBadRequest) return } provisioned, provErr := service.EnsureSquareCustomer(ctx, cardUserID.String) if provErr != nil { log.Printf("Failed to provision Square customer for saved card %s (user %s): %v", *req.UserSavedCardID, cardUserID.String, provErr) http.Error(w, "Failed to process card", http.StatusInternalServerError) return } savedCardCustomerID = provisioned if _, upErr := db.Conn.Exec(ctx, ` UPDATE user_saved_cards SET square_customer_id = $1 WHERE id = $2 `, provisioned, *req.UserSavedCardID); upErr != nil { log.Printf("Failed to persist Square customer id on saved card %s (non-fatal): %v", *req.UserSavedCardID, upErr) } } // 2FA gating (C5): charging a customer's saved card requires 2FA when // the feature is enforced. consume=!reuse (LOW 6a): a FRESH charge // verifies WITH consumption — the code is single-use at the gate, // closing the TOCTOU where a verified-but-unconsumed code could // authorize a second charge within its lifetime — and a failed Square // charge re-issues a fresh code (reissueTwoFACodeAfterFailedCharge // below). A pending-reuse retry (existingPendingID != "") verifies // WITHOUT consuming: the code was re-issued for exactly this retry and // the post-charge success path below (ConsumePendingCode) burns it on // terminal success, so a retry that fails again keeps its code for one // more attempt. if cardUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode, existingPendingID == "") { return } tillSquareSourceID = savedCardSqCardID saleStatus = "pending" needsSquarePayment = true case "card_machine": dbPaymentMethod = "in_person_card" if existingPendingCheckoutID != "" { // Pending retry — reuse the checkout already created for this sale // instead of creating a second one. The original checkout may still // be live at the terminal; a fresh checkout would orphan it into an // untracked charge. squareCheckoutID = &existingPendingCheckoutID saleStatus = "pending" } else { // F1: never mint a NEW terminal checkout while a pending charge for // the same logical sale exists. existingPendingID (resolved by the // idempotency-key lookup or the gift-card fallback) IS such a // pending charge — a pending card_machine sale with no stored // checkout id means the prior response was lost and the original // checkout may still be live at the terminal; a fresh checkout // would orphan it into a second, untracked charge. if existingPendingID != "" { log.Printf("Till-sale retry rejected: pending sale %s has no stored checkout id; refusing to create a second terminal checkout (original may still be live)", existingPendingID) http.Error(w, "This pending sale has a card-machine payment in progress with an unknown checkout — complete or refund it first", http.StatusConflict) return } // Defense-in-depth: a card_machine top-up on a card that already // carries a pending till_sale (reached only when the earlier // idempotency / gift-card resolution treated this as a NEW sale, // e.g. a different amount) must not mint a terminal checkout while // that pending sale's charge is still unresolved. if req.Action == "topup" && req.GiftCardID != nil && *req.GiftCardID != "" { var pendingOnCard string if err := tx.QueryRow(ctx, ` SELECT id FROM till_sales WHERE item_id = $1 AND status = 'pending' ORDER BY created_at DESC LIMIT 1 `, validators.NormalizeGiftCardCode(*req.GiftCardID)).Scan(&pendingOnCard); err == nil && pendingOnCard != "" { log.Printf("Till-sale card_machine create rejected: gift card %s already has a pending till sale %s", *req.GiftCardID, pendingOnCard) http.Error(w, "This gift card already has a pending sale — complete or refund it first", http.StatusConflict) return } } checkoutReq := square.CreateCheckoutReq{ Amount: penceAmount, Currency: "GBP", IdempotencyKey: req.IdempotencyKey, ReferenceID: giftCardID, AllowTipping: false, } checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq) if err != nil { log.Printf("Failed to create Square checkout: %v", err) http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError) return } squareCheckoutID = &checkout.ID checkoutCreated = true saleStatus = "pending" } case "online_square": dbPaymentMethod = "online_square" tillSquareSourceID = req.CardToken saleStatus = "pending" needsSquarePayment = true case "on_the_house": saleStatus = "completed" dbPaymentMethod = "on_the_house" } desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount) var tillSaleID string if existingPendingID != "" { // Reusing the pending sale row from a failed prior attempt — the sale // was already inserted, so skip the insert and reuse its ID. Refresh // the stored source: this attempt may charge a different token than the // failed attempt (one-time cnon: nonces are spent), and the sweep // replays the charge from the stored source. tillSaleID = existingPendingID if tillSquareSourceID != "" { if _, srcErr := tx.Exec(ctx, `UPDATE till_sales SET square_source_id = $1 WHERE id = $2`, tillSquareSourceID, tillSaleID); srcErr != nil { log.Printf("Failed to update square_source_id on reused till sale %s: %v", tillSaleID, srcErr) } // B6: the sweep replays the stored square_request_snapshot VERBATIM, // so the snapshot's source_id must stay in lock-step with the // refreshed square_source_id — a snapshot whose source differs from // the row's source returns IDEMPOTENCY_KEY_REUSED at Square and // strands the row pending forever. Refresh both columns in this same // transaction so they never diverge. refreshTillSnapshotSource(ctx, tx, tillSaleID, tillSquareSourceID) } } else { err = tx.QueryRow(ctx, ` INSERT INTO till_sales ( item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, user_id, user_saved_card_id, square_payment_id, square_checkout_id, idempotency_key, notes, created_by, created_at, updated_at, square_source_id ) VALUES ($1, $2, $3, 1, $4, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW(), $14) RETURNING id `, req.ItemType, giftCardID, desc, req.Amount, dbPaymentMethod, saleStatus, req.UserID, req.UserSavedCardID, squarePaymentID, squareCheckoutID, req.IdempotencyKey, "Admin till sale: "+req.Action+" gift card", adminID, tillSquareSourceID, ).Scan(&tillSaleID) if err != nil { log.Printf("Failed to insert till sale: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` UPDATE gift_card_transactions SET reference_id = $1 WHERE gift_card_id = $2 AND reference_id IS NULL AND created_at > NOW() - INTERVAL '5 seconds' `, tillSaleID, giftCardID) if err != nil { log.Printf("Failed to update gift_card_transactions reference: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if req.PaymentMethod != "on_the_house" && (saleStatus == "completed" || needsSquarePayment) { vatCfg, vatErr := GetVATConfig(ctx, tx) if vatErr == nil && vatAppliesToVoucher(vatCfg) { if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", tillSaleID, vatCfg.DefaultVATRate); vatExecErr != nil { log.Printf("Failed to apply VAT to till sale %s: %v", tillSaleID, vatExecErr) } } } } // Commit the (possibly nested) transaction. In the pending-reuse path it is // empty, but the commit releases the savepoint in the test harness so the // deferred rollback does not undo the later status UPDATE. if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit till sale transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } committed = true // Step 2: DB transaction committed — safe to call Square now. // If Square fails, the till_sale record stays 'pending' for manual retry. // Resolve buyer email for Square receipt delivery (non-fatal if missing). var buyerEmail string if req.UserID != nil && *req.UserID != "" { if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, *req.UserID).Scan(&buyerEmail); err != nil { log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", *req.UserID, err) } } if buyerEmail == "" { if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, adminID).Scan(&buyerEmail); err != nil { log.Printf("[SQUARE-PROD] Failed to resolve admin email for user %s: %v (Square receipts will not be emailed)", adminID, err) } } if needsSquarePayment { var paymentResult *square.PaymentResult var squareErr error var verificationToken string if req.VerificationToken != nil { verificationToken = *req.VerificationToken } if req.PaymentMethod == "saved_card" { paymentReq := square.CreatePaymentReq{ Amount: penceAmount, Currency: "GBP", SourceID: savedCardSqCardID, CustomerID: savedCardCustomerID, IdempotencyKey: req.IdempotencyKey, Note: "Gift Card " + req.Action, BuyerEmail: buyerEmail, // C3: a saved-card (ccof) charge is customer-initiated — Square // requires customer_details on stored-credential payments, and // omitting it can fail/quietly strip the charge. CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true}, // Forward the 3DS/SCA verification token when the request // carries one (the online_square branch already did; the // saved-card branch did not). VerificationToken: verificationToken, } // M1: store the verbatim request JSON so the sweep can replay the // charge with an IDENTICAL body under the same key — Square // compares the whole request on key reuse, and a reconstructed body // returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. // The snapshot holds PII (buyer email + ccof token), so it is // encrypted at rest via encryptSnapshot (plaintext in dev/mock). // // The write is INTENTIONALLY unconditional (WHERE id = $2, no // snapshot-is-null guard like the booking/tip/terminal flows): the // pending-reuse branch above already refreshed square_request_snapshot // in the SAME transaction as the square_source_id refresh // (refreshTillSnapshotSource, B6), and this post-commit write stores // the fresh full body for THIS attempt. A guard would wrongly skip // this write on the reuse path when the in-tx refresh failed // best-effort — do NOT "fix" it into the guarded form. if snap, mErr := json.Marshal(paymentReq); mErr != nil { log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr) } else if stored, eErr := encryptSnapshot(snap); eErr != nil { log.Printf("Failed to encrypt square_request_snapshot for till sale %s: %v", tillSaleID, eErr) } else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); sErr != nil { log.Printf("Failed to store square_request_snapshot for till sale %s: %v", tillSaleID, sErr) } paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq) } else if req.PaymentMethod == "online_square" { // PCI-DSS: raw PANs are never accepted. The admin till must supply a // Square Web Payments nonce (cnon:xxx). if req.CardToken == "" { log.Printf("online_square till sale missing card_token for gift card %s", giftCardID) http.Error(w, "card_token is required for online_square payment — use a Square Web Payments nonce", http.StatusBadRequest) return } // Charge the cnon: nonce DIRECTLY (R6). The old code tokenized the // till card via CreateCardOnFile under a synthetic "till-" // reference and charged the resulting ccof: id — but that card was // never saved or re-listed anywhere, so the CreateCardOnFile call was // pure overhead AND would have charged a card-on-file source without a // customer (Square rejects ccof without CustomerID). A cnon: nonce // needs neither card-on-file nor customer. paymentReq := square.CreatePaymentReq{ Amount: penceAmount, Currency: "GBP", SourceID: req.CardToken, IdempotencyKey: req.IdempotencyKey, Note: "Gift Card " + req.Action, BuyerEmail: buyerEmail, VerificationToken: verificationToken, } // M1: store the verbatim request JSON so the sweep can replay the // charge with an IDENTICAL body under the same key — Square // compares the whole request on key reuse, and a reconstructed body // returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. // // The write is INTENTIONALLY unconditional (WHERE id = $2, no // snapshot-is-null guard like the booking/tip/terminal flows): the // pending-reuse branch above already refreshed square_request_snapshot // in the SAME transaction as the square_source_id refresh // (refreshTillSnapshotSource, B6), and this post-commit write stores // the fresh full body for THIS attempt. A guard would wrongly skip // this write on the reuse path when the in-tx refresh failed // best-effort — do NOT "fix" it into the guarded form. if snap, mErr := json.Marshal(paymentReq); mErr != nil { log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr) } else if stored, eErr := encryptSnapshot(snap); eErr != nil { log.Printf("Failed to encrypt square_request_snapshot for till sale %s: %v", tillSaleID, eErr) } else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); sErr != nil { log.Printf("Failed to store square_request_snapshot for till sale %s: %v", tillSaleID, sErr) } paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq) } if squareErr != nil { log.Printf("Failed to process payment: %v", squareErr) // The gift card was already created/top-upped in the committed DB // transaction before this Square charge. On a DEFINITIVE rejection // (declined/expired) the customer was never charged, so the funded // card must be clawed back — otherwise it stays funded forever (the // stale-pending sweep only marks the sale failed, it never reverts // the card). Ambiguous failures (network/5xx) leave the sale pending // so a late retry can still complete it — the card must stay funded. // The clawback also runs on a pending-retry: the card was funded by // a PRIOR request of this same sale (same idempotency key), and the // retry's definitive failure proves this sale's charge can never // complete — reverting the funding is required, not "the prior // attempt's responsibility". if isDefinitiveChargeFailure(squareErr) { if revErr := revertGiftCardFunding(ctx, req.Action, giftCardID, req.Amount, req.RedeemToUserID, tillSaleID); revErr != nil { log.Printf("CRITICAL: till sale %s charge definitively failed (%v) but gift-card clawback also failed: %v — MANUAL RECONCILIATION REQUIRED: gift card %s may still be funded", tillSaleID, squareErr, revErr, giftCardID) } } // The gate consumed the 2FA code for a FRESH saved-card charge — // re-issue so the same-key retry has a live code to verify. A // pending-reuse retry verified without consuming at the gate, so // its code survives for one more attempt. if req.PaymentMethod == "saved_card" && cardUserID.Valid && existingPendingID == "" { reissueTwoFACodeAfterFailedCharge(ctx, cardUserID.String) } // 402 only for definitive declines; ambiguous transport/5xx must be // 503 so the pending sale stays resumable on a same-key retry // (M3). The clawback decision above stays keyed on // isDefinitiveChargeFailure, unchanged. http.Error(w, "Payment failed", chargeFailureStatus(squareErr)) return } // Square succeeded — update the till_sale record. The status='pending' // guard (claim-first) prevents resurrecting a sale the stale-pending // sweep / gift-card clawback resolved to 'failed' while the Square // charge was in flight: money was taken and the card was already // clawed back, so the sale must stay failed (0 rows → CRITICAL below). tillTag, upErr := db.Conn.Exec(ctx, `UPDATE till_sales SET status = 'completed', square_payment_id = $1 WHERE id = $2 AND status = 'pending'`, paymentResult.SquarePayID, tillSaleID, ) if upErr != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but till_sale %s update failed: %v — manual reconciliation required", paymentResult.SquarePayID, tillSaleID, upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if tillTag.RowsAffected() == 0 { // The stale-pending sweep (or a clawback) resolved the sale while // the Square charge was in flight: the customer WAS charged and the // gift card WAS funded, but the row no longer says 'pending'. // Mirroring the non-Square pending-reuse path below, never report // success for a row the DB doesn't agree on. log.Printf("CRITICAL: Square payment %s succeeded but till_sale %s was already resolved (0 rows updated) — charge taken and card funded; MANUAL RECONCILIATION REQUIRED", paymentResult.SquarePayID, tillSaleID) http.Error(w, "internal server error", http.StatusInternalServerError) return } // MEDIUM-2: a saved-card till charge reached its terminal SUCCESS state // — consume the verified 2FA code now (the gate verified without // consuming, so a failed/ambiguous charge did not burn the code and a // same-key retry could reuse it). Best-effort after the completion // write: a consume failure cannot undo the completed sale, it only // leaves the code valid until its 10-minute expiry. if req.PaymentMethod == "saved_card" && cardUserID.Valid { if consErr := twofa.ConsumePendingCode(ctx, db.Conn, cardUserID.String); consErr != nil { log.Printf("CRITICAL: Square payment %s succeeded but consuming the 2FA code for user %s failed: %v — MANUAL RECONCILIATION REQUIRED", paymentResult.SquarePayID, cardUserID.String, consErr) } // MEDIUM-3a: record the admin-initiated saved-card till charge in // admin_audit_log (mirroring giftcards.go's balance_check audit). insertAdminAuditCharge(ctx, adminID, cardUserID.String, "till_saved_card_charge", map[string]any{ "till_sale_id": tillSaleID, "item_type": req.ItemType, "gift_card_id": giftCardID, "amount": req.Amount, "card_last4": paymentResult.CardLast4, "square_payment_id": paymentResult.SquarePayID, }) } saleStatus = "completed" } // Pending-reuse resolved by a non-Square method (cash / on_the_house): the // original sale row was inserted as 'pending' (prior Square attempt failed // or the method was switched after a failed charge). The reused row skips // the INSERT, and no Square payment runs, so the status must be flipped to // 'completed' explicitly — otherwise the row stays pending forever while // the response claims success. if existingPendingID != "" && !needsSquarePayment && req.PaymentMethod != "card_machine" { tag, upErr := db.Conn.Exec(ctx, `UPDATE till_sales SET status = 'completed', updated_at = NOW() WHERE id = $1 AND status = 'pending'`, tillSaleID, ) if upErr != nil { log.Printf("Failed to complete pending till sale %s after non-Square payment: %v", tillSaleID, upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if tag.RowsAffected() == 0 { // The sweep failed (or a clawback reverted the gift card) between // this retry's read and this write — the cash must not be taken. log.Printf("Pending till sale %s was already resolved before the %s retry could complete it", tillSaleID, req.PaymentMethod) http.Error(w, "This sale was already resolved — do not take cash", http.StatusConflict) return } saleStatus = "completed" } w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(TillSaleResponse{ ID: tillSaleID, ItemType: req.ItemType, ItemID: &giftCardID, TotalAmount: req.Amount, PaymentMethod: req.PaymentMethod, Status: saleStatus, CheckoutID: squareCheckoutID, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) { // Defense-in-depth admin check (S-1). if !isAdminRequest(r) { http.Error(w, "Admin access required", http.StatusForbidden) return } checkoutID := chi.URLParam(r, "checkout_id") if checkoutID == "" { http.Error(w, "Checkout ID is required", http.StatusBadRequest) return } var tillSaleID string var currentStatus string err := db.Conn.QueryRow(r.Context(), ` SELECT id, status FROM till_sales WHERE square_checkout_id = $1 `, checkoutID).Scan(&tillSaleID, ¤tStatus) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Till sale not found", http.StatusNotFound) return } log.Printf("Failed to find till sale: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if currentStatus == "completed" { if err := json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } // A sale already swept to 'failed' (e.g. its checkout was cancelled as // stale, or a definitive decline clawed back the card) must never report a // live payment state: the poll would otherwise tell the admin the terminal // is still waiting when the sale can no longer be completed. Fail loudly so // a stale checkout that later resolves at Square is surfaced for manual // reconciliation instead of silently confusing the operator. if currentStatus == "failed" { log.Printf("Till sale %s is already failed but checkout %s is still being polled — refusing to report a live state", tillSaleID, checkoutID) http.Error(w, "Till sale already failed — manual reconciliation required", http.StatusNotFound) return } paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID) if err != nil { if errors.Is(err, square.ErrCheckoutPending) { if err := json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } log.Printf("Failed to get checkout status: %v", err) http.Error(w, "Failed to get checkout status", http.StatusInternalServerError) return } if paymentResult.Status == "COMPLETED" { // Serialize terminal-completion records per till sale — the one payment // writer that previously lacked the advisory-lock pattern every other // path uses. Two concurrent polls of the same checkout could both run // the UPDATE + VAT (idempotent today, but a double-apply is a latent // bug). Lock on the till-sale id so only one goroutine completes it. // Bounded try-lock (R6) so a contended lock never blocks the pool. pinConn, err := db.Conn.Acquire(r.Context()) if err != nil { log.Printf("Failed to acquire connection for till-completion lock: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer pinConn.Release() lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:tillcomplete:"+tillSaleID) if err != nil { log.Printf("Failed to acquire till-completion serialization lock for %s: %v", tillSaleID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !lockOK { log.Printf("Till-completion serialization lock for %s not acquired within bound — a poll is already completing this sale", tillSaleID) http.Error(w, "Payment in progress, try again", http.StatusConflict) return } defer releasePaymentLock(pinConn, "crussell:tillcomplete:"+tillSaleID) tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() tag, err := tx.Exec(r.Context(), ` UPDATE till_sales SET status = 'completed', square_payment_id = $1, updated_at = NOW() WHERE id = $2 AND status = 'pending' `, paymentResult.SquarePayID, tillSaleID) if err != nil { log.Printf("Failed to update till sale: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if tag.RowsAffected() == 0 { // The sweep already failed this sale (or a clawback reverted the // gift card) — completing it now would resurrect a sale whose card // no longer exists. Fail loudly for manual reconciliation. log.Printf("CRITICAL: checkout %s reports COMPLETED but till sale %s is no longer pending — refusing to complete; MANUAL RECONCILIATION REQUIRED", checkoutID, tillSaleID) http.Error(w, "Till sale no longer pending — manual reconciliation required", http.StatusNotFound) return } ApplyVATToTillSale(r.Context(), tx, tillSaleID) if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", PaymentID: tillSaleID, Amount: paymentResult.Amount, CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } if err := json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } }