Adopt shared charge helpers in till and gift-card flows; redact card tokens

Till sales and gift-card purchases now use uniqueChargeKey and resolveChargeSource/chargeFailureStatus instead of duplicated inline logic. The saved-card delete path logs the square_card_id through square.TokenPrefix so full ccof references never reach logs.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent deb630991e
commit 57bdeb9232
3 changed files with 21 additions and 105 deletions
+10 -90
View File
@@ -1,7 +1,6 @@
package payments package payments
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -896,7 +895,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.IdempotencyKey == "" { if req.IdempotencyKey == "" {
req.IdempotencyKey = uniqueTillKey() req.IdempotencyKey = uniqueChargeKey("till-")
} }
allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true} allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true}
@@ -935,31 +934,11 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
if lockKey == "" { if lockKey == "" {
lockKey = userID lockKey = userID
} }
pinConn, err := db.Conn.Acquire(ctx) pinConn, lockOK := acquireBookingPaymentLock(ctx, w, "crussell:giftcard:"+lockKey, "Purchase in progress, try again")
if err != nil {
log.Printf("Failed to acquire connection for gift-card lock: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer pinConn.Release()
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 { 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 return
} }
defer func() { defer releaseBookingPaymentLock(pinConn, "crussell:giftcard:"+lockKey)
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:giftcard:' || $1))
`, lockKey); err != nil {
log.Printf("Failed to release gift-card serialization lock for %s: %v", lockKey, err)
}
}()
// Idempotency: only short-circuit when the existing record is 'completed'. // Idempotency: only short-circuit when the existing record is 'completed'.
// A 'pending' record means the previous Square call failed — returning it // A 'pending' record means the previous Square call failed — returning it
@@ -1003,71 +982,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
var sourceID string var sourceID string
var savedCardID *string var savedCardID *string
var savedCardCustomerID string var savedCardCustomerID string
// Resolve the new-card-vs-saved-card Square source — shared with
if req.NewCardToken != nil && *req.NewCardToken != "" { // CreateBookingPayment/CreateTipPayment (see resolveChargeSource for the
// A cnon: nonce charge needs NO card-on-file and NO customer (R6). The // R6 rationale).
// old code tokenized every new card via CreateCardOnFile even for sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(ctx, w, paymentService, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found")
// one-off non-save purchases, creating an orphan card at Square for a if !sourceOK {
// payment that only ever uses the nonce once. return
if req.SaveCard {
// 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
}
sourceID = cardOnFile.CardID
savedCardCustomerID = squareCustomerID
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)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Card not found", http.StatusNotFound)
return
}
log.Printf("Failed to get card: %v", err)
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 amountPounds := float64(req.Amount) / 100.0
@@ -1177,7 +1097,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Failed to process gift card purchase payment: %v", err) log.Printf("Failed to process gift card purchase payment: %v", err)
// Payment record intentionally left as 'pending' for manual retry. // Payment record intentionally left as 'pending' for manual retry.
http.Error(w, "Payment failed", http.StatusPaymentRequired) http.Error(w, "Payment failed", chargeFailureStatus(err))
return return
} }
+3 -1
View File
@@ -548,7 +548,9 @@ func (s *PaymentService) DeletePaymentMethod(ctx context.Context, cardID, userID
if square.ErrorCode(err) == "NOT_FOUND" || strings.Contains(msg, "NOT_FOUND") || strings.Contains(msg, "NOT FOUND") { if square.ErrorCode(err) == "NOT_FOUND" || strings.Contains(msg, "NOT_FOUND") || strings.Contains(msg, "NOT FOUND") {
// Square already removed/disabled the card — nothing to do. // Square already removed/disabled the card — nothing to do.
} else { } else {
slog.Warn("failed to disable Square card on local delete — card may remain enabled at Square", "square_card_id", sqCardID.String, "err", err) // TokenPrefix redacts the ccof: card token — the full ID must
// never reach logs.
slog.Warn("failed to disable Square card on local delete — card may remain enabled at Square", "square_card_id", square.TokenPrefix(sqCardID.String), "err", err)
} }
} }
} }
+8 -14
View File
@@ -46,15 +46,9 @@ type TillSaleResponse struct {
CheckoutID *string `json:"checkout_id,omitempty"` CheckoutID *string `json:"checkout_id,omitempty"`
} }
// uniqueTillKey generates a unique idempotency key for till sales where the // uniqueChargeKey is defined in handlers.go (the till fallback key was
// client did not supply one. Dedup of retries is handled by the client-supplied // byte-identical to the tip fallback except the prefix — see the consolidated
// key (the frontend sends a UUID); this fallback only needs to be unique so it // helper).
// 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 // definitivePaymentDeclineCodes are Square payment error codes meaning the
// card charge can never succeed (declined / expired / not supported). They are // card charge can never succeed (declined / expired / not supported). They are
@@ -520,7 +514,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
saleStatus = "completed" saleStatus = "completed"
dbPaymentMethod = "cash" dbPaymentMethod = "cash"
if req.IdempotencyKey == "" { if req.IdempotencyKey == "" {
req.IdempotencyKey = uniqueTillKey() req.IdempotencyKey = uniqueChargeKey("till-")
} }
case "saved_card": case "saved_card":
dbPaymentMethod = "online_square" dbPaymentMethod = "online_square"
@@ -577,7 +571,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
} }
if req.IdempotencyKey == "" { if req.IdempotencyKey == "" {
req.IdempotencyKey = uniqueTillKey() req.IdempotencyKey = uniqueChargeKey("till-")
} }
saleStatus = "pending" saleStatus = "pending"
@@ -585,7 +579,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
case "card_machine": case "card_machine":
dbPaymentMethod = "in_person_card" dbPaymentMethod = "in_person_card"
if req.IdempotencyKey == "" { if req.IdempotencyKey == "" {
req.IdempotencyKey = uniqueTillKey() req.IdempotencyKey = uniqueChargeKey("till-")
} }
if existingPendingCheckoutID != "" { if existingPendingCheckoutID != "" {
@@ -617,7 +611,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
case "online_square": case "online_square":
dbPaymentMethod = "online_square" dbPaymentMethod = "online_square"
if req.IdempotencyKey == "" { if req.IdempotencyKey == "" {
req.IdempotencyKey = uniqueTillKey() req.IdempotencyKey = uniqueChargeKey("till-")
} }
saleStatus = "pending" saleStatus = "pending"
@@ -626,7 +620,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
saleStatus = "completed" saleStatus = "completed"
dbPaymentMethod = "on_the_house" dbPaymentMethod = "on_the_house"
if req.IdempotencyKey == "" { if req.IdempotencyKey == "" {
req.IdempotencyKey = uniqueTillKey() req.IdempotencyKey = uniqueChargeKey("till-")
} }
} }