package payments import ( "context" "crypto/rand" "database/sql" "encoding/json" "errors" "fmt" "log" "log/slog" "math" "net/http" "strings" "crussell/db" "crussell/internal/square" "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 string `json:"idempotency_key,omitempty"` CardToken string `json:"card_token,omitempty"` RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` VerificationToken *string `json:"verification_token,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"` } // uniqueTillKey generates a unique idempotency key for till sales where the // client did not supply one. Dedup of retries is handled by the client-supplied // key (the frontend sends a UUID); this fallback only needs to be unique so it // never collides with the till_sales idempotency_key UNIQUE constraint. // Deliberately NOT derived from request fields — two legitimate identical // sales (e.g. two £50 cash gift-card creations) would hash to the same key. func uniqueTillKey() string { return "till-" + rand.Text() } // definitivePaymentDeclineCodes are Square payment error codes meaning the // card charge can never succeed (declined / expired / not supported). They are // matched against the formatted Square API error 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. 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", } // isDefinitiveChargeFailure reports whether a Square CreatePayment error is a // definitive business rejection (declined/expired) rather than an ambiguous // transport/server error. The real HTTP client formats declines as // "square: POST /v2/payments: [CATEGORY/CODE] ...", so the code is matched // against the uppercased error message. func isDefinitiveChargeFailure(err error) bool { if err == nil { return false } msg := strings.ToUpper(err.Error()) for _, code := range definitivePaymentDeclineCodes { if strings.Contains(msg, code) { return true } } return false } // revertGiftCardFunding undoes the gift-card funding performed earlier in the // SAME till-sale request after a definitive Square charge rejection, matching // the gift_card_transactions accounting: a created card is deleted (with its // purchase transaction) and any immediate redeem-to-account credit reversed; a // topped-up card has the amount subtracted back out and its top-up transaction // removed. The till sale is marked 'failed' in the same compensating // transaction so a late same-key retry cannot re-complete a sale whose gift // card no longer exists. func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error { tx, err := db.Conn.Begin(ctx) if err != nil { return fmt.Errorf("failed to begin clawback transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback gift-card clawback transaction", "err", err) } }() if action == "create" { // A newly created card has exactly one funding transaction (this // request's purchase) — remove it, then the card itself. if _, err := tx.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID); err != nil { return fmt.Errorf("failed to delete gift card transaction: %w", err) } if _, err := tx.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, giftCardID); err != nil { return fmt.Errorf("failed to delete gift card: %w", err) } // If the card was immediately redeemed to a user balance in this // request, reverse that credit (guarded so it can never go negative). if redeemToUserID != nil && *redeemToUserID != "" { if _, err := tx.Exec(ctx, ` UPDATE user_giftcard_balances SET balance = user_giftcard_balances.balance - $1, updated_at = NOW() WHERE user_id = $2 AND balance >= $1 `, amount, *redeemToUserID); err != nil { return fmt.Errorf("failed to reverse redeemed gift card balance: %w", err) } } } else { // Top-up: subtract the amount back out of the card. The guard keeps // amount_remaining from ever going negative in the pathological case // where some of the top-up was already spent before the charge failed. tag, err := tx.Exec(ctx, ` UPDATE gift_cards SET total_funds_added = total_funds_added - $1, amount_remaining = amount_remaining - $1 WHERE id = $2 AND amount_remaining >= $1 `, amount, giftCardID) if err != nil { return fmt.Errorf("failed to reverse gift card top-up: %w", err) } if tag.RowsAffected() == 0 { // The guard blocked the reversal because some of the top-up was // already spent. The sale is still marked failed below — do not // fail the whole clawback tx — but the unreversed money must be // flagged for manual reconciliation. log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (amount_remaining < top-up)", amount, giftCardID) } // Remove only this request's top-up transaction (reference_id = till // sale) so prior sales' accounting on the same card is untouched. if _, err := tx.Exec(ctx, ` DELETE FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2 `, giftCardID, tillSaleID); err != nil { return fmt.Errorf("failed to delete gift card top-up transaction: %w", err) } } if _, err := tx.Exec(ctx, ` UPDATE till_sales SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status = 'pending' `, tillSaleID); err != nil { return fmt.Errorf("failed to mark till sale failed after clawback: %w", err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("failed to commit clawback transaction: %w", err) } return nil } 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 } 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 } // 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; falls back to a per-request key // when absent (client-supplied key is always used in practice). Bounded // try-lock (R6) so a contended lock never blocks the pool across the Square // round-trip. lockKey := req.IdempotencyKey if lockKey == "" { lockKey = "till-" + rand.Text() } 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 func() { if _, err := pinConn.Exec(context.Background(), ` SELECT pg_advisory_unlock(hashtext('crussell:till:' || $1)) `, lockKey); err != nil { log.Printf("Failed to release till-sale serialization lock for %s: %v", lockKey, err) } }() // 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 if req.IdempotencyKey != "" { var existingID, existingStatus, existingItemID string var existingTotal float64 err := db.Conn.QueryRow(ctx, `SELECT id, status, item_id, total_amount FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus, &existingItemID, &existingTotal) if err == nil { if existingStatus == "completed" { if err := json.NewEncoder(w).Encode(TillSaleResponse{ ID: existingID, ItemType: req.ItemType, TotalAmount: req.Amount, PaymentMethod: req.PaymentMethod, 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 } 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 } } } service := NewPaymentService() 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 } defer func() { 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 { 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" } err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase) VALUES ($1, $1, $2, FALSE, $3) RETURNING id `, req.Amount, adminID, purchaseVoucherType).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() WHERE id = $2 `, *req.RedeemToUserID, giftCardID) 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() WHERE id = $2 `, req.Amount, cardID) 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 squarePaymentID *string var squareCheckoutID *string 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 // 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, '') FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID) 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: if the original attempt was // card_machine and created a live terminal checkout, the retry MUST stay // card_machine and reuse that checkout. Switching to cash/on_the_house/ // saved_card/online_square would report the sale completed while the // original checkout is still live — the customer could be charged at the // terminal AND by the new method (double charge). The terminal checkout // cannot be cancelled via this API, so reject the switch outright. if existingPendingID != "" && existingPendingCheckoutID != "" && req.PaymentMethod != "card_machine" { log.Printf("Till-sale retry rejected: pending sale %s has a live card-machine checkout, cannot switch method from card_machine to %s", existingPendingID, req.PaymentMethod) http.Error(w, "This pending sale is tied to a live card-machine checkout — retry with card machine payment", http.StatusConflict) return } switch req.PaymentMethod { case "cash": saleStatus = "completed" dbPaymentMethod = "cash" if req.IdempotencyKey == "" { req.IdempotencyKey = uniqueTillKey() } 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). var cardUserID sql.NullString 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) } } if req.IdempotencyKey == "" { req.IdempotencyKey = uniqueTillKey() } saleStatus = "pending" needsSquarePayment = true case "card_machine": dbPaymentMethod = "in_person_card" if req.IdempotencyKey == "" { req.IdempotencyKey = uniqueTillKey() } 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 { 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 saleStatus = "pending" } case "online_square": dbPaymentMethod = "online_square" if req.IdempotencyKey == "" { req.IdempotencyKey = uniqueTillKey() } saleStatus = "pending" needsSquarePayment = true case "on_the_house": saleStatus = "completed" dbPaymentMethod = "on_the_house" if req.IdempotencyKey == "" { req.IdempotencyKey = uniqueTillKey() } } 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. tillSaleID = existingPendingID } 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 ) VALUES ($1, $2, $3, 1, $4, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) 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, ).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 && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" { 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 } // 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, } 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, } 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) } } http.Error(w, "Payment failed", http.StatusPaymentRequired) return } // Square succeeded — update the till_sale record. _, upErr := db.Conn.Exec(ctx, `UPDATE till_sales SET status = 'completed', square_payment_id = $1 WHERE id = $2`, 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 } 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" { _, 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 } 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 } 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 func() { if _, err := pinConn.Exec(context.Background(), ` SELECT pg_advisory_unlock(hashtext('crussell:tillcomplete:' || $1)) `, tillSaleID); err != nil { log.Printf("Failed to release till-completion serialization lock for %s: %v", tillSaleID, err) } }() 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) } }() _, err = tx.Exec(r.Context(), ` UPDATE till_sales SET status = 'completed', square_payment_id = $1, updated_at = NOW() WHERE id = $2 `, paymentResult.SquarePayID, tillSaleID) if err != nil { log.Printf("Failed to update till sale: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) 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) } }