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
+10 -2
View File
@@ -5,6 +5,7 @@ import (
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"database/sql"
"errors"
"fmt"
"log"
@@ -296,6 +297,9 @@ func (s *PaymentService) CheckIdempotency(ctx context.Context, bookingID, idempo
func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyKey string) (*PaymentRecord, error) {
var p PaymentRecord
// booking_id and gift_card_id are NULL for gift-card purchases — scan into
// NullString to avoid "cannot scan NULL into *string".
var bookingID, giftCardID sql.NullString
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
@@ -304,11 +308,15 @@ func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyK
FROM payments
WHERE idempotency_key = $1
`, idempotencyKey).Scan(
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
&p.ID, &bookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID,
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &giftCardID,
)
p.BookingID = bookingID.String
if giftCardID.Valid {
p.GiftCardID = &giftCardID.String
}
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {