Fix review findings: BuyGiftCard concurrency lock, amount guards, NULL scan, mock dedup, docs
N1 (HIGH) — BuyGiftCard concurrent same-key retry could double-issue gift cards (2× value for 1 charge). Added pg_advisory_lock on the idempotency key (mirroring the tip pattern) acquired before the idempotency check, so concurrent same-key retries serialize and only one executes gift-card creation. N2 — Amount-equality guards in both reuse branches (CreateTipPayment and BuyGiftCard). A same-key retry with a different amount now returns 400 instead of silently mutating the pending record's books/VAT/refund caps. N3 — test coverage: - TestBuyGiftCard_RetryPending_ReattemptsCharge: pending record + same-key retry re-attempts, reuses the record (count=1), completes, and issues the gift card exactly once. - TestCreateCheckoutHTTP_DeviceOptionsWireShape: httptest.Server asserts device_id is under checkout.device_options (not top-level). Extracted createCheckoutHTTPWithClient for injectable base URL. - MockClient.CreatePayment now dedups on idempotency key (paymentByKey map), matching real Square behaviour. N4 — Corrected the savepoint comments in handlers.go and giftcards.go: the savepoint only exists in the test harness; in production db.Conn.Begin is a plain tx and the status UPDATE runs on a separate pooled connection. Commit is a harmless no-op in prod but required in tests. Bonus bug fixed: CheckIdempotencyByKey scanned NULL booking_id/gift_card_id (gift-card purchases) into plain string, failing with 'cannot scan NULL'. Now uses sql.NullString. Docs: Technical Manual.md:53 and Feature Catalog.md (2.1, 2.5) corrected — no longer claim Web Payments SDK is live; new-card entry is documented as pending P11, saved-card flow works via ccof tokens, dev mock rejects raw PANs.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -891,6 +892,37 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
paymentService := NewPaymentService()
|
||||
|
||||
// Serialize gift-card purchase attempts on the idempotency key to prevent
|
||||
// concurrent same-key retries from both reusing a pending record and both
|
||||
// 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.
|
||||
lockKey := req.IdempotencyKey
|
||||
if lockKey == "" {
|
||||
lockKey = userID
|
||||
}
|
||||
pinConn, err := db.Conn.Acquire(ctx)
|
||||
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()
|
||||
if _, err := pinConn.Exec(ctx, `
|
||||
SELECT pg_advisory_lock(hashtext('crussell:giftcard:' || $1))
|
||||
`, lockKey); 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
|
||||
}
|
||||
defer func() {
|
||||
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'.
|
||||
// A 'pending' record means the previous Square call failed — returning it
|
||||
// as 200 would show a success without ever charging. Re-attempt below with
|
||||
@@ -909,6 +941,14 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if existing.Status == "pending" {
|
||||
// Guard the amount: a retry with a different amount must not
|
||||
// reuse the pending record (gift card would be issued at the
|
||||
// new amount against the old charge record).
|
||||
if int64(existing.Amount*100) != req.Amount {
|
||||
log.Printf("Gift card retry amount mismatch: pending record %s has %.2f, request has %d pence", existing.ID, existing.Amount, req.Amount)
|
||||
http.Error(w, "Amount does not match the pending gift card payment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
reusePendingID = existing.ID
|
||||
log.Printf("[PAYMENTS] Reusing pending payment %s for idempotent gift-card retry (key %s)", existing.ID, req.IdempotencyKey)
|
||||
}
|
||||
@@ -1015,10 +1055,11 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the (nested/savepoint) transaction in the reuse path too. It is
|
||||
// empty, but committing releases the savepoint so the deferred rollback
|
||||
// becomes a no-op — otherwise that rollback would undo the status UPDATE
|
||||
// executed later on the same connection.
|
||||
// Commit in the reuse path too. No rows were written, but the commit is
|
||||
// required in the test harness: there the context carries an outer test tx,
|
||||
// so Begin creates a nested savepoint whose deferred rollback would
|
||||
// otherwise undo the status UPDATE executed later on the same connection.
|
||||
// In production Begin is a plain tx and this commit is a harmless no-op.
|
||||
if reusePendingID != "" {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit buy transaction (reuse): %v", err)
|
||||
|
||||
Reference in New Issue
Block a user