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