The CURRENT saved-card SCA contract (Square card.tokenize(verificationDetails, cardId)) returns a one-time tokenize-result that must be sent as the charge SOURCE (source_id), not a separate verification_token. - square_dev.go: the mock validates the WIRE BODY (mockPaymentWireBody — an independently assembled copy of buildCreatePaymentBody) so it accepts exactly the request shape the real client emits. SimulateSavedCardVerificationRequired now demands SCA on every saved-card charge in both wire shapes: (a) a genuine tokenize-result (cnon:sca-... — isSCATokenizeResultSource) as source_id + customer_id is ACCEPTED (the token IS the buyer verification); a RAW card.tokenize() nonce in the tokenize-result slot is REJECTED CARD_DECLINED_VERIFICATION_REQUIRED (money-F2 — the mock is the enforcement point that stops the forged shape); (b) legacy ccof: + verification_token is kept for backward-compat. - square_http_client.go: byte-identical body assembly shared with the mock, so TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical pins the mock and the real client emit identical CreatePayment bodies (a wire drift fails the test before reaching prod).
1561 lines
69 KiB
Go
1561 lines
69 KiB
Go
//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_<prefix>_<amount>_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_<prefix>_<amount>[_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_<prefix>_<amount>[_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-")
|
|
}
|
|
|
|
// 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).
|
|
if !isTokenLike(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",
|
|
StatusCode: http.StatusBadRequest,
|
|
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",
|
|
StatusCode: http.StatusBadRequest,
|
|
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.
|
|
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",
|
|
StatusCode: http.StatusBadRequest,
|
|
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 <status> (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_<n>"; a refund targeting such an
|
|
// ID that is NOT in the ledger is a provable bug (that charge never went
|
|
// through this mock) and real Square answers 404 NOT_FOUND. Non-"pay_mock_"
|
|
// IDs (e.g. the DB-fixture square_payment_id values handler tests seed
|
|
// refunds against) are payments that exist outside the mock's ledger —
|
|
// exactly as they would at real Square — so they take the lenient path.
|
|
if strings.HasPrefix(req.PaymentID, "pay_mock_") {
|
|
if _, known := m.payments[req.PaymentID]; !known {
|
|
log.Printf("[SQUARE-MOCK] RefundPayment REJECTED: payment %s not found (NOT_FOUND)", req.PaymentID)
|
|
return nil, &squareAPIError{
|
|
Code: "NOT_FOUND",
|
|
Category: "INVALID_REQUEST_ERROR",
|
|
Detail: "The payment_id in the refund request does not exist",
|
|
StatusCode: http.StatusNotFound,
|
|
err: fmt.Errorf("square: no payment %s exists to refund", tokenPrefix(req.PaymentID)),
|
|
}
|
|
}
|
|
}
|
|
|
|
amount := req.Amount
|
|
|
|
// Known payments get the real Square over-refund rejection: refunding more
|
|
// than the remaining balance answers 400 REFUND_AMOUNT_INVALID. Square
|
|
// returns that SAME code for an already-refunded payment, so — exactly like
|
|
// the real client — the mock reconciles: an existing refund (money already
|
|
// moved) → ErrRefundAlreadyProcessed; no refund recorded → the amount is
|
|
// genuinely invalid → ErrRefundDeclined.
|
|
if payment, ok := m.payments[req.PaymentID]; ok {
|
|
remaining := payment.Amount
|
|
for _, r := range m.refunds {
|
|
if r.PaymentID == req.PaymentID && (r.Status == "COMPLETED" || r.Status == "APPROVED" || r.Status == "PENDING") {
|
|
remaining -= r.Amount
|
|
}
|
|
}
|
|
if req.Amount > remaining {
|
|
apiErr := &squareAPIError{
|
|
Code: "REFUND_AMOUNT_INVALID",
|
|
Category: "INVALID_REQUEST_ERROR",
|
|
Detail: "The refunded amount is more than the remaining balance",
|
|
StatusCode: http.StatusBadRequest,
|
|
err: fmt.Errorf("square: refund amount %d exceeds remaining balance %d for payment %s", req.Amount, remaining, req.PaymentID),
|
|
}
|
|
if remaining < payment.Amount {
|
|
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, apiErr)
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, apiErr)
|
|
}
|
|
} else {
|
|
// Payment not in mock map — this happens when integration tests create
|
|
// payments via DB fixture with a square_payment_id, bypassing the mock.
|
|
// Process the refund without full payment data (the balance is unknown,
|
|
// so no over-refund check applies).
|
|
log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID)
|
|
}
|
|
|
|
locationID := req.LocationID
|
|
if locationID == "" {
|
|
locationID = "L_MOCK"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
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
|
|
}
|