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:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
@@ -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")
}
}