feat: Square SCA tokenize-result wire contract — token as source_id, byte-identical dev mock
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).
This commit is contained in:
@@ -159,21 +159,35 @@ type MockClient struct {
|
||||
// plain "cnon:test-card"-style tokens without verification tokens.
|
||||
SimulateVerificationRequired bool
|
||||
// SimulateSavedCardVerificationRequired mirrors Square's SCA enforcement on
|
||||
// saved-card (ccof:) charges — the SCA-primary saved-card posture the
|
||||
// platform is moving toward (buyer verification on card-on-file charges,
|
||||
// not just new-card nonces). When true, CreatePayment with a ccof: source
|
||||
// and NO VerificationToken is rejected with the same structured 400
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, 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 ccof charge that would
|
||||
// be rejected by Square's SCA.
|
||||
// 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"
|
||||
@@ -428,6 +442,19 @@ func verificationTokenPrefixForSource(sourceID string) string {
|
||||
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
|
||||
@@ -497,15 +524,49 @@ func (m *MockClient) resolveVerificationToken(token, sourceID string, amount int
|
||||
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(req.SourceID) {
|
||||
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(req.SourceID))
|
||||
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
|
||||
@@ -513,7 +574,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
// £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 req.Amount <= 0 {
|
||||
if body.AmountMoney.Amount <= 0 {
|
||||
return nil, &squareAPIError{
|
||||
Code: "INVALID_REQUEST_ERROR",
|
||||
Category: "INVALID_REQUEST_ERROR",
|
||||
@@ -546,25 +607,25 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
// VALUE_TOO_LONG; the mock mirrors the rejection with the same structured
|
||||
// error so dev parity catches over-length keys (the real client always
|
||||
// derives ≤45-char keys, so this only fires on a caller bug).
|
||||
if len(req.IdempotencyKey) > MaxIdempotencyKeyLength {
|
||||
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(req.IdempotencyKey), len(req.IdempotencyKey)),
|
||||
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(req.SourceID) > 8 {
|
||||
sourcePrefix = req.SourceID[:8] + "..."
|
||||
if len(body.SourceID) > 8 {
|
||||
sourcePrefix = body.SourceID[:8] + "..."
|
||||
} else {
|
||||
sourcePrefix = req.SourceID
|
||||
sourcePrefix = body.SourceID
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s, source=%s", req.Amount, req.ReferenceID, sourcePrefix)
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s, source=%s", body.AmountMoney.Amount, body.ReferenceID, sourcePrefix)
|
||||
mockSleep(1 * time.Second)
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -580,26 +641,38 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
// gift-card same-key retry (which refreshes square_source_id with a fresh
|
||||
// cnon on pending-reuse) surfaces the real prod rejection in dev instead of
|
||||
// succeeding where prod would strand the row pending for the sweep.
|
||||
if req.IdempotencyKey != "" {
|
||||
if existing, ok := m.paymentByKey[req.IdempotencyKey]; ok {
|
||||
if storedSource, hasSource := m.paymentSource[req.IdempotencyKey]; hasSource && storedSource != "" && storedSource != req.SourceID {
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment IDEMPOTENCY_KEY_REUSED: key=%s reused with a different source (%s vs %s)", req.IdempotencyKey, tokenPrefix(req.SourceID), tokenPrefix(storedSource))
|
||||
return nil, keyReuseError(req.IdempotencyKey)
|
||||
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", req.IdempotencyKey, existing.ID)
|
||||
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 a 3DS/SCA verification token is
|
||||
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the
|
||||
// buyer must re-verify and re-tokenize, NOT retry the same request (the
|
||||
// code is in definitivePaymentCodes). A present verification token (e.g.
|
||||
// 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.
|
||||
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") {
|
||||
if req.VerificationToken == "" {
|
||||
// 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",
|
||||
@@ -607,36 +680,74 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
||||
}
|
||||
}
|
||||
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, false); err != nil {
|
||||
return nil, err
|
||||
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 (ccof:) 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 ccof:
|
||||
// charge without a 3DS/SCA verification token is rejected with the same
|
||||
// structured 400 CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and
|
||||
// a pending buyer-verification challenge is recorded for the card. A
|
||||
// subsequent charge WITH a verification token resolves the challenge (see
|
||||
// resolveVerificationToken). Grandfathered cards (GrandfatherSavedCard)
|
||||
// bypass the gate.
|
||||
if m.SimulateSavedCardVerificationRequired && strings.HasPrefix(req.SourceID, "ccof:") {
|
||||
if req.VerificationToken == "" {
|
||||
if m.grandfatheredCards[req.SourceID] {
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate bypassed: source %s is grandfathered", tokenPrefix(req.SourceID))
|
||||
// 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[req.SourceID] = &pendingChallenge{outcome: "approved"}
|
||||
m.pendingChallenges[body.SourceID] = &pendingChallenge{outcome: "approved"}
|
||||
} else {
|
||||
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: ""}
|
||||
m.pendingChallenges[body.SourceID] = &pendingChallenge{outcome: ""}
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate: source %s rejected without a verification token", tokenPrefix(req.SourceID))
|
||||
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",
|
||||
@@ -645,8 +756,8 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, true); err != nil {
|
||||
default:
|
||||
if err := m.resolveVerificationToken(body.VerificationToken, body.SourceID, body.AmountMoney.Amount, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -663,23 +774,23 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
// global) and reuses "cnon:test-card"-style tokens across tests, so
|
||||
// default-on consumption would break those tests. Tests that need the
|
||||
// single-use simulation flip the toggle on.
|
||||
if strings.HasPrefix(req.SourceID, "cnon:") && m.SimulateSourceUsed {
|
||||
if m.usedSources[req.SourceID] {
|
||||
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(req.SourceID)),
|
||||
err: fmt.Errorf("square: card nonce %s has already been used to create a payment", tokenPrefix(body.SourceID)),
|
||||
}
|
||||
}
|
||||
m.usedSources[req.SourceID] = true
|
||||
m.usedSources[body.SourceID] = true
|
||||
}
|
||||
|
||||
now := clock.Now().UTC()
|
||||
|
||||
status := "COMPLETED"
|
||||
if req.Autocomplete != nil && !*req.Autocomplete {
|
||||
if body.Autocomplete != nil && !*body.Autocomplete {
|
||||
status = "APPROVED"
|
||||
}
|
||||
if m.ForcePaymentStatus != "" {
|
||||
@@ -690,18 +801,18 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
status = m.ForcePaymentStatus
|
||||
}
|
||||
|
||||
amount := req.Amount
|
||||
amount := body.AmountMoney.Amount
|
||||
tipAmount := int64(0)
|
||||
if req.TipMoney != nil {
|
||||
tipAmount = *req.TipMoney
|
||||
if body.TipMoney != nil {
|
||||
tipAmount = body.TipMoney.Amount
|
||||
amount += tipAmount
|
||||
}
|
||||
|
||||
cardBrand, cardLast4 := detectCardInfo(req.SourceID)
|
||||
cardBrand, cardLast4 := detectCardInfo(body.SourceID)
|
||||
|
||||
// Entry method: ON_FILE for card-on-file tokens, KEYED for nonces
|
||||
entryMethod := "KEYED"
|
||||
if len(req.SourceID) >= 5 && req.SourceID[:5] == "ccof:" {
|
||||
if len(body.SourceID) >= 5 && body.SourceID[:5] == "ccof:" {
|
||||
entryMethod = "ON_FILE"
|
||||
}
|
||||
|
||||
@@ -714,10 +825,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
// see TestProcessingFeeSign_Parity_MockAndRealClientAgree.
|
||||
fees := amount*14/1000 + 25 // online rate: 1.4% + 25p
|
||||
|
||||
locationID := req.LocationID
|
||||
if locationID == "" {
|
||||
locationID = "L_MOCK"
|
||||
}
|
||||
locationID := body.LocationID
|
||||
|
||||
expMonth := 12
|
||||
expYear := 2030
|
||||
@@ -739,12 +847,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", now.UnixNano()),
|
||||
SquarePayID: paymentID,
|
||||
Fees: fees,
|
||||
BuyerEmail: req.BuyerEmail,
|
||||
CustomerID: req.CustomerID,
|
||||
BuyerEmail: body.BuyerEmailAddress,
|
||||
CustomerID: body.CustomerID,
|
||||
LocationID: locationID,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
UpdatedAt: now.Format(time.RFC3339),
|
||||
ReferenceID: req.ReferenceID,
|
||||
ReferenceID: body.ReferenceID,
|
||||
}
|
||||
m.payments[paymentID] = result
|
||||
// SquarePayID is the same ID as the payment (paymentFromSquare sets
|
||||
@@ -752,9 +860,9 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
// client — reconcile/sweep code that resolves a stored square_payment_id
|
||||
// via GetPayment behaves the same in mock and prod.
|
||||
m.payments[result.SquarePayID] = result
|
||||
if req.IdempotencyKey != "" {
|
||||
m.paymentByKey[req.IdempotencyKey] = result
|
||||
m.paymentSource[req.IdempotencyKey] = req.SourceID
|
||||
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 {
|
||||
@@ -763,7 +871,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
// response: the caller sees a 5xx-style error while Square holds the
|
||||
// payment under the key. A same-key + same-source retry dedups to the
|
||||
// committed payment instead of charging twice, exactly like prod.
|
||||
log.Printf("[SQUARE-MOCK] FailAfterCommit: payment %s committed under key=%s but returning simulated 503 (response lost)", paymentID, req.IdempotencyKey)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user