//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, SimulateSavedCardVerificationRequired, // ChallengeResult) 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" "strconv" "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 // SimulateSavedCardVerificationRequired mirrors Square's SCA enforcement on // saved-card charges — the SCA-primary saved-card posture the platform uses // (buyer verification on card-on-file charges, not just new-card nonces). // When true, CreatePayment demands SCA on every saved-card charge, where a // charge is a saved-card charge in one of two wire shapes: // (a) a fresh cnon:-style tokenize-result as source_id + customer_id // (Square's CURRENT contract: card.tokenize(verificationDetails, // cardId) returns a one-time token sent as source_id with the card's // customer_id — the token IS the buyer verification, so this shape is // ACCEPTED without any verification_token). ONLY a genuine // tokenize-result (cnon:sca-... — see isSCATokenizeResultSource) is // accepted: a RAW card.tokenize() nonce in the tokenize-result slot // is REJECTED with CARD_DECLINED_VERIFICATION_REQUIRED, mirroring real // Square rejecting an unverified nonce as a card-on-file source // (money-F2 — the handler treats any non-empty new_card_token + // saved-card ref as an SCA tokenize-result, so the mock is the // enforcement point that stops the forged shape); // (b) a legacy ccof: source carrying a verification_token (the deprecated // verifyBuyer() contract — kept accepting for backward-compat). // A ccof: source with NEITHER is rejected with the structured 400 // CARD_DECLINED_VERIFICATION_REQUIRED and a pending buyer-verification // challenge is recorded for the card. A subsequent charge WITH a // verification token resolves that challenge (see resolveVerificationToken): // an explicitly approved challenge, or a stateless // verify_mock___ok token, lets the charge succeed; a denied // challenge / _deny token is rejected with 400 VERIFICATION_TOKEN_INVALID. // Cards marked via GrandfatherSavedCard bypass the gate entirely. Off by // default — existing dev/test flows charge saved cards without verification // tokens, so flipping it on in a prod-like test setup intentionally surfaces // every saved-card charge that would be rejected by Square's SCA. SimulateSavedCardVerificationRequired bool // ChallengeResult configures the mock's SCA challenge outcome when a // verification token is supplied on a gated charge. "" or "approve" // (default) accepts a valid token / approved challenge; "deny" simulates // the buyer denying EVERY banking-app challenge (any token → // VERIFICATION_TOKEN_INVALID); "auto" auto-resolves a gate rejection's // pending challenge as approved, so the next tokenized retry succeeds // without an explicit ApprovePendingVerification call. ChallengeResult string // grandfatheredCards marks ccof: tokens that are exempt from the saved-card // verification gate (GrandfatherSavedCard). An exempt card charges without // a verification token even while SimulateSavedCardVerificationRequired is // on — mirroring cards Square has already verified / stored with a standing // SCA exemption. grandfatheredCards map[string]bool // pendingChallenges records the per-card buyer-verification challenge state // that the saved-card gate creates when it rejects a ccof charge without a // token. An opaque (real-Square-shaped) verification token is resolved // against this ledger; the deterministic verify_mock_... tokens carry their // own outcome and only consult the ledger to honour an explicit denial. pendingChallenges map[string]*pendingChallenge // verifyTokens is a one-time-use ledger of verify_mock_* verification // tokens consumed by a successful charge or a definitive // VERIFICATION_TOKEN_INVALID rejection — mirroring real Square, which // consumes a verification token on use so a replayed token is rejected // (VERIFICATION_TOKEN_INVALID). Mirrors the usedSources ledger pattern. verifyTokens map[string]bool } type devProdClient struct{} // pendingChallenge is the recorded buyer-verification challenge state for one // saved-card (ccof:) token. outcome "" = pending (recorded by the gate's // rejection, not yet resolved); "approved" / "denied" = resolved via // ApprovePendingVerification / DenyPendingVerification. type pendingChallenge struct { outcome string } 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), grandfatheredCards: make(map[string]bool), pendingChallenges: make(map[string]*pendingChallenge), verifyTokens: 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), } } // verificationTokenInvalidError is Square's VERIFICATION_TOKEN_INVALID // rejection (definitivePaymentCodes): the supplied 3DS/SCA verification token // is invalid, expired, already used, denied by the buyer, or not bound to this // card + amount. A definitive payment error — retrying the same request can // never succeed. func verificationTokenInvalidError(token, sourceID string) error { return &squareAPIError{ Code: "VERIFICATION_TOKEN_INVALID", Category: "PAYMENT_METHOD_ERROR", Detail: "The verification token is invalid, expired, already used, or not valid for this charge", StatusCode: http.StatusBadRequest, err: fmt.Errorf("square: verification token %s is not valid for card-on-file charge on %s", tokenPrefix(token), tokenPrefix(sourceID)), } } // parsedVerifyToken is the deterministic verify_mock__[_ok|_deny] // verification-token encoding shared by the dev frontend and the mock, so the // two sides can exercise approve/deny outcomes WITHOUT shared state. type parsedVerifyToken struct { prefix string amount int64 denied bool } // parseVerifyToken parses a deterministic mock verification token of the form // verify_mock__[_ok|_deny] (outcome suffix defaults to ok). // Returns ok=false for anything that is not parseable (an opaque token — the // same shape as real Square's verification tokens — which resolves against the // recorded pending challenge instead). func parseVerifyToken(token string) (parsedVerifyToken, bool) { const marker = "verify_mock_" if !strings.HasPrefix(token, marker) { return parsedVerifyToken{}, false } rest := strings.TrimPrefix(token, marker) if rest == "" { return parsedVerifyToken{}, false } parts := strings.Split(rest, "_") denied := false if n := len(parts); n > 1 { switch parts[n-1] { case "ok", "deny": denied = parts[n-1] == "deny" parts = parts[:n-1] } } if len(parts) == 0 { return parsedVerifyToken{}, false } amount, err := strconv.ParseInt(parts[len(parts)-1], 10, 64) if err != nil { return parsedVerifyToken{}, false } return parsedVerifyToken{prefix: strings.Join(parts[:len(parts)-1], "_"), amount: amount, denied: denied}, true } // verificationTokenPrefixForSource returns the card prefix the mock binds an // SCA verification token to for a source_id — the SAME derivation the dev // frontend uses when minting verify_mock_... tokens, so the binding check // cannot drift between the two sides. New-card (cnon:) nonces bind to the // first four digits of the PAN the user typed (MockCardForm MOCK_TOKENS); // saved-card (ccof:) tokens bind to the first four chars after the ccof: // prefix (MockCardForm.verifySavedCard). func verificationTokenPrefixForSource(sourceID string) string { switch sourceID { case "cnon:test-card": return "4242" case "cnon:visa": return "4111" case "cnon:mastercard": return "5555" case "cnon:amex": return "3782" } if strings.HasPrefix(sourceID, "ccof:") { rest := strings.TrimPrefix(sourceID, "ccof:") if len(rest) > 4 { return rest[:4] } return rest } return "" } // isSCATokenizeResultSource reports whether a cnon: source represents a GENUINE // Square tokenizeWithVerification result — the CURRENT saved-card SCA contract's // charge source (card.tokenize(verificationDetails, cardId)) — rather than a RAW // card.tokenize() nonce. Real Square returns both as opaque cnon: tokens, so the // dev mock needs an explicit marker to tell them apart: a genuine tokenize-result // carries "sca-" immediately after the cnon: prefix (the shape the dev // frontend's MockCardForm mints for saved-card verification). A raw nonce like // "cnon:test-card" — whatever customer_id rides along — is NOT a tokenize-result, // and real Square rejects it as a card-on-file charge source. func isSCATokenizeResultSource(sourceID string) bool { return strings.HasPrefix(sourceID, "cnon:sca-") } // parseSCATokenizeResult parses the deterministic mock tokenize-result encoding // the dev frontend mints for saved-card SCA: cnon:sca-_[_ok|_deny] // (outcome suffix defaults to ok). The prefix is the first four characters of // the saved card's ccof: id, the amount is the pence charge the token was bound // to, and the outcome encodes the buyer's challenge decision // (MockCardForm.tokenizeSavedCard / verifySavedCard, square.ts's // tokenizeSavedCardWithVerification mock fallback). Reuses parsedVerifyToken's // shape — same binding + outcome semantics as the legacy verify_mock_ encoding. // Returns ok=false for anything that is not parseable (an arbitrary cnon:sca-... // string — NOT a genuine tokenize-result). func parseSCATokenizeResult(token string) (parsedVerifyToken, bool) { const marker = "cnon:sca-" if !strings.HasPrefix(token, marker) { return parsedVerifyToken{}, false } rest := strings.TrimPrefix(token, marker) if rest == "" { return parsedVerifyToken{}, false } parts := strings.Split(rest, "_") denied := false if n := len(parts); n > 1 { switch parts[n-1] { case "ok", "deny": denied = parts[n-1] == "deny" parts = parts[:n-1] } } if len(parts) == 0 { return parsedVerifyToken{}, false } amount, err := strconv.ParseInt(parts[len(parts)-1], 10, 64) if err != nil { return parsedVerifyToken{}, false } prefix := strings.Join(parts[:len(parts)-1], "_") if prefix == "" { return parsedVerifyToken{}, false } return parsedVerifyToken{prefix: prefix, amount: amount, denied: denied}, true } // tokenizeResultVerificationRequiredError is the refusal for a cnon:sca- // tokenize-result that does not prove buyer verification for THIS charge — the // same structured 402 CARD_DECLINED_VERIFICATION_REQUIRED rejection a raw nonce // in the tokenize-result slot gets. The buyer must re-verify (mint a token bound // to this card + amount), never retry the same request. func tokenizeResultVerificationRequiredError(sourceID, reason string) error { return &squareAPIError{ Code: "CARD_DECLINED_VERIFICATION_REQUIRED", Category: "PAYMENT_METHOD_ERROR", Detail: "tokenize-result token does not prove buyer verification for this charge; complete buyer verification (tokenizeWithVerification) for this card and amount", StatusCode: http.StatusPaymentRequired, err: fmt.Errorf("square: tokenize-result source %s is not valid for this saved-card charge (%s)", tokenPrefix(sourceID), reason), } } // scaTokenizePrefixKnown reports whether the card prefix embedded in a // cnon:sca- tokenize-result is one the mock can bind the token to: the // deterministic prefixes the mock assigns to its test cards, the "test" fallback // the frontend uses for a degenerate ccof id, or the prefix of a saved card in // the mock's ledger (the ccof: token prefix the frontend derives the minted // prefix from — square.ts / MockCardForm). A prefix outside this set cannot come // from a tokenize-result minted for a card this mock knows, so it is a forged or // wrong-card binding. func (m *MockClient) scaTokenizePrefixKnown(prefix string) bool { if prefix == "test" { return true } switch prefix { case "4242", "4111", "5555", "3782": return true } for _, card := range m.cardByToken { if verificationTokenPrefixForSource(card.CardID) == prefix { return true } } return false } // validateSCATokenizeResult validates the deterministic binding the dev frontend // encodes into a cnon:sca- tokenize-result source against the charge: the // embedded amount must match the charge amount (Square binds buyer verification // to the exact amount), the encoded outcome must not be a buyer denial, and the // embedded card prefix must be one the mock can bind to. An arbitrary // cnon:sca-... string that does not carry the deterministic encoding is NOT a // genuine tokenize-result (the frontend always mints the bound shape), so it is // refused exactly like a raw nonce in the tokenize-result slot. Caller holds // m.mu; the source is already known to carry the cnon:sca- marker. func (m *MockClient) validateSCATokenizeResult(sourceID string, amount int64) error { parsed, ok := parseSCATokenizeResult(sourceID) if !ok { return tokenizeResultVerificationRequiredError(sourceID, "does not carry the deterministic tokenize-result binding") } if parsed.denied { return tokenizeResultVerificationRequiredError(sourceID, "the buyer denied the SCA challenge") } if parsed.amount != amount { return tokenizeResultVerificationRequiredError(sourceID, fmt.Sprintf("bound to a different amount (%d vs %d)", parsed.amount, amount)) } if !m.scaTokenizePrefixKnown(parsed.prefix) { return tokenizeResultVerificationRequiredError(sourceID, fmt.Sprintf("bound to an unknown card prefix %q", parsed.prefix)) } return nil } // isTokenLikeMock is the dev mock's PCI-DSS token predicate: it accepts the // SAME production token shapes as the real client (cnon: nonces / ccof: card // ids — isTokenLike). The legacy verify_mock__ source widening // is REMOVED: the transition to genuine cnon:sca- tokenize-results is complete // and real Square never accepts verify_mock_ as a source_id, so accepting it in // the mock would mask a bug that prod would reject. verify_mock_ tokens remain // valid in the VerificationToken field (the deprecated verifyBuyer() contract) — // only the source-slot widening is removed. func isTokenLikeMock(s string) bool { return isTokenLike(s) } // legacyVerifyMockSourceError is the mock's explicit rejection of the // deprecated verify_mock__[_ok|_deny] charge-source shape the // dev frontend used during the token-shape transition. Real Square never // accepts verify_mock_ as a source_id (it is not a cnon:/ccof: token), so the // mock refuses it with a clear "legacy shape not supported" error instead of // silently accepting it — the old source widening (isTokenLikeMock) masked the // divergence. func legacyVerifyMockSourceError(s string) error { return fmt.Errorf("square: %s is the legacy verify_mock_ transition shape and is NOT a valid charge source — real Square rejects it; mint a genuine tokenize-result (cnon:sca-__ok) or use a cnon:/ccof: token", tokenPrefix(s)) } // resolveVerificationToken validates a supplied 3DS/SCA verification token for // a charge. savedCard=true resolves against the saved-card challenge ledger; // savedCard=false (new-card nonce) treats any present token as satisfying the // gate. Returns nil when the token is accepted (consuming it in the one-time-use // ledger), or a definitive 400 VERIFICATION_TOKEN_INVALID. Must be called under // m.mu (CreatePayment holds the write lock). func (m *MockClient) resolveVerificationToken(token, sourceID string, amount int64, savedCard bool) error { isVerifyToken := strings.HasPrefix(token, "verify_mock_") // ChallengeResult="deny" simulates the buyer denying every banking-app // challenge: ANY token is definitively rejected. if m.ChallengeResult == "deny" { if isVerifyToken { m.verifyTokens[token] = true } return verificationTokenInvalidError(token, sourceID) } // One-time-use ledger: a verify_mock_* token is consumed on its first use; // a second use of the same token is definitively invalid. if isVerifyToken && m.verifyTokens[token] { return verificationTokenInvalidError(token, sourceID) } // Deterministic tokens carry their own binding and outcome. if parsed, ok := parseVerifyToken(token); ok { if parsed.amount != amount || parsed.prefix != verificationTokenPrefixForSource(sourceID) { m.verifyTokens[token] = true return verificationTokenInvalidError(token, sourceID) } if parsed.denied { m.verifyTokens[token] = true return verificationTokenInvalidError(token, sourceID) } // Encoded approval. An explicitly DENIED pending challenge is still the // authority (shared-state denial overrides the stateless encoding). if savedCard { if ch := m.pendingChallenges[sourceID]; ch != nil && ch.outcome == "denied" { m.verifyTokens[token] = true return verificationTokenInvalidError(token, sourceID) } } m.verifyTokens[token] = true return nil } // Opaque token (real-Square-shaped): a new-card charge accepts it (the cnon // gate requires only a present token); a saved-card charge resolves it // against the recorded pending challenge — a token for a challenge that was // never recorded, or that is still pending, is "never seen" → invalid. if !savedCard { if isVerifyToken { m.verifyTokens[token] = true } return nil } ch := m.pendingChallenges[sourceID] if ch == nil || ch.outcome != "approved" { if isVerifyToken { m.verifyTokens[token] = true } return verificationTokenInvalidError(token, sourceID) } if isVerifyToken { m.verifyTokens[token] = true } return nil } // mockPaymentWireBody builds the sqCreatePaymentRequest the dev mock validates // a CreatePaymentReq against. It is an INDEPENDENTLY assembled copy of the // client's wire shape (buildCreatePaymentBody, square_http_client.go) so the // contract test TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical can // prove the mock and the real client emit BYTE-IDENTICAL CreatePayment bodies // for the same charge — a wire drift (like the legacy verification_token + // ccof: divergence this rebuild replaces) fails that test before reaching prod. // LocationID defaults to "L_MOCK" (the mock's location), mirroring the client's // env-defaulted location for a charge that specifies none. func mockPaymentWireBody(req CreatePaymentReq) sqCreatePaymentRequest { body := sqCreatePaymentRequest{ SourceID: req.SourceID, IdempotencyKey: req.IdempotencyKey, AmountMoney: sqMoney{Amount: req.Amount, Currency: req.Currency}, Autocomplete: req.Autocomplete, LocationID: firstNonEmpty(req.LocationID, "L_MOCK"), ReferenceID: req.ReferenceID, CustomerID: req.CustomerID, Note: req.Note, VerificationToken: req.VerificationToken, BuyerEmailAddress: req.BuyerEmail, CustomerDetails: req.CustomerDetails, } if req.TipMoney != nil { body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency} } return body } func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) { if m.ShouldFail { return nil, fmt.Errorf("mock: payment declined (simulated failure)") } // Validate the WIRE BODY (not the raw req fields) so the mock accepts // exactly the request shape the real client emits — the gates below read // body fields, and TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical // pins that body byte-identical to the client's. body := mockPaymentWireBody(req) // 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). The legacy // verify_mock_* transition shape is rejected explicitly — real Square never // accepts it as a source_id (isTokenLikeMock). if strings.HasPrefix(body.SourceID, "verify_mock_") { return nil, legacyVerifyMockSourceError(body.SourceID) } if !isTokenLikeMock(body.SourceID) { return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(body.SourceID)) } // Square's CreatePayment requires a positive amount_money — a missing or // zero amount is rejected (400 INVALID_REQUEST_ERROR), never treated as a // no-op. The mock mirrors the rejection so a caller that tries to charge // £0 (e.g. a deposit fully covered by a campaign discount) fails loudly in // dev instead of minting a completed £0 payment that real Square would // never accept (finding 2). if body.AmountMoney.Amount <= 0 { return nil, &squareAPIError{ Code: "INVALID_REQUEST_ERROR", Category: "INVALID_REQUEST_ERROR", Field: "amount_money", Detail: "The payment amount must be greater than zero", StatusCode: http.StatusBadRequest, err: errors.New("square: payment amount must be positive (amount_money is required)"), } } // 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(body.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(body.IdempotencyKey), len(body.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(body.SourceID) > 8 { sourcePrefix = body.SourceID[:8] + "..." } else { sourcePrefix = body.SourceID } log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s, source=%s", body.AmountMoney.Amount, body.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 body.IdempotencyKey != "" { if existing, ok := m.paymentByKey[body.IdempotencyKey]; ok { if storedSource, hasSource := m.paymentSource[body.IdempotencyKey]; hasSource && storedSource != "" && storedSource != body.SourceID { log.Printf("[SQUARE-MOCK] CreatePayment IDEMPOTENCY_KEY_REUSED: key=%s reused with a different source (%s vs %s)", body.IdempotencyKey, tokenPrefix(body.SourceID), tokenPrefix(storedSource)) return nil, keyReuseError(body.IdempotencyKey) } log.Printf("[SQUARE-MOCK] CreatePayment dedup hit: key=%s → id=%s", body.IdempotencyKey, existing.ID) return existing, nil } } // Mirror Square's SCA enforcement on NEW-CARD charges (opt-in toggle, off // by default): a cnon: charge without 3DS/SCA verification 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. EXEMPTION: a // cnon: source carrying customer_id is the CURRENT saved-card SCA // contract's tokenize-result (card.tokenize(verificationDetails, cardId) // returns a one-time cnon:-style token sent as source_id with the card's // customer_id) — that token only exists because the buyer completed issuer // verification, so it IS the SCA proof and is not subject to this // new-card verification-token requirement (the saved-card gate below is // its authority: it accepts a GENUINE cnon:sca-... tokenize-result and // rejects a RAW card.tokenize() nonce in the same slot, money-F2). if m.SimulateVerificationRequired && strings.HasPrefix(body.SourceID, "cnon:") { switch { case body.CustomerID != "": // Saved-card tokenize-result — SCA already satisfied by the token // (the saved-card gate below validates it is a GENUINE one). case body.VerificationToken == "": return nil, &squareAPIError{ Code: "CARD_DECLINED_VERIFICATION_REQUIRED", Category: "PAYMENT_METHOD_ERROR", Detail: "card requires buyer verification (3DS/SCA); supply a verification token", // Square's documented wire status for verification-required is // 402 Payment Required, not 400 — the mock must match the WIRE // (the handler's code-keyed classification is unchanged, and // chargeFailureStatus maps any definitive 4xx to 402 anyway). StatusCode: http.StatusPaymentRequired, err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"), } default: if err := m.resolveVerificationToken(body.VerificationToken, body.SourceID, body.AmountMoney.Amount, false); err != nil { return nil, err } } } // Mirror Square's SCA enforcement on SAVED-CARD charges — the SCA-primary // saved-card posture (opt-in toggle, off by default; placement AFTER the // customer_id gate above so a ccof charge without a customer is still // MISSING_REQUIRED_PARAMETER, never verification-required). A charge is a // saved-card charge in one of two wire shapes: // (a) CURRENT contract: source_id is a fresh SCA tokenize-result (a // one-time cnon:-style token from card.tokenize(verificationDetails, // cardId)) sent with the saved card's customer_id. The token only // exists after the buyer completed issuer verification, so it IS the // SCA proof — the charge is accepted without any verification_token. // The mock distinguishes a genuine tokenize-result (cnon:sca-...) from // a RAW card.tokenize() nonce: real Square rejects an unverified nonce // as a card-on-file source (money-F2), so the mock does too. // (b) LEGACY verifyBuyer() contract (backward-compat): source_id is the // stored ccof: card id carrying a verification_token, resolved // against the pending-challenge ledger below. // (c) NEITHER (a ccof: charge with no verification token): SCA is // demanded — the charge is rejected with the structured 400 // CARD_DECLINED_VERIFICATION_REQUIRED and a pending buyer-verification // challenge is recorded for the card, exactly as before. // Grandfathered cards (GrandfatherSavedCard) bypass the gate. isSavedCardCharge := strings.HasPrefix(body.SourceID, "ccof:") || (strings.HasPrefix(body.SourceID, "cnon:") && body.CustomerID != "") if m.SimulateSavedCardVerificationRequired && isSavedCardCharge { switch { case strings.HasPrefix(body.SourceID, "cnon:"): if !isSCATokenizeResultSource(body.SourceID) { // A RAW card.tokenize() nonce in the tokenize-result slot. // Real Square rejects this shape: only a // tokenizeWithVerification RESULT is a valid card-on-file // charge source — a plain nonce (new-card flow) cannot stand // in for buyer verification. The handler treats any non-empty // new_card_token + saved-card ref as an SCA tokenize-result // (skipping the 2FA and consent gates), so the mock MUST // reject the forged source here (money-F2) or the unverified // charge would sail through in dev where Square 400s it. The // buyer must complete issuer verification to mint a genuine // sca-... tokenize-result. return nil, &squareAPIError{ Code: "CARD_DECLINED_VERIFICATION_REQUIRED", Category: "PAYMENT_METHOD_ERROR", Detail: "unverified card nonce cannot be used as a saved-card (card-on-file) charge source; complete buyer verification (tokenizeWithVerification) first", // 402 on the wire, matching Square's documented status for // verification-required. StatusCode: http.StatusPaymentRequired, err: fmt.Errorf("square: unverified cnon nonce %s cannot be used as a saved-card (card-on-file) charge source — a genuine tokenizeWithVerification result is required", tokenPrefix(body.SourceID)), } } // (a) genuine tokenize-result — the token IS the buyer verification. // The mock validates the deterministic binding the dev frontend // encodes into the token (cnon:sca-__ok|_deny): the // embedded amount must match the charge, the prefix must bind to a // card the mock knows, and a _deny outcome is refused. An arbitrary // cnon:sca-... string that does not prove verification for THIS // charge is rejected exactly like real Square's SCA enforcement. if err := m.validateSCATokenizeResult(body.SourceID, body.AmountMoney.Amount); err != nil { return nil, err } log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA satisfied by tokenize-result: source %s (no verification_token needed)", tokenPrefix(body.SourceID)) case body.VerificationToken == "": if m.grandfatheredCards[body.SourceID] { log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate bypassed: source %s is grandfathered", tokenPrefix(body.SourceID)) } else { if m.ChallengeResult == "auto" { // "auto" config: the banking-app challenge resolves itself // as approved, so the next tokenized retry succeeds without // an explicit ApprovePendingVerification call. m.pendingChallenges[body.SourceID] = &pendingChallenge{outcome: "approved"} } else { m.pendingChallenges[body.SourceID] = &pendingChallenge{outcome: ""} } log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate: source %s rejected without a verification token", tokenPrefix(body.SourceID)) return nil, &squareAPIError{ Code: "CARD_DECLINED_VERIFICATION_REQUIRED", Category: "PAYMENT_METHOD_ERROR", Detail: "saved card requires buyer verification (3DS/SCA); supply a verification token", // 402 on the wire, matching Square's documented status for // verification-required. StatusCode: http.StatusPaymentRequired, err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"), } } default: if err := m.resolveVerificationToken(body.VerificationToken, body.SourceID, body.AmountMoney.Amount, true); err != nil { return nil, err } } } // 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(body.SourceID, "cnon:") && m.SimulateSourceUsed { if m.usedSources[body.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(body.SourceID)), } } m.usedSources[body.SourceID] = true } now := clock.Now().UTC() status := "COMPLETED" if body.Autocomplete != nil && !*body.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 := body.AmountMoney.Amount tipAmount := int64(0) if body.TipMoney != nil { tipAmount = body.TipMoney.Amount amount += tipAmount } cardBrand, cardLast4 := detectCardInfo(body.SourceID) // Entry method: ON_FILE for card-on-file tokens, KEYED for nonces entryMethod := "KEYED" if len(body.SourceID) >= 5 && body.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 := body.LocationID 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: body.BuyerEmailAddress, CustomerID: body.CustomerID, LocationID: locationID, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339), ReferenceID: body.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 body.IdempotencyKey != "" { m.paymentByKey[body.IdempotencyKey] = result m.paymentSource[body.IdempotencyKey] = body.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, body.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) } // Mirror prod's replay-by-key sanity validation // (replayPaymentByKeyHTTPWithClient, square_http_client.go): a snapshot // missing source_id or idempotency_key is CORRUPT — never replay it as if // it could succeed. Prod returns a plain (ambiguous) error here, so the // sweep leaves the row PENDING for manual reconciliation; the mock must NOT // answer ErrReplayKeyNotRetained (which the sweep treats as "the charge // provably never happened" and definitively fails the row) — that is the // OPPOSITE money decision on a snapshot we cannot trust. if req.SourceID == "" || req.IdempotencyKey == "" { return nil, fmt.Errorf("square: replay-by-key snapshot missing source_id/idempotency_key") } 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 (refundPaymentHTTPWithClient's REFUND_AMOUNT_INVALID // reconciliation via paymentRefundedExactlyWithClient) — the mock // reconciles: an existing COMPLETED refund for the EXACT requested amount // (that money provably already moved) → ErrRefundAlreadyProcessed; anything // else → the amount is genuinely invalid → ErrRefundDeclined. A different // amount is NEVER AlreadyProcessed: only an exact-amount refund covers the // requested money, so an over-refund on top of a PARTIAL prior refund // surfaces as ErrRefundDeclined (the row is marked failed, alerting the // over-refund guard bug) instead of being resolved 'completed' and hiding // it. 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 m.refundExistsExact(req.PaymentID, req.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) } // refundExistsExact reports whether the mock holds a COMPLETED refund for the // payment in the EXACT amount requested — the same exact-match reconciliation // the real client runs when Square returns REFUND_AMOUNT_INVALID // (paymentRefundedExactlyWithClient: status COMPLETED && amount == requested). // Only an exact-amount COMPLETED refund proves THIS requested money already // moved; a partial refund does not cover it. Caller holds m.mu (RefundPayment // holds the write lock). func (m *MockClient) refundExistsExact(paymentID string, amount int64) bool { for _, r := range m.refunds { if r.PaymentID == paymentID && r.Status == "COMPLETED" && r.Amount == amount { return true } } return false } // 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 } // GrandfatherSavedCard marks a ccof: token exempt from the saved-card // verification gate (SimulateSavedCardVerificationRequired), so that card // charges without a verification token — mirroring a card Square has already // verified or holds a standing SCA exemption for. func (m *MockClient) GrandfatherSavedCard(ccofToken string) { m.mu.Lock() defer m.mu.Unlock() m.grandfatheredCards[ccofToken] = true } // ApprovePendingVerification marks the recorded buyer-verification challenge // for a saved card as approved (creating it if the gate never recorded one), so // a subsequent tokenized charge of that card succeeds. func (m *MockClient) ApprovePendingVerification(ccofToken string) { m.mu.Lock() defer m.mu.Unlock() if ch, ok := m.pendingChallenges[ccofToken]; ok { ch.outcome = "approved" return } m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "approved"} } // DenyPendingVerification marks the recorded buyer-verification challenge for // a saved card as denied, so a subsequent tokenized charge of that card is // rejected with VERIFICATION_TOKEN_INVALID (the token is consumed). func (m *MockClient) DenyPendingVerification(ccofToken string) { m.mu.Lock() defer m.mu.Unlock() if ch, ok := m.pendingChallenges[ccofToken]; ok { ch.outcome = "denied" return } m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "denied"} } 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. The legacy verify_mock_* shape is // rejected explicitly — real Square never accepts it as a source. if strings.HasPrefix(cardToken, "verify_mock_") { return nil, legacyVerifyMockSourceError(cardToken) } if !isTokenLikeMock(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 }