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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user