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:
@@ -789,7 +789,7 @@ func TestCreateTillSale_SavedCardNoUserSavedCardID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTillSale_OnlineSquareNoCardNumber(t *testing.T) {
|
||||
func TestCreateTillSale_OnlineSquareNoCardToken(t *testing.T) {
|
||||
_, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
@@ -823,6 +823,128 @@ func TestCreateTillSale_OnlineSquareNoCardNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTillSale_OnlineSquareWithToken(t *testing.T) {
|
||||
_, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 1000,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:visa",
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_PendingRetry_ReattemptsSquare verifies that a same-key
|
||||
// retry after a failed Square charge (sale stuck 'pending', gift card already
|
||||
// funded) re-attempts the charge and completes the sale — it must NOT return
|
||||
// the stale 'pending' status without re-charging (silent money loss).
|
||||
func TestCreateTillSale_PendingRetry_ReattemptsSquare(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create saved card: %v", err)
|
||||
}
|
||||
|
||||
// Seed a PENDING till_sale with the same key and a funded gift card —
|
||||
// simulates a prior attempt where the Square charge failed after the DB
|
||||
// transaction committed (card already funded).
|
||||
key := "till-pending-retry-key"
|
||||
var giftCardID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
|
||||
RETURNING id
|
||||
`, adminID).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create gift card: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
||||
$2, $3, $4, $5, NOW(), NOW())
|
||||
`, giftCardID, userID, cardID, key, adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending till sale: %v", err)
|
||||
}
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "saved_card",
|
||||
UserSavedCardID: &cardID,
|
||||
UserID: &userID,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The sale must now be 'completed' (Square re-attempted and succeeded).
|
||||
var saleStatus string
|
||||
var saleCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query till sale: %v", err)
|
||||
}
|
||||
if saleCount != 1 {
|
||||
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
|
||||
}
|
||||
if saleStatus != "completed" {
|
||||
t.Errorf("expected pending sale to be completed after retry, got %s", saleStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTillSale_CreateCash(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user