Fix P0/P1 review findings: truncation, raw-PAN API edge, refund lock, till pending-retry, idempotency keys

P0 — float truncation: applied math.Round to all remaining int64(x*100)
sites (till penceAmount, refund over-refund guard, GetAlreadyRefundedAmount,
payment summary conversions). A £1.14 till sale previously charged 113p.

P0 — raw PAN stopped at the API edge:
- Deleted CardNumber/CardExpMonth/CardExpYear/CardCVC from TillSaleRequest
  and CardNumber/Expiry/CVC from CreatePaymentMethodRequest. Both now accept
  card_token (Square nonce) and return 400 when absent. PAN+CVV no longer
  transit the application server (PCI-DSS SAQ-A scope).
- Deleted CreateCardOnFileRaw from the SquareClient interface and all
  implementations (MockClient, ProdClient, devProdClient).
- Added idempotency_key column to refunds table (UNIQUE).

P0 — RefundPayment hardened: advisory lock on payment ID (prevents two
concurrent refunds passing the over-refund guard), pending-refund-record-
then-Square pattern (scheduler reprocesses on failure), same-key dedup.

P1 — till sale pending-retry now re-attempts the Square charge instead of
returning the stale 'pending' status (gift card was already funded in the
committed tx — silent money loss otherwise). Sale row reused, not duplicated.

P1 — idempotency key caching in frontend: BuyGiftCard and
UserPaymentModal/BookingFlow now cache the key per amount+card, regenerated
on change and cleared on success — matches the tip-flow pattern so a
lost-response retry dedups instead of double-charging.

P1 — CreateTerminalPayment cash/giftcard INSERTs now persist idempotency_key.
Key is unique per payment (booking+type+amount would wrongly dedup two
legitimate identical payments, e.g. two £50 cash receipts).

P1 — gift-card codes no longer logged (spendable credential; value+recipient
only).

Tests: till pending-retry re-attempt, refund same-key dedup, mock CreatePayment
idempotency dedup, CreatePaymentMethod nonce happy path + raw-PAN rejection,
till online_square card_token required/valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent d54f526b56
commit 54f6bf3c1a
14 changed files with 632 additions and 345 deletions
+10 -29
View File
@@ -10,8 +10,7 @@ import (
"fmt"
"log"
"log/slog"
"strconv"
"strings"
"math"
"time"
"github.com/jackc/pgx/v5"
@@ -359,7 +358,7 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID
if err != nil {
return 0, err
}
return int64(amount * 100), nil
return int64(math.Round(amount * 100)), nil
}
func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID string) (bool, error) {
@@ -501,29 +500,11 @@ func (s *PaymentService) DeletePaymentMethod(ctx context.Context, cardID, userID
return nil
}
func (s *PaymentService) CreatePaymentMethodFromDetails(ctx context.Context, userID, cardNumber, expiry, cvc string) (*SavedCard, error) {
parts := strings.Split(expiry, "/")
if len(parts) != 2 {
return nil, errors.New("invalid expiry format, use MM/YY")
}
expMonth, err := strconv.Atoi(parts[0])
if err != nil || expMonth < 1 || expMonth > 12 {
return nil, errors.New("invalid expiry month")
}
expYear, err := strconv.Atoi(parts[1])
if err != nil || expYear < 0 || expYear > 99 {
return nil, errors.New("invalid expiry year")
}
expYear += 2000
// Check if card is expired
now := clock.Now()
expiryDate := time.Date(expYear, time.Month(expMonth), 1, 0, 0, 0, 0, time.UTC)
if expiryDate.Before(now) {
return nil, errors.New("card has expired")
}
cardOnFile, err := SquareClient.CreateCardOnFileRaw(ctx, userID, cardNumber, expMonth, expYear, cvc)
func (s *PaymentService) CreatePaymentMethodFromToken(ctx context.Context, userID, cardToken string) (*SavedCard, error) {
// PCI-DSS: raw PANs are never accepted. The client must supply a Square
// Web Payments nonce (cnon:xxx), which the backend tokenizes via the
// Cards API — the full PAN exists only inside Square's vault.
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, cardToken)
if err != nil {
return nil, fmt.Errorf("failed to tokenize card: %w", err)
}
@@ -535,7 +516,7 @@ func (s *PaymentService) CreatePaymentMethodFromDetails(ctx context.Context, use
SELECT $1, $2, $3, $4, $5, $6, $7,
NOT EXISTS(SELECT 1 FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL)
RETURNING id, is_default
`, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, expMonth, expYear, cardOnFile.Fingerprint).Scan(&savedCardID, &isDefault)
`, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint).Scan(&savedCardID, &isDefault)
if err != nil {
return nil, fmt.Errorf("failed to save card: %w", err)
}
@@ -545,8 +526,8 @@ func (s *PaymentService) CreatePaymentMethodFromDetails(ctx context.Context, use
SquareCardID: cardOnFile.CardID,
Brand: cardOnFile.Brand,
Last4: cardOnFile.Last4,
ExpMonth: expMonth,
ExpYear: expYear,
ExpMonth: cardOnFile.ExpMonth,
ExpYear: cardOnFile.ExpYear,
Fingerprint: cardOnFile.Fingerprint,
IsDefault: isDefault,
}, nil