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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user