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:
2026-08-22 00:34:49 +01:00
parent 3db8b54923
commit 4f5dd5c426
9 changed files with 231 additions and 21 deletions
+21 -5
View File
@@ -28,6 +28,7 @@ type MockClient struct {
cards map[string]map[string]*CardOnFile
checkouts map[string]*CheckoutResult
payments map[string]*PaymentResult
paymentByKey map[string]*PaymentResult
refunds map[string]*RefundResult
completed map[string]*PaymentResult
HoldCheckouts bool
@@ -73,11 +74,12 @@ func NewDevClient() SquareClient {
}
log.Println("[SQUARE-MOCK] Using in-memory mock client")
return &MockClient{
cards: make(map[string]map[string]*CardOnFile),
checkouts: make(map[string]*CheckoutResult),
payments: make(map[string]*PaymentResult),
refunds: make(map[string]*RefundResult),
completed: make(map[string]*PaymentResult),
cards: make(map[string]map[string]*CardOnFile),
checkouts: make(map[string]*CheckoutResult),
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
refunds: make(map[string]*RefundResult),
completed: make(map[string]*PaymentResult),
}
}
@@ -106,6 +108,17 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
m.mu.Lock()
defer m.mu.Unlock()
// Real Square dedups on idempotency key: a retry with the same key returns
// the original payment rather than creating a second charge. The mock
// mirrors this so dev/testing behaves like production (also why the tip
// retry regression test can rely on the mock).
if req.IdempotencyKey != "" {
if existing, ok := m.paymentByKey[req.IdempotencyKey]; ok {
log.Printf("[SQUARE-MOCK] CreatePayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
return existing, nil
}
}
now := clock.Now().UTC()
status := "COMPLETED"
@@ -162,6 +175,9 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
}
m.payments[paymentID] = result
m.payments[result.SquarePayID] = result
if req.IdempotencyKey != "" {
m.paymentByKey[req.IdempotencyKey] = result
}
log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees)
return result, nil
}
@@ -297,7 +297,10 @@ func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResul
}
func createCheckoutHTTP(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
hc := newHTTPClient()
return createCheckoutHTTPWithClient(ctx, req, newHTTPClient())
}
func createCheckoutHTTPWithClient(ctx context.Context, req CreateCheckoutReq, hc *httpClient) (*CheckoutResult, error) {
body := sqTerminalCheckoutRequest{
IdempotencyKey: req.IdempotencyKey,
Checkout: sqTerminalCheckoutPayload{
@@ -2,7 +2,13 @@
package square
import "testing"
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestPaymentFromSquare_ElseBranch_SurfacesBrandWithoutCardID(t *testing.T) {
p := &sqPayment{
@@ -43,3 +49,54 @@ func TestPaymentFromSquare_NilCardDetails(t *testing.T) {
t.Errorf("expected amount 2500, got %d", result.Amount)
}
}
// TestCreateCheckoutHTTP_DeviceOptionsWireShape verifies the terminal checkout
// request puts device_id inside checkout.device_options (Square's required
// shape), not at the top level. A top-level device_id is rejected with 400 by
// Square's real API.
func TestCreateCheckoutHTTP_DeviceOptionsWireShape(t *testing.T) {
var captured map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/v2/terminals/checkouts" {
t.Errorf("expected /v2/terminals/checkouts, got %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Errorf("failed to decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_test","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "test-token", http: srv.Client()}
_, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
Amount: 5000,
Currency: "GBP",
IdempotencyKey: "ik-1",
DeviceID: "dvc_test",
}, hc)
if err != nil {
t.Fatalf("createCheckoutHTTP failed: %v", err)
}
checkout, ok := captured["checkout"].(map[string]any)
if !ok {
t.Fatalf("expected checkout object in body, got %v", captured)
}
// device_id must NOT be at the top level
if _, hasTopLevel := captured["device_id"]; hasTopLevel {
t.Errorf("device_id must not be top-level in terminal checkout request: %v", captured)
}
// device_id must live under checkout.device_options
devOpts, ok := checkout["device_options"].(map[string]any)
if !ok {
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
}
if devOpts["device_id"] != "dvc_test" {
t.Errorf("expected checkout.device_options.device_id = dvc_test, got %v", devOpts)
}
}