fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes: - CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back - A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit) - A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds - A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows - A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs - A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point) - A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface - A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction - M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test - Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status) All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
@@ -518,15 +518,21 @@ func TestCreatePaymentHTTP_TipMoneyAbsentWhenNil(t *testing.T) {
|
||||
// PAYMENT_NOT_REFUNDABLE) map to ErrRefundDeclined, the money-in-flight codes
|
||||
// (PAYMENT_ALREADY_REFUNDED, REFUND_ALREADY_PENDING) map to
|
||||
// ErrRefundAlreadyProcessed, and ambiguous errors pass through unwrapped.
|
||||
// REFUND_AMOUNT_INVALID is special: Square returns it BOTH for a genuinely
|
||||
// invalid amount and for an already-refunded payment, so the client reconciles
|
||||
// via PaymentWasRefunded (GET /v2/refunds) — no existing refund → declined,
|
||||
// an existing COMPLETED/APPROVED/PENDING refund → already processed.
|
||||
func TestRefundPaymentHTTP_CodeClassification(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
wantErrIs error // nil = no sentinel expected
|
||||
refundList string // GET /v2/refunds body served to the PaymentWasRefunded reconciliation
|
||||
wantErrIs error // nil = no sentinel expected
|
||||
wantErrNil bool
|
||||
}{
|
||||
{name: "refund_declined", code: "REFUND_DECLINED", wantErrIs: ErrRefundDeclined},
|
||||
{name: "amount_invalid", code: "REFUND_AMOUNT_INVALID", wantErrIs: ErrRefundDeclined},
|
||||
{name: "amount_invalid_no_refund_reconciles_to_declined", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined},
|
||||
{name: "amount_invalid_existing_refund_reconciles_to_already_processed", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","location_id":"loc","reason":"cancellation","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed},
|
||||
{name: "payment_not_refundable", code: "PAYMENT_NOT_REFUNDABLE", wantErrIs: ErrRefundDeclined},
|
||||
{name: "already_refunded", code: "PAYMENT_ALREADY_REFUNDED", wantErrIs: ErrRefundAlreadyProcessed},
|
||||
{name: "already_pending", code: "REFUND_ALREADY_PENDING", wantErrIs: ErrRefundAlreadyProcessed},
|
||||
@@ -537,6 +543,17 @@ func TestRefundPaymentHTTP_CodeClassification(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet {
|
||||
// The PaymentWasRefunded reconciliation call (GET /v2/refunds)
|
||||
// must be answered with the configured refund list so the
|
||||
// REFUND_AMOUNT_INVALID branch runs end to end.
|
||||
body := tc.refundList
|
||||
if body == "" {
|
||||
body = `{"refunds":[]}`
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if tc.code == "" {
|
||||
_, _ = w.Write([]byte("plain text failure"))
|
||||
@@ -1783,3 +1800,232 @@ func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) {
|
||||
t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePaymentHTTP_CustomerDetailsWireShape verifies the customer_details
|
||||
// wiring on POST /v2/payments: when CustomerDetails is set
|
||||
// (CustomerInitiated=true — online card entry is always cardholder-initiated),
|
||||
// the request body carries customer_details.customer_initiated=true; when nil,
|
||||
// the field is omitted entirely (Square's default classification applies).
|
||||
func TestCreatePaymentHTTP_CustomerDetailsWireShape(t *testing.T) {
|
||||
t.Run("customer_initiated_true_is_sent", func(t *testing.T) {
|
||||
var captured map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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(`{"payment":{"id":"pay_cd","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd",
|
||||
CustomerDetails: &CreateCustomerDetails{CustomerInitiated: true},
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createPaymentHTTP failed: %v", err)
|
||||
}
|
||||
cd, ok := captured["customer_details"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected customer_details object, got %v", captured["customer_details"])
|
||||
}
|
||||
if cd["customer_initiated"] != true {
|
||||
t.Errorf("expected customer_details.customer_initiated=true, got %v", cd["customer_initiated"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil_customer_details_is_omitted", func(t *testing.T) {
|
||||
var captured map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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(`{"payment":{"id":"pay_cd2","status":"COMPLETED","total_money":{"amount":1000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"MASTERCARD","last_4":"4444"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
_, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
|
||||
Amount: 1000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd-nil",
|
||||
}, hc)
|
||||
if err != nil {
|
||||
t.Fatalf("createPaymentHTTP failed: %v", err)
|
||||
}
|
||||
if _, present := captured["customer_details"]; present {
|
||||
t.Errorf("expected customer_details to be ABSENT when CustomerDetails is nil, got %v", captured["customer_details"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentWasRefunded verifies the exported PaymentWasRefunded reconciliation
|
||||
// helper: an existing COMPLETED/APPROVED/PENDING refund for the payment means
|
||||
// money has moved (true), a FAILED/rejected refund or an empty list means
|
||||
// nothing moved (false), and a server error propagates as an error.
|
||||
func TestPaymentWasRefunded(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
refundList string
|
||||
statusCode int
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "completed_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_c","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
|
||||
{name: "approved_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_a","status":"APPROVED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
|
||||
{name: "pending_refund_means_money_in_flight", refundList: `{"refunds":[{"id":"ref_p","status":"PENDING","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true},
|
||||
{name: "failed_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_f","status":"FAILED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
|
||||
{name: "rejected_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_r","status":"REJECTED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
|
||||
{name: "empty_list_means_nothing_moved", refundList: `{"refunds":[]}`, want: false},
|
||||
{name: "other_payment_refund_is_filtered_out", refundList: `{"refunds":[{"id":"ref_o","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}]}`, want: false},
|
||||
{name: "server_error_propagates", refundList: `{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INTERNAL_SERVER_ERROR","detail":"boom"}]}`, statusCode: http.StatusInternalServerError, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v2/refunds" {
|
||||
t.Errorf("expected /v2/refunds, got %s", r.URL.Path)
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "begin_time=") {
|
||||
t.Errorf("expected begin_time in query, got %q", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if tc.statusCode != 0 {
|
||||
w.WriteHeader(tc.statusCode)
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.refundList))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
got, err := paymentWasRefundedWithClient(context.Background(), "pay_1", hc)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got wasRefunded=%v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("paymentWasRefunded failed: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("paymentWasRefunded = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundPaymentHTTP_RefundAmountInvalidReconciliation locks the
|
||||
// REFUND_AMOUNT_INVALID reconciliation end-to-end: Square returns that code BOTH
|
||||
// for a genuinely invalid refund amount AND for an already-refunded payment, so
|
||||
// refundPaymentHTTP re-checks the refund list (GET /v2/refunds) before
|
||||
// classifying — an existing COMPLETED refund → ErrRefundAlreadyProcessed (money
|
||||
// already moved), an empty list → ErrRefundDeclined (mark failed, never retry).
|
||||
func TestRefundPaymentHTTP_RefundAmountInvalidReconciliation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
refundList string
|
||||
wantErrIs error
|
||||
}{
|
||||
{name: "existing_exact_amount_completed_refund_reconciles_to_already_processed", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed},
|
||||
{name: "partial_refund_does_not_cover_requested_amount_reconciles_to_declined", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundDeclined},
|
||||
{name: "no_refunds_reconciles_to_declined", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var reconcileGETs int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet {
|
||||
// The PaymentWasRefunded reconciliation call must hit
|
||||
// GET /v2/refunds for the payment.
|
||||
reconcileGETs++
|
||||
if r.URL.Path != "/v2/refunds" {
|
||||
t.Errorf("expected reconciliation GET /v2/refunds, got %s", r.URL.Path)
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "begin_time=") {
|
||||
t.Errorf("expected begin_time in reconciliation query, got %q", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.refundList))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"REFUND_AMOUNT_INVALID","detail":"The refunded amount is more than the remaining balance"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
_, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{
|
||||
PaymentID: "pay_rec", Amount: 5000, IdempotencyKey: "ik-rec",
|
||||
}, hc)
|
||||
if err == nil {
|
||||
t.Fatal("expected REFUND_AMOUNT_INVALID rejection error")
|
||||
}
|
||||
if reconcileGETs == 0 {
|
||||
t.Error("expected the client to reconcile against GET /v2/refunds before classifying")
|
||||
}
|
||||
if !errors.Is(err, tc.wantErrIs) {
|
||||
t.Errorf("expected errors.Is(%v), got %v", tc.wantErrIs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSCACodes_ClassifyAsDefinitivePaymentErrors locks the SCA / buyer-verification
|
||||
// classification: the seven Square verification codes all mean the buyer must
|
||||
// re-verify (3DS/SCA) or re-tokenize the card, NOT that the same request should
|
||||
// be retried — so each must classify as a definitive payment error via
|
||||
// IsDefinitivePaymentError and surface its code through ErrorCode.
|
||||
func TestSCACodes_ClassifyAsDefinitivePaymentErrors(t *testing.T) {
|
||||
scaCodes := []string{
|
||||
"CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
"VERIFICATION_TOKEN_EXPIRED",
|
||||
"VERIFICATION_TOKEN_INVALID",
|
||||
"CVV_VERIFICATION_REQUIRED",
|
||||
"ADDRESS_VERIFICATION_REQUIRED",
|
||||
"MISSING_PIN",
|
||||
"MISSING_VERIFICATION_TOKEN",
|
||||
}
|
||||
for _, code := range scaCodes {
|
||||
t.Run(code, func(t *testing.T) {
|
||||
err := &squareAPIError{
|
||||
Code: code, Category: "PAYMENT_METHOD_ERROR", StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: " + code),
|
||||
}
|
||||
if !IsDefinitivePaymentError(err) {
|
||||
t.Errorf("IsDefinitivePaymentError(%s) must be true — SCA codes are definitive", code)
|
||||
}
|
||||
if got := ErrorCode(err); got != code {
|
||||
t.Errorf("expected ErrorCode %s, got %q", code, got)
|
||||
}
|
||||
// Handlers wrap the client error before classifying — the accessor
|
||||
// must see through the wrap.
|
||||
if !IsDefinitivePaymentError(fmt.Errorf("wrap: %w", err)) {
|
||||
t.Errorf("IsDefinitivePaymentError must work through a wrapped error for %s", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidRequestError_IsCategoryNotCode locks the category/code distinction:
|
||||
// INVALID_REQUEST_ERROR is a Square error CATEGORY, never an error CODE — so
|
||||
// ErrorCategory surfaces it while ErrorCode must NOT (ErrorCode returns "" for
|
||||
// a code-less squareAPIError), and it must never classify as definitive.
|
||||
func TestInvalidRequestError_IsCategoryNotCode(t *testing.T) {
|
||||
err := &squareAPIError{
|
||||
Category: "INVALID_REQUEST_ERROR", StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: invalid request"),
|
||||
}
|
||||
if got := ErrorCategory(err); got != "INVALID_REQUEST_ERROR" {
|
||||
t.Errorf("expected ErrorCategory INVALID_REQUEST_ERROR, got %q", got)
|
||||
}
|
||||
if got := ErrorCode(err); got != "" {
|
||||
t.Errorf("expected ErrorCode \"\" for a category-only error, got %q", got)
|
||||
}
|
||||
if IsDefinitivePaymentError(err) {
|
||||
t.Error("INVALID_REQUEST_ERROR is a category, never a definitive payment code")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user