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
+165 -23
View File
@@ -22,18 +22,21 @@ package square
//
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus) that let dev/tests
// drive Square failure modes that are otherwise only reachable against the real
// API. FailAfterCommit simulates the exact "charged but response lost → same-key
// retry" prod scenario: CreatePayment COMMITS the charge (retaining the key
// and source in the ledgers exactly like a successful charge) and THEN returns
// a 5xx-style error to the caller. A subsequent CreatePayment with the SAME
// key + SAME source dedups to the committed payment, proving no double charge.
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus,
// SimulateVerificationRequired) that let dev/tests drive Square failure modes
// that are otherwise only reachable against the real API. FailAfterCommit
// simulates the exact "charged but response lost → same-key retry" prod
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
// the ledgers exactly like a successful charge) and THEN returns a 5xx-style
// error to the caller. A subsequent CreatePayment with the SAME key + SAME
// source dedups to the committed payment, proving no double charge.
// SimulateSourceUsed simulates Square's SOURCE_USED rejection of a card source
// (cnon: nonce) reused after a previous save. ForcePaymentStatus forces
// CreatePayment's payment status while returning nil error — the "Square
// returned 200 with a non-terminal payment" prod scenario, so a status-blind
// handler (records 'completed' on nil error alone) is caught in dev.
// SimulateVerificationRequired mirrors Square's SCA enforcement on
// customer-initiated new-card charges (see the field doc).
//
// REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to
// the real PRODUCTION Square API on an env-string match alone — a typo'd or
@@ -114,18 +117,24 @@ type MockClient struct {
// committed payment — never a second charge — exercising the retry path
// devs hit in prod when Square processes a charge but the response is lost.
FailAfterCommit bool
// SimulateSourceUsed makes CreateCardOnFile enforce Square's SOURCE_USED
// rejection: a card source (cnon: nonce) already used to create a card on
// this mock instance is rejected with the same structured 400 SOURCE_USED
// error real Square's CreateCard API returns (SOURCE_USED — NOT the
// CreatePayment code CARD_TOKEN_USED). Off by default — dev/test flows
// reuse plain "cnon:test-card"-style tokens across requests, so enforcement
// is enabled only in tests that exercise the reused-source rejection.
// UsedSources() reports the sources consumed so far.
// SimulateSourceUsed enforces Square's single-use source simulation on both
// endpoints: CreateCardOnFile rejects a card source (cnon: nonce) already
// used to create a card with the structured 400 SOURCE_USED error real
// Square's CreateCard API returns, and CreatePayment rejects a cnon nonce
// already used to create a payment or card with 400 CARD_TOKEN_USED. Off by
// default — the handler integration suite shares ONE mock instance across
// parallel tests (testmain_test.go) and reuses "cnon:test-card"-style
// tokens across requests, so enforcement is enabled only in tests that
// exercise the reused-source rejection. UsedSources() reports the sources
// consumed so far.
SimulateSourceUsed bool
// usedSources records card sources consumed by CreateCardOnFile while
// SimulateSourceUsed is enabled (Square consumes a cnon: nonce on card
// creation, so reusing it is rejected with SOURCE_USED).
// creation, so reusing it is rejected with SOURCE_USED). CreatePayment's
// single-use nonce simulation shares the same map: with the toggle on, a
// cnon consumed by either endpoint is rejected on reuse (CARD_TOKEN_USED
// from CreatePayment, SOURCE_USED from CreateCardOnFile) — exactly like
// real Square, which consumes a nonce regardless of which endpoint used it.
usedSources map[string]bool
// ForcePaymentStatus forces CreatePayment's payment status instead of the
// default "COMPLETED" (or "APPROVED" for autocomplete=false). When set,
@@ -137,6 +146,16 @@ type MockClient struct {
// see square_http_client.go), so only the handler's own status check can
// catch a FAILED/CANCELED/PENDING/APPROVED payment.
ForcePaymentStatus string
// SimulateVerificationRequired mirrors Square's SCA enforcement on
// customer-initiated new-card charges: when true, CreatePayment with a
// cnon: (new-card nonce) source that carries no VerificationToken is
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED —
// the buyer must complete 3DS/SCA verification and re-tokenize, NOT retry
// the same request (the code is in definitivePaymentCodes). A present
// verification token (e.g. a verify_mock_... token) satisfies the gate and
// the charge succeeds. Off by default — existing dev/test flows charge
// plain "cnon:test-card"-style tokens without verification tokens.
SimulateVerificationRequired bool
}
type devProdClient struct{}
@@ -169,6 +188,9 @@ func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) e
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
return refundPaymentHTTP(ctx, req)
}
func (d *devProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
return PaymentWasRefunded(ctx, paymentID)
}
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
}
@@ -338,6 +360,47 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
}
}
// Mirror Square's SCA enforcement (opt-in toggle, off by default): a
// new-card (cnon:) charge without a 3DS/SCA verification token is rejected
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
// must re-verify and re-tokenize, NOT retry the same request (the code is
// in definitivePaymentCodes). A present verification token (e.g.
// verify_mock_...) satisfies the gate exactly as production accepts a
// Square-issued verification_token on the CreatePayment body.
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" {
return nil, &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
}
}
// Mirror Square's single-use card nonces: when SimulateSourceUsed is set,
// a cnon: nonce can only be charged once on this mock instance. Square
// consumes a nonce when it is used to create a payment, so reusing it
// under a DIFFERENT idempotency key is rejected with CARD_TOKEN_USED (the
// CreatePayment code for a used source) — a same-key retry already deduped
// above and never reaches here. The consumption is OFF by default: the
// handler integration suite shares ONE mock instance across parallel tests
// (testmain_test.go assigns a single square.NewDevClient() to the package
// global) and reuses "cnon:test-card"-style tokens across tests, so
// default-on consumption would break those tests. Tests that need the
// single-use simulation flip the toggle on.
if strings.HasPrefix(req.SourceID, "cnon:") && m.SimulateSourceUsed {
if m.usedSources[req.SourceID] {
return nil, &squareAPIError{
Code: "CARD_TOKEN_USED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "The card nonce can no longer be used because it has been used to create a payment",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: card nonce %s has already been used to create a payment", tokenPrefix(req.SourceID)),
}
}
m.usedSources[req.SourceID] = true
}
now := clock.Now().UTC()
status := "COMPLETED"
@@ -712,15 +775,62 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
return nil, fmt.Errorf("square: refund amount must be positive (amount_money is required)")
}
if _, ok := m.payments[req.PaymentID]; !ok {
// Payment not in mock map — this happens when integration tests
// create payments via DB fixture with a square_payment_id, bypassing
// the mock. Process the refund without full payment data.
log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID)
// Reject refunds for a mock-artifact payment ID that was never created.
// The mock mints payment IDs as "pay_mock_<n>"; a refund targeting such an
// ID that is NOT in the ledger is a provable bug (that charge never went
// through this mock) and real Square answers 404 NOT_FOUND. Non-"pay_mock_"
// IDs (e.g. the DB-fixture square_payment_id values handler tests seed
// refunds against) are payments that exist outside the mock's ledger —
// exactly as they would at real Square — so they take the lenient path.
if strings.HasPrefix(req.PaymentID, "pay_mock_") {
if _, known := m.payments[req.PaymentID]; !known {
log.Printf("[SQUARE-MOCK] RefundPayment REJECTED: payment %s not found (NOT_FOUND)", req.PaymentID)
return nil, &squareAPIError{
Code: "NOT_FOUND",
Category: "INVALID_REQUEST_ERROR",
Detail: "The payment_id in the refund request does not exist",
StatusCode: http.StatusNotFound,
err: fmt.Errorf("square: no payment %s exists to refund", tokenPrefix(req.PaymentID)),
}
}
}
amount := req.Amount
// Known payments get the real Square over-refund rejection: refunding more
// than the remaining balance answers 400 REFUND_AMOUNT_INVALID. Square
// returns that SAME code for an already-refunded payment, so — exactly like
// the real client — the mock reconciles: an existing refund (money already
// moved) → ErrRefundAlreadyProcessed; no refund recorded → the amount is
// genuinely invalid → ErrRefundDeclined.
if payment, ok := m.payments[req.PaymentID]; ok {
remaining := payment.Amount
for _, r := range m.refunds {
if r.PaymentID == req.PaymentID && (r.Status == "COMPLETED" || r.Status == "APPROVED" || r.Status == "PENDING") {
remaining -= r.Amount
}
}
if req.Amount > remaining {
apiErr := &squareAPIError{
Code: "REFUND_AMOUNT_INVALID",
Category: "INVALID_REQUEST_ERROR",
Detail: "The refunded amount is more than the remaining balance",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: refund amount %d exceeds remaining balance %d for payment %s", req.Amount, remaining, req.PaymentID),
}
if remaining < payment.Amount {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, apiErr)
}
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, apiErr)
}
} else {
// Payment not in mock map — this happens when integration tests create
// payments via DB fixture with a square_payment_id, bypassing the mock.
// Process the refund without full payment data (the balance is unknown,
// so no over-refund check applies).
log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID)
}
locationID := req.LocationID
if locationID == "" {
locationID = "L_MOCK"
@@ -758,6 +868,26 @@ func (m *MockClient) RefundKeyCount() int {
return len(m.refundByKey)
}
// PaymentWasRefunded mirrors the real client's reconciliation source: true when
// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment
// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact
// status set the real client's paymentWasRefundedWithClient uses so handler
// reconciliation behaves identically in dev/mock and production.
func (m *MockClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, r := range m.refunds {
if r.PaymentID != paymentID {
continue
}
switch r.Status {
case "COMPLETED", "APPROVED", "PENDING":
return true, nil
}
}
return false, nil
}
// UsedSources returns the card sources consumed by CreateCardOnFile while
// SimulateSourceUsed is enabled. Test accessor for asserting that a reused
// source is rejected with SOURCE_USED after a previous save.
@@ -883,14 +1013,26 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
m.mu.Lock()
defer m.mu.Unlock()
// Production callers pass the DB-stored ccof: card reference
// (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must resolve
// through cardByToken (keyed by the full CardID) so the deletion actually
// finds and disables the card — previously the mock keyed only by its
// mock-local ID (mock_card_...) and silently missed every ccof: call.
if card, ok := m.cardByToken[cardID]; ok {
card.Enabled = false
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", tokenPrefix(cardID), tokenPrefix(card.ReferenceID))
return nil
}
// Fallback for the mock-local ID form (mock_card_...) still exercised by
// this package's own tests — resolve the card through the per-user maps.
for userID, cards := range m.cards {
if card, ok := cards[cardID]; ok {
card.Enabled = false
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", cardID, userID)
log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", tokenPrefix(cardID), userID)
return nil
}
}
return fmt.Errorf("card not found: %s", cardID)
return fmt.Errorf("square: card not found: %s", cardID)
}
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {