Money-safety idempotency fixes (external review bugs 1-3): - processChargeGroup: aggregated refund key now hashes the sorted pending-row set (chargeID-square-agg-<sha256 suffix>) so a changed group can never mark a new row completed against an old smaller refund; >45-char chargeIDs use a hashed prefix instead of verbatim truncation (which would collide charges on Square's global key dedup). Same-set crash-retry keeps Square's dedup. - CreateTerminalPayment saved_card: two-tier idempotency key — client-supplied per-attempt UUID preferred (distinct identical charges no longer collapse), deterministic booking+type+amount+card fallback for no-key retry safety. PaymentModal sends a per-charge UUID cleared after success. - ensureRefundKey: legacy NULL-key manual refunds persist a generated key to the row BEFORE the Square call (race-safe AND idempotency_key IS NULL guard), so a lost-response retry reuses the key and never double-refunds. Wired into resumeManualPendingRefund and the sweep's manual-retry loop. Classification + money-safety hardening: - till.go/sweep.go: structured square.ErrorCode/IsNotFound are authoritative when present; message-substring matching only for non-structured errors (dev mock, client-side status errors). Fixes fragile string-matching driving sweep retries and gift-card clawbacks. - SaveCardForUser: ON CONFLICT (user_id, square_card_id) DO NOTHING + re-select (was a latent UNIQUE-violation 500 on save-card retry). - CreateBookingPayment: partial payments re-validated against remaining balance inside the advisory lock (closes concurrent-overpayment race). - InvalidateSquareCustomerCache on GDPR erasure paths (account.go, time-blockers.go stale-guest anonymization). - GetUserGiftCardBalanceAdmin: in-handler admin check (defense-in-depth). - getCheckoutHTTP: warn on multi-payment checkouts instead of dropping payments[1:]. - Cash/giftcard terminal branch: removed dead idempotency SELECT, "tip-" -> "till-" prefix. - UserPaymentModal: removed vestigial polling state; proper interval cleanup. - account/+page.svelte: gift-card redeem dialog links /terms. - nginx CSP: allow *.squarecdn.com and js.squareup.com so the Square Web Payments SDK + card iframe can tokenize behind the proxy. Tests: +8 regression tests covering changed-set refund keys, legacy NULL-key single-refund, saved-card client-key dedup/no-dedup, concurrent partials, and cache invalidation. Full suite + race detector clean via run-tests.sh lockfile.
1097 lines
44 KiB
Go
1097 lines
44 KiB
Go
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"`
|
|
}
|
|
|
|
// uniqueChargeKey is defined in handlers.go (the till fallback key was
|
|
// byte-identical to the tip fallback except the prefix — see the consolidated
|
|
// helper).
|
|
|
|
// 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 surfaces declines as a
|
|
// structured squareAPIError carrying the Square error Code (and Category), so
|
|
// the classification matches those EXACTLY against definitivePaymentDeclineCodes
|
|
// — a Square message-wording change can never silently flip the
|
|
// definitive↔retryable decision that drives the gift-card funding clawback.
|
|
// 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] ..."), which is the only signal
|
|
// available for them.
|
|
func isDefinitiveChargeFailure(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
// The formatted message carries both [CATEGORY/CODE] and the legacy check
|
|
// matched either, so compare the Code AND the Category exactly.
|
|
if code := square.ErrorCode(err); code != "" {
|
|
return declineCodeListContains(code) || declineCodeListContains(square.ErrorCategory(err))
|
|
}
|
|
msg := strings.ToUpper(err.Error())
|
|
for _, code := range definitivePaymentDeclineCodes {
|
|
if strings.Contains(msg, code) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// declineCodeListContains reports whether s is exactly one of the definitive
|
|
// payment decline codes.
|
|
func declineCodeListContains(s string) bool {
|
|
for _, code := range definitivePaymentDeclineCodes {
|
|
if s == code {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 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 clawback is claim-first: it atomically claims the till sale
|
|
// with a gating `status='pending'` UPDATE whose row lock serializes against
|
|
// the handler's completion UPDATE, then runs the card mutation + failed-mark
|
|
// in the same 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)
|
|
}
|
|
}()
|
|
|
|
// Claim the sale first: the row lock serializes against the handler's
|
|
// completion UPDATE; a zero-row claim means the funding is not ours.
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE till_sales SET status = 'failed', updated_at = NOW()
|
|
WHERE id = $1 AND status = 'pending'`, tillSaleID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to claim till sale for clawback: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return errTillSaleNotPending
|
|
}
|
|
|
|
if action == "create" {
|
|
// A newly created card's transactions are scoped to THIS sale's
|
|
// funding (reference_type='till_sale' AND reference_id=sale id) — never
|
|
// a wholesale delete, which would destroy the value of a different
|
|
// idempotency-keyed top-up sale that funded the same card before this
|
|
// create resolved. Then remove the card itself.
|
|
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 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 != "" {
|
|
bTag, bErr := 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)
|
|
if bErr != nil {
|
|
return fmt.Errorf("failed to reverse redeemed gift card balance: %w", bErr)
|
|
}
|
|
if bTag.RowsAffected() == 0 {
|
|
// The guard blocked the reversal because some of the credited
|
|
// balance was already spent. The sale is still marked failed
|
|
// below — do not fail the whole clawback tx — but the
|
|
// un-reversed credit must be flagged for manual reconciliation
|
|
// (mirrors the top-up branch).
|
|
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: create-with-redeem clawback for gift card %s could not fully reverse the £%.2f balance credited to user %s (balance < amount)", giftCardID, amount, *redeemToUserID)
|
|
}
|
|
}
|
|
} 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.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()
|
|
|
|
// 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 {
|
|
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 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.
|
|
// 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 != "" && 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
switch req.PaymentMethod {
|
|
case "cash":
|
|
saleStatus = "completed"
|
|
dbPaymentMethod = "cash"
|
|
if req.IdempotencyKey == "" {
|
|
req.IdempotencyKey = uniqueChargeKey("till-")
|
|
}
|
|
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 = uniqueChargeKey("till-")
|
|
}
|
|
|
|
saleStatus = "pending"
|
|
needsSquarePayment = true
|
|
case "card_machine":
|
|
dbPaymentMethod = "in_person_card"
|
|
if req.IdempotencyKey == "" {
|
|
req.IdempotencyKey = uniqueChargeKey("till-")
|
|
}
|
|
|
|
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
|
|
checkoutCreated = true
|
|
saleStatus = "pending"
|
|
}
|
|
case "online_square":
|
|
dbPaymentMethod = "online_square"
|
|
if req.IdempotencyKey == "" {
|
|
req.IdempotencyKey = uniqueChargeKey("till-")
|
|
}
|
|
|
|
saleStatus = "pending"
|
|
needsSquarePayment = true
|
|
case "on_the_house":
|
|
saleStatus = "completed"
|
|
dbPaymentMethod = "on_the_house"
|
|
if req.IdempotencyKey == "" {
|
|
req.IdempotencyKey = uniqueChargeKey("till-")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
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,
|
|
}
|
|
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-<giftCardID>"
|
|
// 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" {
|
|
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 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)
|
|
}
|
|
}()
|
|
|
|
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)
|
|
}
|
|
}
|