Fix payment review round 3: saved-card idempotency, stale-pending sweep, webhook fail-closed
R1/R4: saved_card branch in CreateTerminalPayment now mirrors CreateTipPayment - advisory lock (crussell:payment:<bookingID>) serializes concurrent double-clicks - deterministic key bookingID-sc-type-amount-cardID (<=45 chars) so a lost-response retry derives the same key and dedups instead of double-charging - idempotency switch inside the lock: completed -> dedup, pending -> reuse with pence amount-guard, failed -> clean 409 - success response includes card_brand/card_last4 (frontend already reads them) R2: add 'failed' case to all four retry switches (tip, booking, gift card, till) - a swept/definitively-rejected record returns 409 instead of 500-ing on the idempotency_key UNIQUE constraint R3: extend SweepStalePendingPayments to till_sales card rows - sweeps pending till_sales (online_square/in_person_card) past Square's ~24h key retention, closing the double-charge window for till sales - swept rows logged with the same CRITICAL manual-reconciliation marker as the refund sweep Webhook fail-closed: reject 503 when SQUARE_WEBHOOK_SIGNATURE_KEY unset, 403 on bad signature (was: skip verification in dev) Refund status resolution: refunds now resolve by Square status (COMPLETED/PENDING/FAILED/REJECTED) instead of assuming completed; real error codes (REFUND_AMOUNT_INVALID, PAYMENT_NOT_REFUNDABLE, REFUND_ALREADY_PENDING) added to the definitive/processed classification HTTP client: CreateCard key truncated to <=45 chars, device_options always sent (env SQUARE_TERMINAL_DEVICE_ID fallback), processing_fee reads amount_money, ListCards cursor loop, refund keys hashed to <=45 chars Other fixes: payment/till/gift-card advisory-lock + FOR UPDATE asymmetries, GetPaymentByID NULL scans, loyalty redemption lock, card upsert on conflict, mock ccof: prefix parity, IsValidSquareCheckoutID for real Square IDs, isAdminRequest defense-in-depth on all 6 admin payment handlers, webhook signature docs, M8/L5 debug markers removed Docs: README/FC/TM/Overview updated (22 jobs, 20 CRITICAL sites, 23-section GDPR export, sweep jobs, webhook fail-closed); P11 plan marks remaining items (sandbox smoke test, M-8 customer_id, saved-card key dedup trade-off) as deferred with rationale; gap backlog pruned of completed items
This commit is contained in:
@@ -481,7 +481,7 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
var redeemedBy sql.NullString
|
||||
var isInventory bool
|
||||
var currentTotalFunds float64
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds)
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1 FOR UPDATE", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||||
@@ -597,7 +597,11 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
var fromRedeemedBy, toRedeemedBy sql.NullString
|
||||
var fromRemaining, toRemaining float64
|
||||
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", fromCardID).Scan(&fromRedeemedBy, &fromRemaining)
|
||||
// Lock both rows FOR UPDATE (source first, deterministic order) so a
|
||||
// concurrent transfer/topup can't interleave a read-then-write on the same
|
||||
// card — the same check-then-act race RedeemGiftCard and the till path
|
||||
// already guard against (N-5).
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", fromCardID).Scan(&fromRedeemedBy, &fromRemaining)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Source gift card not found", http.StatusNotFound)
|
||||
@@ -608,7 +612,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1", req.ToCardID).Scan(&toRedeemedBy, &toRemaining)
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", req.ToCardID).Scan(&toRedeemedBy, &toRemaining)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Destination gift card not found", http.StatusNotFound)
|
||||
@@ -953,6 +957,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
reusePendingID = existing.ID
|
||||
log.Printf("[PAYMENTS] Reusing pending payment %s for idempotent gift-card retry (key %s)", existing.ID, req.IdempotencyKey)
|
||||
}
|
||||
if existing.Status == "failed" {
|
||||
// Swept as stale (>24h) or definitively rejected — a retry would
|
||||
// risk a second Square charge. Reject cleanly (R2).
|
||||
log.Printf("Gift card retry rejected: pending record %s was marked failed", existing.ID)
|
||||
http.Error(w, "This gift card purchase previously failed and can no longer be retried", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1093,8 +1104,26 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Square succeeded — update payment, create gift card.
|
||||
_, upErr := db.Conn.Exec(ctx,
|
||||
// Step 3: Square succeeded — atomically flip the payment to completed and
|
||||
// create the gift card + balance + transaction in ONE transaction. If any
|
||||
// step fails, the whole thing rolls back, the payment stays 'pending', and
|
||||
// a same-key retry re-attempts the Square charge (Square dedups) before
|
||||
// delivering the card. Previously these were separate non-transactional
|
||||
// writes: a failure after the payment-completed update left the customer
|
||||
// CHARGED but with no card, and the completed-dedup swallowed the retry.
|
||||
issueTx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin gift-card issue transaction: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := issueTx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback gift-card issue transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, upErr := issueTx.Exec(ctx,
|
||||
`UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`,
|
||||
paymentResult.SquarePayID, buyPaymentID,
|
||||
)
|
||||
@@ -1108,7 +1137,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if req.RecipientType == "self" {
|
||||
var purchaseVoucherType string
|
||||
err = db.Conn.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||
err = issueTx.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)
|
||||
@@ -1117,7 +1146,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
if purchaseVoucherType == "" {
|
||||
purchaseVoucherType = "SPV"
|
||||
}
|
||||
err = db.Conn.QueryRow(ctx, `
|
||||
err = issueTx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES ($1, 0, $2, NOW(), $2, FALSE, $3)
|
||||
RETURNING id
|
||||
@@ -1128,7 +1157,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Conn.Exec(ctx, `
|
||||
_, err = issueTx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
@@ -1141,7 +1170,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Conn.Exec(ctx, `
|
||||
_, err = issueTx.Exec(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||||
VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'self-purchase, auto-redeemed')
|
||||
`, cardID, amountPounds, userID)
|
||||
@@ -1152,7 +1181,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
var purchaseVoucherType string
|
||||
err = db.Conn.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType)
|
||||
err = issueTx.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)
|
||||
@@ -1161,7 +1190,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
if purchaseVoucherType == "" {
|
||||
purchaseVoucherType = "SPV"
|
||||
}
|
||||
err = db.Conn.QueryRow(ctx, `
|
||||
err = issueTx.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
|
||||
@@ -1175,7 +1204,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
recipient := req.RecipientEmail
|
||||
if recipient == "" {
|
||||
var userEmail string
|
||||
err = db.Conn.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail)
|
||||
err = issueTx.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail)
|
||||
if err != nil {
|
||||
log.Printf("Failed to query user email: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1188,7 +1217,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
// code anyone can redeem). Log only the value and recipient for audit.
|
||||
log.Printf("Gift card purchased for friend — value: £%.2f, intended for: %s (code stored in DB, not logged)", amountPounds, recipient)
|
||||
|
||||
_, err = db.Conn.Exec(ctx, `
|
||||
_, err = issueTx.Exec(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||||
VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'purchased for friend')
|
||||
`, cardID, amountPounds, userID)
|
||||
@@ -1199,6 +1228,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := issueTx.Commit(ctx); err != nil {
|
||||
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift-card issue transaction commit failed: %v — manual reconciliation required", paymentResult.SquarePayID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "success",
|
||||
|
||||
Reference in New Issue
Block a user