Fix gift-card, till, and refund paths: nonce-direct charges, customer_id forwarding, blocking refund lock

BuyGiftCard and till online_square charge the cnon: nonce directly (no synthetic card-on-file); BuyGiftCard.IdempotencyKey is now validate-required (empty key previously collided on the UNIQUE constraint). Saved-card branches forward customer_id and lazily provision legacy cards. lockCancellationPayments uses the deliberately-blocking xact lock so a cancellation never silently drops a refund under contention (admins see the full manual refund round-trip).
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent b6f07fe6e8
commit 515b828550
3 changed files with 156 additions and 55 deletions
+72 -22
View File
@@ -81,7 +81,11 @@ type BuyGiftCardRequest struct {
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key"`
// IdempotencyKey is required (R2): an empty key would be stored as '' on
// the pending payments row, and a second empty-key purchase would 500 on
// the UNIQUE(payments.idempotency_key) constraint. The frontend always
// sends a per-purchase UUID; the max=64 matches the column width.
IdempotencyKey string `json:"idempotency_key" validate:"required,max=64"`
VerificationToken *string `json:"verification_token,omitempty"`
}
@@ -879,6 +883,22 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
// R2: idempotency_key is required (validate:"required,max=64"). An empty
// key would be stored as '' on the pending payments row and a second
// empty-key purchase would 500 on the UNIQUE(payments.idempotency_key)
// constraint. The frontend always sends a per-purchase UUID. The fallback
// below is defense-in-depth only — the validator rejects the empty key
// first, but if it is ever relaxed the fallback keeps the UNIQUE
// constraint from firing.
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.IdempotencyKey == "" {
req.IdempotencyKey = uniqueTillKey()
}
allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true}
if !allowedAmounts[req.Amount] {
http.Error(w, "Invalid amount. Must be £10, £20, or £50.", http.StatusBadRequest)
@@ -909,6 +929,8 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// executing the gift-card creation (2× value for 1 charge). Mirrors the tip
// advisory-lock pattern (handlers.go). Lock is keyed on the idempotency key
// so distinct purchases are unaffected; falls back to userID when absent.
// Bounded try-lock (R6) so a contended lock never blocks the pool across
// the Square round-trip.
lockKey := req.IdempotencyKey
if lockKey == "" {
lockKey = userID
@@ -920,13 +942,17 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(ctx, `
SELECT pg_advisory_lock(hashtext('crussell:giftcard:' || $1))
`, lockKey); err != nil {
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard:"+lockKey)
if err != nil {
log.Printf("Failed to acquire gift-card serialization lock for %s: %v", lockKey, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if !lockOK {
log.Printf("Gift-card serialization lock for %s not acquired within bound — a purchase is already in progress", lockKey)
http.Error(w, "Purchase in progress, try again", http.StatusConflict)
return
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:giftcard:' || $1))
@@ -976,38 +1002,44 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
var sourceID string
var savedCardID *string
var savedCardCustomerID string
if req.NewCardToken != nil && *req.NewCardToken != "" {
// P14: when the card is being SAVED, provision (or reuse) the user's
// Square customer profile BEFORE tokenizing so the new card is created
// against that customer. One-off non-save charges pass "" — a cnon:
// nonce charge needs no customer.
squareCustomerID := ""
// A cnon: nonce charge needs NO card-on-file and NO customer (R6). The
// old code tokenized every new card via CreateCardOnFile even for
// one-off non-save purchases, creating an orphan card at Square for a
// payment that only ever uses the nonce once.
if req.SaveCard {
var custErr error
squareCustomerID, custErr = paymentService.EnsureSquareCustomer(ctx, userID)
// Save path: provision (or reuse) the user's Square customer BEFORE
// tokenizing so the new card is created against that customer, and
// forward the customer id on the charge. A ccof: source MUST carry
// its customer (R6).
squareCustomerID, custErr := paymentService.EnsureSquareCustomer(ctx, userID)
if custErr != nil {
log.Printf("Failed to provision Square customer for user %s: %v", userID, custErr)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
}
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken, squareCustomerID)
if err != nil {
log.Printf("Failed to create card on file: %v", err)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken, squareCustomerID)
if err != nil {
log.Printf("Failed to create card on file: %v", err)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
sourceID = cardOnFile.CardID
savedCardCustomerID = squareCustomerID
sourceID = cardOnFile.CardID
if req.SaveCard {
cardID, err := paymentService.SaveCardForUser(ctx, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
cardID, err := paymentService.SaveCardForUser(ctx, userID, squareCustomerID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
if err != nil {
log.Printf("Failed to save card: %v", err)
} else {
savedCardID = &cardID
}
} else {
// One-off new-card purchase: use the cnon: nonce DIRECTLY as the
// source. No card-on-file is created (nothing to orphan, no
// customer needed).
sourceID = *req.NewCardToken
}
} else if req.CardID != nil {
card, err := paymentService.GetCardByID(ctx, *req.CardID, userID)
@@ -1020,8 +1052,22 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
// A ccof: source can NEVER be charged without a CustomerID — Square
// rejects the payment. A saved-card row created before P14 has an empty
// square_customer_id; lazily provision the user's Square customer and
// persist it on the row BEFORE charging (R6).
if card.SquareCustomerID == "" {
provisioned, provErr := paymentService.EnsureSquareCustomerForSavedCard(ctx, *req.CardID, userID)
if provErr != nil {
log.Printf("Failed to provision Square customer for saved card %s (user %s): %v", *req.CardID, userID, provErr)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
card.SquareCustomerID = provisioned
}
sourceID = card.SquareCardID
savedCardID = req.CardID
savedCardCustomerID = card.SquareCustomerID
}
amountPounds := float64(req.Amount) / 100.0
@@ -1117,6 +1163,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
Amount: req.Amount,
Currency: "GBP",
SourceID: sourceID,
// CustomerID carries the saved-card row's Square customer id on ccof:
// charges (save-card path); a cnon: nonce charge (one-off) needs none
// (R6).
CustomerID: savedCardCustomerID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card Purchase",
BuyerEmail: buyerEmail,
+27 -9
View File
@@ -106,7 +106,7 @@ func CalculateRefundForCancellation(
// manual guard read precedes the cancellation's commit). Locks are acquired in
// ascending payment_id order (matching processChargeGroup) to avoid deadlocks,
// and only for card methods the manual handler can touch.
func lockCancellationPayments(ctx context.Context, q db.Querier, payments []paymentRow) error {
func lockCancellationPayments(ctx context.Context, tx pgx.Tx, payments []paymentRow) error {
var ids []string
for _, p := range payments {
if p.PaymentMethod == "online_square" || p.PaymentMethod == "in_person_card" {
@@ -118,7 +118,15 @@ func lockCancellationPayments(ctx context.Context, q db.Querier, payments []paym
}
sort.Strings(ids)
for _, pid := range ids {
if _, err := q.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('crussell:refund:' || $1))`, pid); err != nil {
// Blocking xact lock (NOT the bounded try-lock used elsewhere): this is
// the admin-only cancellation path, and the manual RefundPayment handler
// can hold the same key across its up-to-30s Square round-trip. A timed
// out acquire here would abort the cancellation transaction — the caller
// (manage.go) would commit the cancellation with ZERO refund rows and no
// sweep retry could ever recover the money. Blocking guarantees the
// refund runs; the lock auto-releases at the caller's commit/rollback.
// See acquireAdvisoryXactLockBlocking for the full rationale.
if err := acquireAdvisoryXactLockBlocking(ctx, tx, "crussell:refund:"+pid); err != nil {
return fmt.Errorf("failed to acquire cancellation refund lock for payment %s: %w", pid, err)
}
}
@@ -749,10 +757,15 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
locked := 0
for _, pid := range paymentIDs {
if _, err := pinConn.Exec(ctx, `
SELECT pg_advisory_lock(hashtext('crussell:refund:' || $1))
`, pid); err != nil {
log.Printf("Failed to acquire refund lock for payment %s (charge %s): %v", pid, chargeID, err)
// Bounded try-lock (R6) so the sweep never blocks a pool connection
// while the manual handler holds the same key across its Square call.
ok, lockErr := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+pid)
if lockErr != nil {
log.Printf("Failed to acquire refund lock for payment %s (charge %s): %v", pid, chargeID, lockErr)
break
}
if !ok {
log.Printf("Refund lock for payment %s (charge %s) not acquired within bound — a refund is in progress", pid, chargeID)
break
}
locked++
@@ -1083,11 +1096,16 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
return 0, err
}
defer pinConn.Release()
if _, err := pinConn.Exec(ctx, `
SELECT pg_advisory_lock(hashtext('crussell:refund:' || $1))
`, paymentID); err != nil {
// Bounded try-lock (R6) so the sweep never blocks a pool connection while
// the manual handler holds the same key across its Square call.
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+paymentID)
if err != nil {
return 0, err
}
if !lockOK {
log.Printf("Refund lock for payment %s not acquired within bound — a manual refund is in progress; leaving rows pending for the next sweep", paymentID)
return 0, nil
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
+57 -24
View File
@@ -247,7 +247,9 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// 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).
// 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()
@@ -259,13 +261,17 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(ctx, `
SELECT pg_advisory_lock(hashtext('crussell:till:' || $1))
`, lockKey); err != nil {
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))
@@ -531,17 +537,45 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
}
}
// 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 square_card_id, COALESCE(square_customer_id, '')
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(&savedCardSqCardID, &savedCardCustomerID)
`, *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()
}
@@ -697,29 +731,23 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
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), tokenized via the Cards API.
// 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
}
// Tokenize the till card. The reference_id is the synthetic
// "till-<giftCardID>" namespace, NOT a real user — this card is
// ephemeral (used once for this charge) and is never stored in
// user_saved_cards or re-listed. The prefix can't collide with a
// real CHAR(12)-hex user ID. No Square customer is provisioned for
// it ("" as the customerID): a cnon: nonce charge needs none.
cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken, "")
if cardErr != nil {
log.Printf("Failed to tokenize card: %v", cardErr)
http.Error(w, "Card tokenization failed", http.StatusInternalServerError)
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: cardOnFile.CardID,
SourceID: req.CardToken,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
@@ -854,6 +882,7 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
// 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)
@@ -861,13 +890,17 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
return
}
defer pinConn.Release()
if _, err := pinConn.Exec(r.Context(), `
SELECT pg_advisory_lock(hashtext('crussell:tillcomplete:' || $1))
`, tillSaleID); err != nil {
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))