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:
@@ -81,7 +81,11 @@ type BuyGiftCardRequest struct {
|
|||||||
CardID *string `json:"card_id,omitempty"`
|
CardID *string `json:"card_id,omitempty"`
|
||||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||||
SaveCard bool `json:"save_card"`
|
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"`
|
VerificationToken *string `json:"verification_token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +883,22 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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}
|
allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true}
|
||||||
if !allowedAmounts[req.Amount] {
|
if !allowedAmounts[req.Amount] {
|
||||||
http.Error(w, "Invalid amount. Must be £10, £20, or £50.", http.StatusBadRequest)
|
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
|
// 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
|
// advisory-lock pattern (handlers.go). Lock is keyed on the idempotency key
|
||||||
// so distinct purchases are unaffected; falls back to userID when absent.
|
// 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
|
lockKey := req.IdempotencyKey
|
||||||
if lockKey == "" {
|
if lockKey == "" {
|
||||||
lockKey = userID
|
lockKey = userID
|
||||||
@@ -920,13 +942,17 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer pinConn.Release()
|
defer pinConn.Release()
|
||||||
if _, err := pinConn.Exec(ctx, `
|
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard:"+lockKey)
|
||||||
SELECT pg_advisory_lock(hashtext('crussell:giftcard:' || $1))
|
if err != nil {
|
||||||
`, lockKey); err != nil {
|
|
||||||
log.Printf("Failed to acquire gift-card serialization lock for %s: %v", lockKey, err)
|
log.Printf("Failed to acquire gift-card serialization lock for %s: %v", lockKey, err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
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() {
|
defer func() {
|
||||||
if _, err := pinConn.Exec(context.Background(), `
|
if _, err := pinConn.Exec(context.Background(), `
|
||||||
SELECT pg_advisory_unlock(hashtext('crussell:giftcard:' || $1))
|
SELECT pg_advisory_unlock(hashtext('crussell:giftcard:' || $1))
|
||||||
@@ -976,38 +1002,44 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var sourceID string
|
var sourceID string
|
||||||
var savedCardID *string
|
var savedCardID *string
|
||||||
|
var savedCardCustomerID string
|
||||||
|
|
||||||
if req.NewCardToken != nil && *req.NewCardToken != "" {
|
if req.NewCardToken != nil && *req.NewCardToken != "" {
|
||||||
// P14: when the card is being SAVED, provision (or reuse) the user's
|
// A cnon: nonce charge needs NO card-on-file and NO customer (R6). The
|
||||||
// Square customer profile BEFORE tokenizing so the new card is created
|
// old code tokenized every new card via CreateCardOnFile even for
|
||||||
// against that customer. One-off non-save charges pass "" — a cnon:
|
// one-off non-save purchases, creating an orphan card at Square for a
|
||||||
// nonce charge needs no customer.
|
// payment that only ever uses the nonce once.
|
||||||
squareCustomerID := ""
|
|
||||||
if req.SaveCard {
|
if req.SaveCard {
|
||||||
var custErr error
|
// Save path: provision (or reuse) the user's Square customer BEFORE
|
||||||
squareCustomerID, custErr = paymentService.EnsureSquareCustomer(ctx, userID)
|
// 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 {
|
if custErr != nil {
|
||||||
log.Printf("Failed to provision Square customer for user %s: %v", userID, custErr)
|
log.Printf("Failed to provision Square customer for user %s: %v", userID, custErr)
|
||||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken, squareCustomerID)
|
||||||
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken, squareCustomerID)
|
if err != nil {
|
||||||
if err != nil {
|
log.Printf("Failed to create card on file: %v", err)
|
||||||
log.Printf("Failed to create card on file: %v", err)
|
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
return
|
||||||
return
|
}
|
||||||
}
|
sourceID = cardOnFile.CardID
|
||||||
|
savedCardCustomerID = squareCustomerID
|
||||||
|
|
||||||
sourceID = cardOnFile.CardID
|
cardID, err := paymentService.SaveCardForUser(ctx, userID, squareCustomerID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
|
||||||
|
|
||||||
if req.SaveCard {
|
|
||||||
cardID, err := paymentService.SaveCardForUser(ctx, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to save card: %v", err)
|
log.Printf("Failed to save card: %v", err)
|
||||||
} else {
|
} else {
|
||||||
savedCardID = &cardID
|
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 {
|
} else if req.CardID != nil {
|
||||||
card, err := paymentService.GetCardByID(ctx, *req.CardID, userID)
|
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)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
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
|
sourceID = card.SquareCardID
|
||||||
savedCardID = req.CardID
|
savedCardID = req.CardID
|
||||||
|
savedCardCustomerID = card.SquareCustomerID
|
||||||
}
|
}
|
||||||
|
|
||||||
amountPounds := float64(req.Amount) / 100.0
|
amountPounds := float64(req.Amount) / 100.0
|
||||||
@@ -1117,6 +1163,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
SourceID: sourceID,
|
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,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card Purchase",
|
Note: "Gift Card Purchase",
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func CalculateRefundForCancellation(
|
|||||||
// manual guard read precedes the cancellation's commit). Locks are acquired in
|
// manual guard read precedes the cancellation's commit). Locks are acquired in
|
||||||
// ascending payment_id order (matching processChargeGroup) to avoid deadlocks,
|
// ascending payment_id order (matching processChargeGroup) to avoid deadlocks,
|
||||||
// and only for card methods the manual handler can touch.
|
// 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
|
var ids []string
|
||||||
for _, p := range payments {
|
for _, p := range payments {
|
||||||
if p.PaymentMethod == "online_square" || p.PaymentMethod == "in_person_card" {
|
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)
|
sort.Strings(ids)
|
||||||
for _, pid := range 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)
|
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
|
locked := 0
|
||||||
for _, pid := range paymentIDs {
|
for _, pid := range paymentIDs {
|
||||||
if _, err := pinConn.Exec(ctx, `
|
// Bounded try-lock (R6) so the sweep never blocks a pool connection
|
||||||
SELECT pg_advisory_lock(hashtext('crussell:refund:' || $1))
|
// while the manual handler holds the same key across its Square call.
|
||||||
`, pid); err != nil {
|
ok, lockErr := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+pid)
|
||||||
log.Printf("Failed to acquire refund lock for payment %s (charge %s): %v", pid, chargeID, err)
|
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
|
break
|
||||||
}
|
}
|
||||||
locked++
|
locked++
|
||||||
@@ -1083,11 +1096,16 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer pinConn.Release()
|
defer pinConn.Release()
|
||||||
if _, err := pinConn.Exec(ctx, `
|
// Bounded try-lock (R6) so the sweep never blocks a pool connection while
|
||||||
SELECT pg_advisory_lock(hashtext('crussell:refund:' || $1))
|
// the manual handler holds the same key across its Square call.
|
||||||
`, paymentID); err != nil {
|
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+paymentID)
|
||||||
|
if err != nil {
|
||||||
return 0, err
|
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() {
|
defer func() {
|
||||||
if _, err := pinConn.Exec(context.Background(), `
|
if _, err := pinConn.Exec(context.Background(), `
|
||||||
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
|
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
|
||||||
|
|||||||
@@ -247,7 +247,9 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
// constraint after the funding already committed. Mirrors the gift-card
|
// constraint after the funding already committed. Mirrors the gift-card
|
||||||
// advisory-lock pattern (giftcards.go). Lock is keyed on the idempotency
|
// advisory-lock pattern (giftcards.go). Lock is keyed on the idempotency
|
||||||
// key so distinct sales are unaffected; falls back to a per-request key
|
// 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
|
lockKey := req.IdempotencyKey
|
||||||
if lockKey == "" {
|
if lockKey == "" {
|
||||||
lockKey = "till-" + rand.Text()
|
lockKey = "till-" + rand.Text()
|
||||||
@@ -259,13 +261,17 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer pinConn.Release()
|
defer pinConn.Release()
|
||||||
if _, err := pinConn.Exec(ctx, `
|
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:till:"+lockKey)
|
||||||
SELECT pg_advisory_lock(hashtext('crussell:till:' || $1))
|
if err != nil {
|
||||||
`, lockKey); err != nil {
|
|
||||||
log.Printf("Failed to acquire till-sale serialization lock for %s: %v", lockKey, err)
|
log.Printf("Failed to acquire till-sale serialization lock for %s: %v", lockKey, err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
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() {
|
defer func() {
|
||||||
if _, err := pinConn.Exec(context.Background(), `
|
if _, err := pinConn.Exec(context.Background(), `
|
||||||
SELECT pg_advisory_unlock(hashtext('crussell:till:' || $1))
|
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, `
|
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
|
FROM user_saved_cards
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
`, *req.UserSavedCardID).Scan(&savedCardSqCardID, &savedCardCustomerID)
|
`, *req.UserSavedCardID).Scan(&cardUserID, &savedCardSqCardID, &savedCardCustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get saved card details: %v", err)
|
log.Printf("Failed to get saved card details: %v", err)
|
||||||
http.Error(w, "Card not found", http.StatusNotFound)
|
http.Error(w, "Card not found", http.StatusNotFound)
|
||||||
return
|
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 == "" {
|
if req.IdempotencyKey == "" {
|
||||||
req.IdempotencyKey = uniqueTillKey()
|
req.IdempotencyKey = uniqueTillKey()
|
||||||
}
|
}
|
||||||
@@ -697,29 +731,23 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
||||||
} else if req.PaymentMethod == "online_square" {
|
} else if req.PaymentMethod == "online_square" {
|
||||||
// PCI-DSS: raw PANs are never accepted. The admin till must supply a
|
// 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 == "" {
|
if req.CardToken == "" {
|
||||||
log.Printf("online_square till sale missing card_token for gift card %s", giftCardID)
|
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)
|
http.Error(w, "card_token is required for online_square payment — use a Square Web Payments nonce", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Tokenize the till card. The reference_id is the synthetic
|
// Charge the cnon: nonce DIRECTLY (R6). The old code tokenized the
|
||||||
// "till-<giftCardID>" namespace, NOT a real user — this card is
|
// till card via CreateCardOnFile under a synthetic "till-<giftCardID>"
|
||||||
// ephemeral (used once for this charge) and is never stored in
|
// reference and charged the resulting ccof: id — but that card was
|
||||||
// user_saved_cards or re-listed. The prefix can't collide with a
|
// never saved or re-listed anywhere, so the CreateCardOnFile call was
|
||||||
// real CHAR(12)-hex user ID. No Square customer is provisioned for
|
// pure overhead AND would have charged a card-on-file source without a
|
||||||
// it ("" as the customerID): a cnon: nonce charge needs none.
|
// customer (Square rejects ccof without CustomerID). A cnon: nonce
|
||||||
cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken, "")
|
// needs neither card-on-file nor customer.
|
||||||
if cardErr != nil {
|
|
||||||
log.Printf("Failed to tokenize card: %v", cardErr)
|
|
||||||
http.Error(w, "Card tokenization failed", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
paymentReq := square.CreatePaymentReq{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: penceAmount,
|
Amount: penceAmount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
SourceID: cardOnFile.CardID,
|
SourceID: req.CardToken,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card " + req.Action,
|
Note: "Gift Card " + req.Action,
|
||||||
BuyerEmail: buyerEmail,
|
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
|
// path uses. Two concurrent polls of the same checkout could both run
|
||||||
// the UPDATE + VAT (idempotent today, but a double-apply is a latent
|
// 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.
|
// 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())
|
pinConn, err := db.Conn.Acquire(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to acquire connection for till-completion lock: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
defer pinConn.Release()
|
defer pinConn.Release()
|
||||||
if _, err := pinConn.Exec(r.Context(), `
|
lockOK, err := acquireAdvisoryLock(r.Context(), pinConn, "crussell:tillcomplete:"+tillSaleID)
|
||||||
SELECT pg_advisory_lock(hashtext('crussell:tillcomplete:' || $1))
|
if err != nil {
|
||||||
`, tillSaleID); err != nil {
|
|
||||||
log.Printf("Failed to acquire till-completion serialization lock for %s: %v", tillSaleID, err)
|
log.Printf("Failed to acquire till-completion serialization lock for %s: %v", tillSaleID, err)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
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() {
|
defer func() {
|
||||||
if _, err := pinConn.Exec(context.Background(), `
|
if _, err := pinConn.Exec(context.Background(), `
|
||||||
SELECT pg_advisory_unlock(hashtext('crussell:tillcomplete:' || $1))
|
SELECT pg_advisory_unlock(hashtext('crussell:tillcomplete:' || $1))
|
||||||
|
|||||||
Reference in New Issue
Block a user