//go:build dev package square // KNOWN LIMITATION — THIS MOCK IS IN-MEMORY ONLY. Every ledger map below // (payments, paymentByKey, paymentSource, cards, cardByToken, checkouts, // completed, refunds, refundByKey, customers) lives for the lifetime of the // process and is reset on ANY dev-server restart. There is intentionally NO // persistence — this is a dev mock, not a store. // // Money-state consequence: a keyed pending row that is replayed AFTER a // restart looks like an UNKNOWN idempotency key to the fresh mock, so the // replay takes the unknown-key path — a spent/expired cnon: nonce is rejected // (ErrReplayKeyNotRetained → the sweep DEFINITIVELY fails the row, and a till // sale's funded gift card is clawed back) where prod would still hold the // ORIGINAL payment under the retained key and return it. A test that // "simulates a restart" with a fresh MockClient is therefore exercising the // prod UNKNOWN-KEY case, NOT the prod retained-key case — do not read such a // test as evidence of how prod treats a retained key after a restart. If a // test needs retained-key behaviour, it must re-seed the payment under the key // into the same mock instance (see TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued). // // FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail, // FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout, // 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 // leftover SQUARE_ENVIRONMENT=production in a dev shell would otherwise create // REAL charges from test bookings. NewDevClient therefore HARD-FAILS (panics // with errDevRealAPIRequiresOverride) when SQUARE_ENVIRONMENT=production // unless the explicit override SQUARE_ALLOW_REAL_API=1 is set, and logs a loud // banner before routing a dev build to the SANDBOX. The non-dev build // (square.go, `//go:build !dev`) is untouched: NewProdClient always uses the // real client path selected by the normal non-dev wiring. import ( "context" "crussell/clock" "crypto/sha256" "encoding/json" "errors" "fmt" "log" "net/http" "os" "strings" "sync" "time" ) var Client SquareClient var isTesting = os.Getenv("GO_TESTING") == "1" func mockSleep(d time.Duration) { if !isTesting { time.Sleep(d) } } type MockClient struct { mu sync.RWMutex cards map[string]map[string]*CardOnFile cardByToken map[string]*CardOnFile // ccof: token (CardOnFile.CardID) → the saved card, for replay-by-key rescue checkouts map[string]*CheckoutResult payments map[string]*PaymentResult paymentByKey map[string]*PaymentResult paymentSource map[string]string // idempotency key → the source_id the original CreatePayment used refunds map[string]*RefundResult refundByKey map[string]*RefundResult customers map[string]*CustomerResult completed map[string]*PaymentResult HoldCheckouts bool ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths // FailRefundCode simulates a specific Square refund rejection code. Empty // = normal success; when set, RefundPayment returns the sentinel-wrapped // error for that code. The money-in-flight codes Square actually emits — // REFUND_ALREADY_PENDING (real) and PAYMENT_ALREADY_REFUNDED (kept for // resilience, matching the real client's classification) — map to // ErrRefundAlreadyProcessed; any other code maps to ErrRefundDeclined. FailRefundCode string // ForceCheckoutState forces CreateCheckout's initial status instead of the // default "PENDING" (one of "IN_PROGRESS", "CANCEL_REQUESTED", "CANCELED"). // While set, the auto-complete goroutine is suppressed so the forced state // persists — the sweep's intermediate-state paths (isTerminalCheckoutError // / isCheckoutDefinitivelyDead) can then be exercised in dev/tests exactly // as they run against the real Square API. ForceCheckoutState string // ForceRefundPending makes RefundPayment return a PENDING refund so the // prod-only pending-refund branch (normally only reachable against the // real Square API) can be exercised in dev/tests. ForceRefundPending bool // FailCreateCheckout makes CreateCheckout return an error so the handler's // post-insert CreateCheckout-failure path (marking the provisional // terminal_checkouts row failed) can be exercised in dev/tests. FailCreateCheckout bool // FailAfterCommit simulates the exact "charged but response lost → same-key // retry" prod scenario: CreatePayment COMMITS the charge internally // (retaining the key + source in paymentByKey/paymentSource 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 — 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 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). 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, // CreatePayment returns a payment carrying the forced status with nil // error — the "Square returned 200 with a non-terminal payment" prod // scenario. It proves a status-blind handler (one that records 'completed' // on nil error alone) is a regression: the client surfaces Status // faithfully (paymentFromSquare never errors on a non-terminal status — // 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{} func (d *devProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) { return createPaymentHTTP(ctx, req) } func (d *devProdClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) { return createCheckoutHTTP(ctx, req) } func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) { return getCheckoutHTTP(ctx, checkoutID) } func (d *devProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) { return getPaymentHTTP(ctx, paymentID) } func (d *devProdClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) { return replayPaymentByKeyHTTP(ctx, snapshotJSON) } func (d *devProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) { return createCustomerHTTP(ctx, name, email) } func (d *devProdClient) DeleteCustomer(ctx context.Context, customerID string) error { return deleteCustomerHTTP(ctx, customerID) } func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) error { return cancelCheckoutHTTP(ctx, checkoutID) } func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) { return refundPaymentHTTP(ctx, req) } // PaymentWasRefunded has ZERO production callers — kept only to satisfy the // SquareClient interface for the dev mock's refund-reconciliation parity // tests. Production reconciliation uses paymentRefundedExactlyWithClient. 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) } func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) { return getCardsOnFileHTTP(ctx, userID) } func (d *devProdClient) DeleteCardOnFile(ctx context.Context, cardID string) error { return deleteCardOnFileHTTP(ctx, cardID) } func (d *devProdClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) { return listRefundsHTTP(ctx, paymentID, beginTime) } func NewClient() SquareClient { return NewDevClient() } // errDevRealAPIRequiresOverride is the hard-fail error NewDevClient panics // with when a dev build is asked to route to the real PRODUCTION Square API // without the explicit SQUARE_ALLOW_REAL_API=1 override. A dev build must // never silently charge real money on an env-string match alone. var errDevRealAPIRequiresOverride = errors.New("square: dev build refuses SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1 (would route to the REAL Square API)") func NewDevClient() SquareClient { env := SquareEnvironment() switch env { case "production": // A `//go:build dev` build routing to the real production API is an // explicit safety boundary, not a string-match convenience. Without // the override, a typo'd or leftover SQUARE_ENVIRONMENT=production in // a dev shell would make test bookings create REAL charges and payouts. // Fail fast so the misconfiguration is impossible to miss. if os.Getenv("SQUARE_ALLOW_REAL_API") != "1" { log.Printf("[SQUARE-PROD] REFUSING to construct the real production Square client in a dev build: SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1 — set SQUARE_ALLOW_REAL_API=1 to override, or SQUARE_ENVIRONMENT=sandbox/mock for safe dev traffic") panic(errDevRealAPIRequiresOverride) } log.Printf("[SQUARE-PROD] SQUARE_ENVIRONMENT=production WITH SQUARE_ALLOW_REAL_API=1 — dev build making REAL API calls to %s (explicit override, real money)", realBaseURL(env)) return &devProdClient{} case "sandbox": // Sandbox never moves real money, so a dev build may route there — but // loudly, so no-one mistakes a sandbox for the mock. log.Printf("[SQUARE-PROD] *** DEV BUILD ROUTING TO SQUARE SANDBOX %s — test credentials only, NO real charges — this is NOT the mock client ***", realBaseURL(env)) return &devProdClient{} default: log.Println("[SQUARE-MOCK] Using in-memory mock client") return &MockClient{ cards: make(map[string]map[string]*CardOnFile), cardByToken: make(map[string]*CardOnFile), checkouts: make(map[string]*CheckoutResult), payments: make(map[string]*PaymentResult), paymentByKey: make(map[string]*PaymentResult), paymentSource: make(map[string]string), refunds: make(map[string]*RefundResult), refundByKey: make(map[string]*RefundResult), customers: make(map[string]*CustomerResult), completed: make(map[string]*PaymentResult), usedSources: make(map[string]bool), } } } func detectCardInfo(sourceID string) (brand, last4 string) { switch sourceID { case "cnon:test-card": return "VISA", "4242" case "cnon:visa": return "VISA", "1111" case "cnon:mastercard": return "MASTERCARD", "4444" case "cnon:amex": return "AMERICAN_EXPRESS", "0005" default: return "VISA", "4242" } } // keyReuseError is Square's documented IDEMPOTENCY_KEY_REUSED rejection: an // idempotency key reused with a DIFFERENT request body (real Square compares // the WHOLE body; the mock checks the source_id, the only body field that // legitimately varies between same-intent retries). The structured code lets // ErrorCode(err) read it, and the sweep treats it as ambiguous — a data bug, // NOT proof the charge never happened. Shared by CreatePayment's dedup and // ReplayPaymentByKey so both paths return the byte-identical error the real // API would. func keyReuseError(key string) error { return &squareAPIError{ Code: "IDEMPOTENCY_KEY_REUSED", Detail: "idempotency key was reused with a different request body", StatusCode: http.StatusBadRequest, err: fmt.Errorf("square: idempotency key %s reused with a different source_id", key), } } func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) { if m.ShouldFail { return nil, fmt.Errorf("mock: payment declined (simulated failure)") } // Match the real Square API: source_id must be a token (cnon:xxx nonce or // ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the // mock behaves identically to production (PCI-DSS parity). if !isTokenLike(req.SourceID) { return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(req.SourceID)) } // Square requires customer_id when charging a card-on-file (ccof:) token. // The mock enforces the same rule so dev parity catches the production bug // where a saved-card charge is sent without the customer's Square customer // id (real Square rejects it with a 400 MISSING_REQUIRED_PARAMETER — // category INVALID_REQUEST_ERROR — because customer_id is required for a // card-on-file source). if strings.HasPrefix(req.SourceID, "ccof:") && req.CustomerID == "" { return nil, &squareAPIError{ Code: "MISSING_REQUIRED_PARAMETER", Category: "INVALID_REQUEST_ERROR", Field: "customer_id", Detail: "customer_id required for card-on-file source", StatusCode: http.StatusBadRequest, err: errors.New("square: customer_id required for card-on-file source"), } } // Square's idempotency-key limit for POST /v2/payments is 45 characters // (64 only for /v2/terminals/checkouts) — MaxIdempotencyKeyLength // (square_http_client.go), the single source the payments package also // aliases. Real Square rejects an oversized key with a 400 // VALUE_TOO_LONG; the mock mirrors the rejection with the same structured // error so dev parity catches over-length keys (the real client always // derives ≤45-char keys, so this only fires on a caller bug). if len(req.IdempotencyKey) > MaxIdempotencyKeyLength { return nil, &squareAPIError{ Code: "VALUE_TOO_LONG", Detail: "idempotency_key must be 45 characters or fewer", Category: "INVALID_REQUEST_ERROR", StatusCode: http.StatusBadRequest, err: fmt.Errorf("square: idempotency_key %s is %d chars, exceeds Square's 45-char limit", tokenPrefix(req.IdempotencyKey), len(req.IdempotencyKey)), } } // Do NOT log the full source token — it is a single-use nonce (cnon:) or a // card reference (ccof:) that could be replayed. Log only its prefix and // length for debugging (S-2). sourcePrefix := "" if len(req.SourceID) > 8 { sourcePrefix = req.SourceID[:8] + "..." } else { sourcePrefix = req.SourceID } log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s, source=%s", req.Amount, req.ReferenceID, sourcePrefix) mockSleep(1 * time.Second) m.mu.Lock() defer m.mu.Unlock() // Real Square dedups on idempotency key: a retry with the same key returns // the original payment rather than creating a second charge. The mock // mirrors this so dev/testing behaves like production (also why the tip // retry regression test can rely on the mock). Like real Square, the dedup // is BODY-AWARE: a retained key reused with a DIFFERENT source_id is // rejected with IDEMPOTENCY_KEY_REUSED (the same error ReplayPaymentByKey // returns for a source mismatch), never silently satisfied — so the // gift-card same-key retry (which refreshes square_source_id with a fresh // cnon on pending-reuse) surfaces the real prod rejection in dev instead of // succeeding where prod would strand the row pending for the sweep. if req.IdempotencyKey != "" { if existing, ok := m.paymentByKey[req.IdempotencyKey]; ok { if storedSource, hasSource := m.paymentSource[req.IdempotencyKey]; hasSource && storedSource != "" && storedSource != req.SourceID { log.Printf("[SQUARE-MOCK] CreatePayment IDEMPOTENCY_KEY_REUSED: key=%s reused with a different source (%s vs %s)", req.IdempotencyKey, tokenPrefix(req.SourceID), tokenPrefix(storedSource)) return nil, keyReuseError(req.IdempotencyKey) } log.Printf("[SQUARE-MOCK] CreatePayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID) return existing, nil } } // 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" if req.Autocomplete != nil && !*req.Autocomplete { status = "APPROVED" } if m.ForcePaymentStatus != "" { // Drive the "Square returned 200 with a non-terminal payment" prod // scenario: the payment comes back with a non-default status and nil // error, so a status-blind caller (records 'completed' on nil error // alone) is exposed as a regression. status = m.ForcePaymentStatus } amount := req.Amount tipAmount := int64(0) if req.TipMoney != nil { tipAmount = *req.TipMoney amount += tipAmount } cardBrand, cardLast4 := detectCardInfo(req.SourceID) // Entry method: ON_FILE for card-on-file tokens, KEYED for nonces entryMethod := "KEYED" if len(req.SourceID) >= 5 && req.SourceID[:5] == "ccof:" { entryMethod = "ON_FILE" } paymentID := fmt.Sprintf("pay_mock_%d", now.UnixNano()) // Sign-convention parity (finding A): Square reports processing_fee amounts // as NEGATIVE on the wire, and paymentFromSquare negates them so // PaymentResult.Fees is POSITIVE — the value handlers store as p.fees. The // mock fabricates the same positive magnitude directly: online rate 1.4% + // 25p (amount*14/1000+25). Mock and real client must agree on the sign; // see TestProcessingFeeSign_Parity_MockAndRealClientAgree. fees := amount*14/1000 + 25 // online rate: 1.4% + 25p locationID := req.LocationID if locationID == "" { locationID = "L_MOCK" } expMonth := 12 expYear := 2030 result := &PaymentResult{ ID: paymentID, Status: status, Amount: amount, CardBrand: cardBrand, CardLast4: cardLast4, CardFingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()), ExpMonth: &expMonth, ExpYear: &expYear, EntryMethod: entryMethod, CVVStatus: "CVV_ACCEPTED", AVSStatus: "AVS_ACCEPTED", TipAmount: tipAmount, ReceiptURL: "https://squareup.com/receipt/" + paymentID, ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", now.UnixNano()), SquarePayID: paymentID, Fees: fees, BuyerEmail: req.BuyerEmail, CustomerID: req.CustomerID, LocationID: locationID, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339), ReferenceID: req.ReferenceID, } m.payments[paymentID] = result // SquarePayID is the same ID as the payment (paymentFromSquare sets // SquarePayID = sq.ID), so the lookup map is keyed identically to the real // client — reconcile/sweep code that resolves a stored square_payment_id // via GetPayment behaves the same in mock and prod. m.payments[result.SquarePayID] = result if req.IdempotencyKey != "" { m.paymentByKey[req.IdempotencyKey] = result m.paymentSource[req.IdempotencyKey] = req.SourceID } log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees) if m.FailAfterCommit { // The charge is already committed above (payment + key + source are in // the ledgers exactly like a successful charge) — now simulate the lost // response: the caller sees a 5xx-style error while Square holds the // payment under the key. A same-key + same-source retry dedups to the // committed payment instead of charging twice, exactly like prod. log.Printf("[SQUARE-MOCK] FailAfterCommit: payment %s committed under key=%s but returning simulated 503 (response lost)", paymentID, req.IdempotencyKey) return nil, fmt.Errorf("square: charge %s committed but response lost (simulated HTTP 503) — retry with the same idempotency key to receive the committed payment", paymentID) } return result, nil } func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) { if m.FailCreateCheckout { return nil, fmt.Errorf("mock: checkout creation failed (simulated failure)") } // Real Square's TerminalCheckout API REQUIRES device_options.device_id: a // checkout with an empty device id is rejected with a 400. The real client // resolves the per-request device ID with an env fallback // (SQUARE_TERMINAL_DEVICE_ID, square_http_client.go:516); the mock mirrors // the SAME resolution and rejects when neither is set — so a missing // terminal misconfiguration is caught in dev instead of silently // "succeeding" where prod 400s. deviceID := req.DeviceID if deviceID == "" { deviceID = os.Getenv("SQUARE_TERMINAL_DEVICE_ID") } if deviceID == "" { return nil, &squareAPIError{ Code: "MISSING_REQUIRED_PARAMETER", Detail: "device_options.device_id is required to create a terminal checkout", Category: "INVALID_REQUEST_ERROR", Field: "device_options.device_id", StatusCode: http.StatusBadRequest, err: errors.New("square: device_options.device_id is required for a terminal checkout (set SQUARE_TERMINAL_DEVICE_ID or pass DeviceID)"), } } log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID) now := clock.Now().UTC() checkoutID := fmt.Sprintf("chk_mock_%d", now.UnixNano()) status := "PENDING" if m.ForceCheckoutState != "" { status = m.ForceCheckoutState } result := &CheckoutResult{ ID: checkoutID, Status: status, AmountMoney: req.Amount, Currency: req.Currency, ReferenceID: req.ReferenceID, Note: req.Note, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339), Deadline: "PT5M", // deadline_duration wire format: RFC 3339 duration, not a timestamp } m.mu.Lock() m.checkouts[checkoutID] = result m.mu.Unlock() // Copy the result before spawning the goroutine to avoid data races. // The caller gets this copy; the goroutine modifies the map-stored original. resultCopy := *result // A forced checkout state must persist (the sweep's intermediate-state // paths need a stable IN_PROGRESS / CANCEL_REQUESTED / CANCELED checkout), // so the auto-complete goroutine is suppressed while ForceCheckoutState is // set — exactly like HoldCheckouts. if !m.HoldCheckouts && m.ForceCheckoutState == "" { go func() { defer func() { if r := recover(); r != nil { log.Printf("Panic recovered in Square mock payment processing: %v", r) } }() mockSleep(3 * time.Second) m.mu.Lock() defer m.mu.Unlock() payNow := clock.Now().UTC() paymentID := fmt.Sprintf("pay_mock_%d", payNow.UnixNano()) amount := req.Amount tipAmount := int64(0) // Real Square does NOT add a tip to the checkout amount when // AllowTipping is true — it only enables a tip prompt on the // Terminal. The frontend already embeds any tip in req.Amount, so // the mock must charge exactly req.Amount too (a fixed +500p here // double-counted the tip the customer actually agreed to). fees := amount * 175 / 10000 // in-person rate: 1.75% expMonth := 12 expYear := 2030 paymentResult := &PaymentResult{ ID: paymentID, Status: "COMPLETED", Amount: amount, CardBrand: "VISA", CardLast4: "4242", CardFingerprint: fmt.Sprintf("sqfp_mock_%d", payNow.UnixNano()), ExpMonth: &expMonth, ExpYear: &expYear, EntryMethod: "EMV", CVVStatus: "CVV_ACCEPTED", AVSStatus: "AVS_ACCEPTED", TipAmount: tipAmount, ReceiptURL: "https://squareup.com/receipt/" + paymentID, ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", payNow.UnixNano()), SquarePayID: paymentID, Fees: fees, CustomerID: req.CustomerID, LocationID: "L_MOCK", CreatedAt: payNow.Format(time.RFC3339), UpdatedAt: payNow.Format(time.RFC3339), ReferenceID: req.ReferenceID, } m.completed[checkoutID] = paymentResult // Real Square registers the terminal payment under its own ID: // GET /v2/payments/{id} succeeds on a completed terminal // checkout's payment in prod, but failed in dev because the // payment was never added to m.payments (finding I). Mirror prod // by registering it under both the ID and SquarePayID keys, exactly // like CreatePayment, so the reconcile/sweep GetPayment path // behaves identically. m.payments[paymentID] = paymentResult m.payments[paymentResult.SquarePayID] = paymentResult m.checkouts[checkoutID].Status = "COMPLETED" m.checkouts[checkoutID].UpdatedAt = payNow.Format(time.RFC3339) m.checkouts[checkoutID].PaymentIDs = []string{paymentID} log.Printf("[SQUARE-MOCK] Checkout completed: id=%s, amount=%d, tip=%d", checkoutID, amount, tipAmount) }() } return &resultCopy, nil } func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) { log.Printf("[SQUARE-MOCK] GetCheckout: id=%s", checkoutID) m.mu.RLock() defer m.mu.RUnlock() checkout, ok := m.checkouts[checkoutID] if !ok { return nil, fmt.Errorf("checkout not found: %s", checkoutID) } // Mirror the real client's GetCheckout state machine // (getCheckoutHTTPWithClient): PENDING / IN_PROGRESS / CANCEL_REQUESTED are // all still-live checkout states → ErrCheckoutPending; any other // non-COMPLETED status (CANCELED, FAILED, expired) surfaces a plain // "is (not COMPLETED)" error so the sweep's // isTerminalCheckoutError / isCheckoutDefinitivelyDead classification runs // identically in mock and prod. switch checkout.Status { case "PENDING", "IN_PROGRESS", "CANCEL_REQUESTED": return nil, ErrCheckoutPending case "COMPLETED": result, ok := m.completed[checkoutID] if !ok { return nil, fmt.Errorf("checkout result not found: %s", checkoutID) } return result, nil default: return nil, fmt.Errorf("square: checkout %s is %s (not COMPLETED)", checkoutID, checkout.Status) } } func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) { log.Printf("[SQUARE-MOCK] GetPayment: id=%s", paymentID) m.mu.RLock() defer m.mu.RUnlock() payment, ok := m.payments[paymentID] if !ok { return nil, fmt.Errorf("payment not found: %s", paymentID) } return payment, nil } // ReplayPaymentByKey mirrors the real client's IDENTICAL-body replay-by-key // reconcile (POST /v2/payments with the full stored request snapshot): a // retained key with the matching stored source returns the ORIGINAL payment // (never a second charge); a retained key with a DIFFERENT source returns a // structured 400 IDEMPOTENCY_KEY_REUSED — exactly what Square returns when an // idempotency key is reused with a different request body (the stored source // must never differ from the original, so the sweep treats it as ambiguous); // an unknown key makes Square attempt a real charge with the stored source: a // still-valid ccof: saved-card token CHARGES successfully (returning a new // COMPLETED payment the sweep rescues), while a spent/expired cnon: nonce is // rejected with a 4xx — surfaced as ErrReplayKeyNotRetained. func (m *MockClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) { var req CreatePaymentReq if err := json.Unmarshal(snapshotJSON, &req); err != nil { return nil, fmt.Errorf("square: replay-by-key cannot parse stored request snapshot: %w", err) } log.Printf("[SQUARE-MOCK] ReplayPaymentByKey: key=%s, source=%s", req.IdempotencyKey, tokenPrefix(req.SourceID)) m.mu.RLock() existing, ok := m.paymentByKey[req.IdempotencyKey] storedSource := m.paymentSource[req.IdempotencyKey] m.mu.RUnlock() if ok { if storedSource != "" && storedSource != req.SourceID { // Same key, different body — Square's documented IDEMPOTENCY_KEY_REUSED // rejection. A data bug (the stored source differs from the original // charge), NOT proof the charge never happened. return nil, keyReuseError(req.IdempotencyKey) } log.Printf("[SQUARE-MOCK] ReplayPaymentByKey dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID) return existing, nil } // Unknown key — mirror real Square: it attempts a real charge with the // stored source. A still-valid ccof: saved-card token charges successfully // (the sweep then rescues the row); a spent/expired cnon: nonce (or any // unchargeable source) is rejected with a definitive 4xx. if strings.HasPrefix(req.SourceID, "ccof:") { m.mu.RLock() _, cardOK := m.cardByToken[req.SourceID] m.mu.RUnlock() if !cardOK { // The saved card is not in the mock ledger — mirror real Square // rejecting a deleted/disabled card with a definitive 4xx. return nil, fmt.Errorf("%w: Square has no saved card %s to charge", ErrReplayKeyNotRetained, tokenPrefix(req.SourceID)) } if req.Currency == "" { req.Currency = gbpCurrency } pr, err := m.CreatePayment(ctx, req) if err != nil { if replayErrorProvesNoCharge(err) { return nil, fmt.Errorf("%w: %v", ErrReplayKeyNotRetained, err) } return nil, err } log.Printf("[SQUARE-MOCK] ReplayPaymentByKey charged saved card for unknown key: key=%s → id=%s", req.IdempotencyKey, pr.ID) return pr, nil } return nil, fmt.Errorf("%w: Square has no payment under idempotency key (HTTP 400: source rejected)", ErrReplayKeyNotRetained) } func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) { if m.ShouldFail { return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined) } if m.FailRefundCode != "" { switch m.FailRefundCode { case "PAYMENT_ALREADY_REFUNDED", "REFUND_ALREADY_PENDING": // Both codes mean money is in flight or has already moved at // Square — the same classification the real client applies // (square_http_client.go:687), so the concurrent-refund dedup path // is exercisable in dev. return nil, fmt.Errorf("%w: %s (simulated)", ErrRefundAlreadyProcessed, m.FailRefundCode) default: return nil, fmt.Errorf("%w: %s (simulated failure)", ErrRefundDeclined, m.FailRefundCode) } } log.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d", req.PaymentID, req.Amount) mockSleep(1 * time.Second) m.mu.Lock() defer m.mu.Unlock() // Real Square dedups on idempotency key: a retry with the same key returns // the original refund rather than issuing a second refund. The mock mirrors // this so dev/testing behaves like production (and the pending-refund // resume path can rely on it). if req.IdempotencyKey != "" { if existing, ok := m.refundByKey[req.IdempotencyKey]; ok { log.Printf("[SQUARE-MOCK] RefundPayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID) return existing, nil } } now := clock.Now().UTC() refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano()) // Square's RefundPayment requires amount_money — a missing or zero amount // is rejected (400 REFUND_AMOUNT_INVALID, category INVALID_REQUEST_ERROR), // never treated as a "full refund" shortcut. The mock mirrors this so a // missing-amount bug can't be masked in dev (the real DB also has a CHECK // amount > 0, so a £0 refund must fail rather than silently record nothing). if req.Amount <= 0 { return nil, fmt.Errorf("square: refund amount must be positive (amount_money is required)") } // Reject refunds for a mock-artifact payment ID that was never created. // The mock mints payment IDs as "pay_mock_"; 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" } status := "COMPLETED" if m.ForceRefundPending { status = "PENDING" } result := &RefundResult{ ID: refundID, Status: status, Amount: amount, PaymentID: req.PaymentID, LocationID: locationID, Reason: req.Reason, CreatedAt: now.Format(time.RFC3339), } m.refunds[refundID] = result if req.IdempotencyKey != "" { m.refundByKey[req.IdempotencyKey] = result } log.Printf("[SQUARE-MOCK] Refund completed: id=%s, payment=%s, amount=%d", refundID, req.PaymentID, amount) return result, nil } // RefundKeyCount returns the number of distinct idempotency keys this mock has // recorded refunds against (the refundByKey dedup map). Test accessor for // asserting that same-key retries issue exactly ONE Square refund, never a // second. func (m *MockClient) RefundKeyCount() int { m.mu.RLock() defer m.mu.RUnlock() 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. TEST-ONLY on // the SquareClient interface (no production callers — reconciliation uses the // package-level paymentRefundedExactlyWithClient); kept so this mock satisfies // the interface and its refund-status parity tests can exercise the set. 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. func (m *MockClient) UsedSources() []string { m.mu.RLock() defer m.mu.RUnlock() out := make([]string, 0, len(m.usedSources)) for src := range m.usedSources { out = append(out, src) } return out } func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) { log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID) // Match the real Square API: source_id must be a token (cnon:xxx nonce or // ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the // mock behaves identically to production. if !isTokenLike(cardToken) { return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken)) } // Square's POST /v2/cards rejects a card without card.customer_id at // runtime (confirmed by Square's own SDK maintainer). The production client // omits an empty customer_id via omitempty and every production caller // provisions a Square customer first, so the gate is enforced upstream — the // mock must mirror it (same structured MISSING_REQUIRED_PARAMETER as the // ccof: CreatePayment gate above) so sandbox/dev tests exercise the same // rejection. if customerID == "" { return nil, &squareAPIError{ Code: "MISSING_REQUIRED_PARAMETER", Category: "INVALID_REQUEST_ERROR", Field: "card.customer_id", Detail: "customer_id is required to create a card on file", StatusCode: http.StatusBadRequest, err: errors.New("square: customer_id is required to create a card on file"), } } m.mu.Lock() defer m.mu.Unlock() if m.SimulateSourceUsed && m.usedSources[cardToken] { // Real Square consumes a cnon: nonce on card creation — reusing it to // create another card is rejected with SOURCE_USED (the CreateCard // error; CARD_TOKEN_USED is a CreatePayment code and would be wrong // here). The mock mirrors that structured 400 rejection (opt-in, see // the struct doc). return nil, &squareAPIError{ Code: "SOURCE_USED", Detail: "The provided source id was already used to create a card", Category: "INVALID_REQUEST_ERROR", StatusCode: http.StatusBadRequest, err: fmt.Errorf("square: card source %s has already been used to create a card", tokenPrefix(cardToken)), } } if m.cards[userID] == nil { m.cards[userID] = make(map[string]*CardOnFile) } now := clock.Now().UTC() cardID := fmt.Sprintf("mock_card_%d", now.UnixNano()) brand, last4 := detectCardInfo(cardToken) card := &CardOnFile{ ID: cardID, // Prefix "ccof:" so the mock's own entry-method detection (and any // consumer checking the prefix) sees ON_FILE, matching production where // saved-card tokens are "ccof:xxx". An "ccof_mock_" id would silently // exercise the KEYED path in tests while prod runs ON_FILE. CardID: fmt.Sprintf("ccof:mock_%d", now.UnixNano()), Brand: brand, Last4: last4, ExpMonth: 12, ExpYear: 2030, Fingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()), CardholderName: "John Doe", ReferenceID: userID, Enabled: true, IsDefault: len(m.cards[userID]) == 0, Version: 1, CreatedAt: now.Format(time.RFC3339), } m.cards[userID][cardID] = card m.cardByToken[card.CardID] = card if m.SimulateSourceUsed { m.usedSources[cardToken] = true } log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4) return card, nil } func (m *MockClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) { log.Printf("[SQUARE-MOCK] GetCardsOnFile: user=%s", userID) m.mu.RLock() defer m.mu.RUnlock() userCards, ok := m.cards[userID] if !ok { return []CardOnFile{}, nil } var cards []CardOnFile for _, card := range userCards { // Real Square's List Cards API EXCLUDES disabled cards by default // (the client sends no include_disabled param) — a disabled/deleted // card disappears from GetCardsOnFile. Mirror that so dev parity // matches prod (finding C). if !card.Enabled { continue } cards = append(cards, *card) } return cards, nil } func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error { log.Printf("[SQUARE-MOCK] DeleteCardOnFile: id=%s", cardID) 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)", tokenPrefix(cardID), userID) return nil } } return fmt.Errorf("square: card not found: %s", cardID) } func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) { log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339)) m.mu.RLock() defer m.mu.RUnlock() out := []RefundResult{} for _, r := range m.refunds { if r.PaymentID != paymentID { continue } createdAt, err := time.Parse(time.RFC3339, r.CreatedAt) if err == nil && createdAt.Before(beginTime) { continue } out = append(out, *r) } return out, nil } // redactedEmail masks a customer email for dev logs (PII, S-2 convention): // only the first two characters of the local part plus the domain are shown, // e.g. "ja***@example.com". Malformed addresses fall back to "[redacted]". func redactedEmail(email string) string { at := strings.Index(email, "@") if at < 2 || at+1 >= len(email) { return "[redacted]" } return email[:2] + "***@" + email[at+1:] } func (m *MockClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) { log.Printf("[SQUARE-MOCK] CreateCustomer: name=%s, email=%s", name, redactedEmail(email)) if email == "" { return nil, fmt.Errorf("mock: customer email is required") } m.mu.Lock() defer m.mu.Unlock() // Real Square dedups on the idempotency key (derived from the email); // the mock mirrors this by deduping on email so a retry returns the // original customer rather than creating a duplicate. if existing, ok := m.customers[email]; ok { log.Printf("[SQUARE-MOCK] CreateCustomer dedup hit: email=%s → id=%s", redactedEmail(email), tokenPrefix(existing.ID)) return existing, nil } sum := sha256.Sum256([]byte(email)) customer := &CustomerResult{ ID: "cus_mock_" + fmt.Sprintf("%x", sum)[:12], Email: email, CreatedAt: clock.Now().UTC().Format(time.RFC3339), } m.customers[email] = customer log.Printf("[SQUARE-MOCK] Customer created: id=%s, email=%s", tokenPrefix(customer.ID), redactedEmail(email)) return customer, nil } func (m *MockClient) DeleteCustomer(ctx context.Context, customerID string) error { log.Printf("[SQUARE-MOCK] DeleteCustomer: id=%s", tokenPrefix(customerID)) m.mu.Lock() defer m.mu.Unlock() for email, customer := range m.customers { if customer.ID == customerID { delete(m.customers, email) log.Printf("[SQUARE-MOCK] Customer deleted: id=%s", tokenPrefix(customerID)) return nil } } // Real Square returns 404 / NOT_FOUND for an already-deleted customer — // mirror the prod semantics of idempotent re-deletion as a no-op. return nil } func (m *MockClient) CancelCheckout(ctx context.Context, checkoutID string) error { log.Printf("[SQUARE-MOCK] CancelCheckout: id=%s", checkoutID) m.mu.Lock() defer m.mu.Unlock() // Real Square cancels only pending/in-progress checkouts; a completed or // missing checkout is a no-op (Square returns 404/NOT_FOUND in prod). if checkout, ok := m.checkouts[checkoutID]; ok { if checkout.Status == "PENDING" || checkout.Status == "IN_PROGRESS" { checkout.Status = "CANCELED" checkout.UpdatedAt = clock.Now().UTC().Format(time.RFC3339) } } return nil } func realBaseURL(env string) string { if env == "production" { return squareProductionURL } return squareSandboxURL }