From d25ba16aa7e15b52865d530ab90bd40a139a26b5 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 16 Aug 2026 00:16:13 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Square=20SCA=20tokenize-result=20wire?= =?UTF-8?q?=20contract=20=E2=80=94=20token=20as=20source=5Fid,=20byte-iden?= =?UTF-8?q?tical=20dev=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/internal/square/square_dev.go | 268 ++++++++++++------ backend/internal/square/square_dev_test.go | 238 ++++++++++++++++ backend/internal/square/square_http_client.go | 68 ++++- .../square/square_http_client_test.go | 61 ++++ backend/internal/square/types.go | 60 ++-- 5 files changed, 571 insertions(+), 124 deletions(-) diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 3a075f7..7dbba37 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -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___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___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 diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index 534440e..50540d0 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -2649,3 +2649,241 @@ func TestDevClient_VerifyMockDenyTokenSuffix(t *testing.T) { require.NoError(t, err) require.Equal(t, "COMPLETED", result.Status, "the _ok encoding of the same binding must succeed (control)") } + +// ============================================================================= +// CURRENT saved-card SCA contract: tokenize-result as source_id + customer_id +// ============================================================================= + +// TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical is the CRITICAL wire +// contract guard. For the same logical saved-card SCA charge, the real client +// (buildCreatePaymentBody, square_http_client.go) and the dev mock +// (mockPaymentWireBody) must emit BYTE-IDENTICAL CreatePayment request bodies: +// source_id = the card.tokenize(verificationDetails, cardId) tokenize-result +// (a fresh cnon:-style one-time token) + customer_id resolved from the saved +// card, with NO legacy verification_token. The constructors are written +// independently on purpose — if either side drifts (a renamed field, a token +// misrouted into verification_token, amount handling drift), the byte +// comparison fails here before the divergence can reach prod, exactly like the +// legacy verification_token + ccof: drift this rebuild replaces. +func TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical(t *testing.T) { + ctx := context.Background() + + // The same logical saved-card SCA charge the frontend + handler produce: + // SourceID is the fresh tokenize-result, CustomerID derives from the saved + // card row, and NO verification_token rides along (the token IS the SCA). + req := CreatePaymentReq{ + Amount: 5000, + Currency: "GBP", + SourceID: "cnon:sca-tokenize-result", + IdempotencyKey: "sca-contract-key-1", + ReferenceID: "booking-contract-1", + Note: "full", + CustomerID: "cus_sca_123", + BuyerEmail: "buyer@example.com", + } + + // Real client: buildCreatePaymentBody with an explicit location matching + // the mock's default (location_id is the only env-dependent wire field — + // buildCreatePaymentBody defaults it to SQUARE_LOCATION_ID, the mock to + // L_MOCK). + clientWire, err := json.Marshal(buildCreatePaymentBody(req, &httpClient{locationID: "L_MOCK"})) + require.NoError(t, err) + + // Dev mock: its OWN independently-written wire-body constructor. + mockWire, err := json.Marshal(mockPaymentWireBody(req)) + require.NoError(t, err) + + require.Equal(t, string(clientWire), string(mockWire), + "mock and real client must emit byte-identical CreatePayment bodies for the saved-card SCA charge — wire drift re-slipped") + + // Sanity-pin the wire shape so the byte-identity is provably the CURRENT + // contract, not a shared-but-wrong shape. + var wire map[string]any + require.NoError(t, json.Unmarshal(clientWire, &wire)) + require.Equal(t, "cnon:sca-tokenize-result", wire["source_id"], "the tokenize-result token must be the source_id") + require.Equal(t, "cus_sca_123", wire["customer_id"], "customer_id must come from the saved card") + require.NotContains(t, wire, "verification_token", "the SCA tokenize-result path must not emit a legacy verification_token") + amt, ok := wire["amount_money"].(map[string]any) + require.True(t, ok, "amount_money object expected, got %v", wire["amount_money"]) + require.Equal(t, float64(5000), amt["amount"], "amount_money.amount must be integer pence") + require.Equal(t, "GBP", amt["currency"]) + require.Equal(t, "sca-contract-key-1", wire["idempotency_key"]) + require.Equal(t, "L_MOCK", wire["location_id"]) + + // Functional acceptance: the mock must ACCEPT this exact charge end-to-end + // even with the saved-card SCA gate enforced — the tokenize-result IS the + // buyer verification, so no verification_token is demanded. + mock := NewDevClient().(*MockClient) + mock.SimulateSavedCardVerificationRequired = true + pr, err := mock.CreatePayment(ctx, req) + require.NoError(t, err, "the mock must accept the client's SCA tokenize-result charge end-to-end") + require.Equal(t, "COMPLETED", pr.Status) + + // And the LEGACY verifyBuyer() shape (ccof: + verification_token) stays + // accepted for backward-compat — the current contract replaces the legacy + // primary path, it does not remove it. + legacyReq := req + legacyReq.SourceID = "ccof:mock_saved" + legacyReq.VerificationToken = "verify_mock_mock_5000_ok" + legacyReq.IdempotencyKey = "sca-contract-key-2" + pr, err = mock.CreatePayment(ctx, legacyReq) + require.NoError(t, err, "the legacy ccof + verification_token shape must stay accepted") + require.Equal(t, "COMPLETED", pr.Status) +} + +// TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_Accepted locks the +// mock's saved-card gate on the CURRENT contract: under +// SimulateSavedCardVerificationRequired, a charge with a fresh cnon:-style +// tokenize-result as source_id + the saved card's customer_id is ACCEPTED +// without any verification_token (the token only exists because the buyer +// completed issuer verification — it IS the SCA proof). A cnon: charge with NO +// customer_id is NOT a saved-card charge (the one-off new-card flow never sets +// one), so the saved-card gate leaves it to the separate new-card gate. +func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_Accepted(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSavedCardVerificationRequired = true + ctx := context.Background() + + t.Run("tokenize_result_with_customer_is_sca_compliant", func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: "cnon:sca-tokenize-1", CustomerID: "cus_sca_1", + IdempotencyKey: "sca-accept-1", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", result.Status) + require.Equal(t, "cus_sca_1", result.CustomerID) + }) + + t.Run("tokenize_result_without_customer_is_not_a_saved_card_charge", func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: "cnon:new-card-nonce", IdempotencyKey: "sca-newcard-1", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", result.Status) + }) +} + +// TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_BothGatesOn locks the +// interaction between the new-card and saved-card SCA gates: with BOTH toggles +// on, a cnon:+customer_id tokenize-result is accepted (the token IS the buyer +// verification, so it is exempt from the new-card verification-token +// requirement), while a plain cnon: new-card nonce without a verification token +// is still rejected by the new-card gate. +func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_BothGatesOn(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateVerificationRequired = true + client.SimulateSavedCardVerificationRequired = true + ctx := context.Background() + + t.Run("tokenize_result_with_customer_passes_both_gates", func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: "cnon:sca-both-on", CustomerID: "cus_sca_both", + IdempotencyKey: "both-on-accept", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", result.Status) + }) + + t.Run("new_card_nonce_without_token_still_rejected", func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: "cnon:new-card-nonce", IdempotencyKey: "both-on-reject", + }) + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) + require.True(t, IsDefinitivePaymentError(err)) + }) +} + +// TestDevClient_CreatePayment_SavedCard_RawNonceInTokenizeSlot_Rejected locks +// the money-F2 fix: under SimulateSavedCardVerificationRequired, a RAW +// card.tokenize() nonce (e.g. "cnon:test-card") sent in the SCA tokenize-result +// slot (source_id + customer_id) is REJECTED with the structured 400 +// CARD_DECLINED_VERIFICATION_REQUIRED — real Square rejects an unverified nonce +// as a card-on-file charge source, so the mock must too (the handler treats any +// non-empty new_card_token + saved-card ref as an SCA tokenize-result, skipping +// the 2FA and consent gates; the mock is the enforcement point that stops the +// forged shape). A GENUINE tokenize-result (cnon:sca-..., the marker the dev +// frontend mints) still passes — the token IS the buyer verification. With the +// gate off (default) the raw-nonce shape keeps charging, so existing dev flows +// are unaffected. +func TestDevClient_CreatePayment_SavedCard_RawNonceInTokenizeSlot_Rejected(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSavedCardVerificationRequired = true + ctx := context.Background() + + t.Run("raw_nonce_with_customer_is_rejected", func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: "cnon:test-card", CustomerID: "cus_forge", + IdempotencyKey: "forge-raw-nonce", + }) + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) + require.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) + require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) + require.True(t, IsDefinitivePaymentError(err), "a forged unverified nonce must be a definitive payment error") + }) + + t.Run("genuine_tokenize_result_still_accepted", func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: "cnon:sca-tokenize-fresh", CustomerID: "cus_genuine", + IdempotencyKey: "genuine-tokenize-result", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", result.Status) + }) + + t.Run("gate_off_keeps_legacy_raw_nonce_shape", func(t *testing.T) { + client.SimulateSavedCardVerificationRequired = false + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: "cnon:test-card", CustomerID: "cus_legacy", + IdempotencyKey: "raw-nonce-gate-off", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", result.Status, "with the saved-card SCA gate off (default), the raw-nonce shape must keep charging") + }) +} + +// TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_SingleUse locks the +// single-use contract for the saved-card SCA shape under SimulateSourceUsed: a +// genuine tokenize-result (cnon:sca-...) sent with a customer_id is consumed on +// its first CreatePayment, so reusing the SAME token for a DIFFERENT booking +// (a different idempotency key) is rejected with CARD_TOKEN_USED — Square +// consumes a nonce regardless of which endpoint or charge used it. +func TestDevClient_CreatePayment_SavedCard_SCATokenizeResult_SingleUse(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSourceUsed = true + client.SimulateSavedCardVerificationRequired = true + ctx := context.Background() + source := "cnon:sca-single-use-booking" + + // Booking A's charge consumes the tokenize-result. + first, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: source, CustomerID: "cus_single", + IdempotencyKey: "booking-A-charge", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", first.Status) + + // Booking B's charge reuses the SAME token under a DIFFERENT key — Square's + // CARD_TOKEN_USED rejection. + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", + SourceID: source, CustomerID: "cus_single", + IdempotencyKey: "booking-B-charge", + }) + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, "CARD_TOKEN_USED", ErrorCode(err)) + require.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) + require.Contains(t, client.UsedSources(), source, "the consumed tokenize-result must be reported by UsedSources") +} diff --git a/backend/internal/square/square_http_client.go b/backend/internal/square/square_http_client.go index a283240..8c1da5c 100644 --- a/backend/internal/square/square_http_client.go +++ b/backend/internal/square/square_http_client.go @@ -213,6 +213,20 @@ type sqMoney struct { // --- Payment types --- +// sqCreatePaymentRequest is the exact POST /v2/payments wire body. +// +// SAVED-CARD SCA CONTRACT (CURRENT — Square's "Charge a Card on File" flow): +// the frontend runs card.tokenize(verificationDetails, cardId), which returns +// a fresh one-time cnon:-style tokenize-result minted ONLY after the buyer +// completed issuer verification for that card + amount. That tokenize-result +// is sent here as source_id, together with customer_id resolved from the saved +// card row — Square requires customer_id for a card-on-file source, and the +// token IS the buyer verification, so no verification_token is emitted on this +// path. verification_token is retained ONLY as the LEGACY verifyBuyer() field +// (Square is deprecating verifyBuyer(); see +// https://developer.squareup.com/docs/web-payments/take-card-payment) and is +// emitted only when a caller explicitly populates CreatePaymentReq. +// VerificationToken — no current handler does. type sqCreatePaymentRequest struct { SourceID string `json:"source_id"` IdempotencyKey string `json:"idempotency_key"` @@ -223,7 +237,7 @@ type sqCreatePaymentRequest struct { CustomerID string `json:"customer_id,omitempty"` Note string `json:"note,omitempty"` TipMoney *sqMoney `json:"tip_money,omitempty"` - VerificationToken string `json:"verification_token,omitempty"` + VerificationToken string `json:"verification_token,omitempty"` // LEGACY verifyBuyer() token — deprecated, kept as a fallback BuyerEmailAddress string `json:"buyer_email_address,omitempty"` // CustomerDetails carries customer_initiated so Square classifies the // charge as cardholder-initiated (SCA applies) rather than defaulting to a @@ -453,10 +467,12 @@ func validCardID(id string) bool { return validSquareID(id) } -// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and -// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected. -// This is the single source of truth for token validation, shared by the real -// HTTP client and the dev mock so PCI-DSS parity holds in both builds. +// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces (new +// cards AND saved-card SCA tokenize-results — card.tokenize(verificationDetails, +// cardId) returns a fresh cnon:-style one-time token) and ccof:xxx card IDs. +// Raw PANs (all digits) are NOT token-like and are rejected. This is the single +// source of truth for token validation, shared by the real HTTP client and the +// dev mock so PCI-DSS parity holds in both builds. func isTokenLike(s string) bool { return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:") } @@ -504,6 +520,15 @@ func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc * // charge) and replayPaymentByKeyHTTPWithClient (the identical-body replay), so // a charge replayed from the stored snapshot produces BYTE-IDENTICAL JSON to // the original — Square's idempotency dedup compares the full request body. +// +// Saved-card SCA charges ride this unchanged: the handler resolves the charge +// source to the fresh card.tokenize(verificationDetails, cardId) tokenize-result +// (a cnon:-style token) in SourceID and the saved card's customer in +// CustomerID — the CURRENT contract, where the token IS the buyer verification. +// VerificationToken is passed through untouched for the LEGACY verifyBuyer() +// path only. The dev mock validates the SAME wire shape via mockPaymentWireBody +// (square_dev.go); TestCreatePayment_SCA_SavedCard_WireBody_ByteIdentical +// asserts both constructors stay byte-identical. func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymentRequest { body := sqCreatePaymentRequest{ SourceID: req.SourceID, @@ -634,15 +659,30 @@ func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentR // IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending forever, // so the snapshot is never reconstructed from partial row data. // -// TODO (UNVERIFIED ASSUMPTION): this codebase assumes Square retains -// idempotency keys for ~24 hours. The stale-pending sweeps guard on that -// window with three named constants (defined in handlers/payments): the keyed -// reconcile cutoff stalePendingKeyedAge (22h, sweep.go), the pass-2 -// blind-fail cutoff stalePendingPaymentAge (24h, sweep.go), and the refund -// age guard stalePendingRefundAge (23h, refunds.go). Square's public docs no -// longer state the exact retention window — confirm the current value with -// Square support and update the sweep age guards and this comment when -// confirmed. +// IDEMPOTENCY-KEY RETENTION ASSUMPTION (~24h). The identical-body replay's +// safety relies on Square retaining idempotency keys long enough that a +// stale-pending row's replay still dedups to the original charge. This +// codebase assumes ~24 hours, and the stale-pending sweeps (defined in +// handlers/payments, which own those constants) guard on that window with +// safety margins: the keyed reconcile cutoff stalePendingKeyedAge (22h, +// sweep.go), the pass-2 blind-fail cutoff stalePendingPaymentAge (24h, +// sweep.go), and the refund age guard stalePendingRefundAge (23h, refunds.go). +// Square's public docs do NOT state the exact retention window — the +// Idempotency guide +// (https://developer.squareup.com/docs/build-basics/common-api-patterns/idempotency) +// documents key semantics (same-key retry returns the original response; +// same-key + different body returns an error) but not how long keys are held. +// The assumption is SAFE IN BOTH DIRECTIONS: +// - If Square retains keys SHORTER than 24h, the pass-2 sweep at 24h replays +// a key Square no longer holds → Square attempts a REAL charge with the +// (expired/used) stored source → definitive 4xx → the row is failed as +// ErrReplayKeyNotRetained (proof the charge never happened). The rescue +// degrades, it never double-charges. +// - If Square retains keys LONGER than 24h, the sweeps simply hold rows a +// little longer before giving up — no money moves incorrectly. +// +// Revisit this comment and the sweep constants if Square ever documents a +// different window. func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) { var req CreatePaymentReq if err := json.Unmarshal(snapshotJSON, &req); err != nil { diff --git a/backend/internal/square/square_http_client_test.go b/backend/internal/square/square_http_client_test.go index 909d011..f561fff 100644 --- a/backend/internal/square/square_http_client_test.go +++ b/backend/internal/square/square_http_client_test.go @@ -2029,3 +2029,64 @@ func TestInvalidRequestError_IsCategoryNotCode(t *testing.T) { t.Error("INVALID_REQUEST_ERROR is a category, never a definitive payment code") } } + +// TestCreatePaymentHTTP_SCASavedCard_WireShape locks the CURRENT saved-card SCA +// wire contract on the real client: a card-on-file charge sends source_id = the +// card.tokenize(verificationDetails, cardId) tokenize-result (a cnon:-style +// one-time token) plus customer_id from the saved card — Square requires +// customer_id for a card-on-file source — and emits NO legacy verification_token +// (the token IS the buyer verification). The dev mock's saved-card gate accepts +// this exact shape, and the byte-identity contract test in square_dev_test.go +// pins the mock's wire body identical to this client's. +func TestCreatePaymentHTTP_SCASavedCard_WireShape(t *testing.T) { + var captured map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payment":{"id":"pay_sca","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"ON_FILE"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) + })) + defer srv.Close() + + hc := &httpClient{baseURL: srv.URL, token: "t", locationID: "loc", http: srv.Client()} + _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ + Amount: 5000, + Currency: "GBP", + SourceID: "cnon:sca-tokenize-result", + CustomerID: "cus_sca_123", + IdempotencyKey: "sca-wire-shape-key", + ReferenceID: "booking-sca-1", + Note: "full", + LocationID: "loc", + }, hc) + if err != nil { + t.Fatalf("createPaymentHTTP failed: %v", err) + } + + if captured["source_id"] != "cnon:sca-tokenize-result" { + t.Errorf("expected source_id = the tokenize-result token, got %v", captured["source_id"]) + } + if captured["customer_id"] != "cus_sca_123" { + t.Errorf("expected customer_id from the saved card, got %v", captured["customer_id"]) + } + if _, present := captured["verification_token"]; present { + t.Errorf("the SCA tokenize-result path must NOT emit a legacy verification_token, got %v", captured["verification_token"]) + } + amt, ok := captured["amount_money"].(map[string]any) + if !ok { + t.Fatalf("expected amount_money object, got %v", captured["amount_money"]) + } + if amt["amount"] != float64(5000) || amt["currency"] != "GBP" { + t.Errorf("expected amount_money {5000 GBP} in integer pence, got %v", amt) + } + if captured["idempotency_key"] != "sca-wire-shape-key" { + t.Errorf("expected idempotency_key, got %v", captured["idempotency_key"]) + } + if captured["reference_id"] != "booking-sca-1" { + t.Errorf("expected reference_id, got %v", captured["reference_id"]) + } + if captured["location_id"] != "loc" { + t.Errorf("expected location_id, got %v", captured["location_id"]) + } +} diff --git a/backend/internal/square/types.go b/backend/internal/square/types.go index c0a1cf0..1b2db7c 100644 --- a/backend/internal/square/types.go +++ b/backend/internal/square/types.go @@ -40,12 +40,12 @@ var ErrReplayKeyNotRetained = errors.New("square: no payment under idempotency k // CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments). // Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment type CreatePaymentReq struct { - Amount int64 // in pence - Currency string // "GBP" - SourceID string // card token ("cnon:xxx" nonce) or card-on-file ID - IdempotencyKey string - ReferenceID string // booking ID or other reference - Note string + Amount int64 // in pence + Currency string // "GBP" + SourceID string // card token — a "cnon:xxx" nonce (new-card entry OR the saved-card SCA tokenize-result from card.tokenize(verificationDetails, cardId)) or a "ccof:xxx" card-on-file ID + IdempotencyKey string + ReferenceID string // booking ID or other reference + Note string // Autocomplete and TipMoney are valid Square wire fields that are // intentionally NOT populated by any current handler: online payments are // completed immediately (Autocomplete nil = Square default true, no @@ -53,12 +53,12 @@ type CreatePaymentReq struct { // payments rather than split inside Square's CreatePayment (TipMoney nil). // They are wired through the client into the request body for completeness // and future use — do not remove them. - Autocomplete *bool // nil (default) = true — complete immediately; false = approve only - TipMoney *int64 // optional tip amount in pence - CustomerID string // Square customer ID for card-on-file payments - LocationID string // Square location ID (optional; defaults to main location) - VerificationToken string // 3DS / SCA verification token from buyer verification - BuyerEmail string // buyer email for receipt + Autocomplete *bool // nil (default) = true — complete immediately; false = approve only + TipMoney *int64 // optional tip amount in pence + CustomerID string // Square customer ID — REQUIRED for saved-card (ccof: or tokenize-result) charges; Square rejects a card-on-file source without it + LocationID string // Square location ID (optional; defaults to main location) + VerificationToken string // LEGACY verifyBuyer() 3DS/SCA verification token — the CURRENT saved-card SCA contract sends the tokenize-result as SourceID instead, so no current handler populates this; kept for the deprecated verifyBuyer() path only + BuyerEmail string // buyer email for receipt // CustomerDetails classifies the charge as cardholder-initiated (true) or // merchant-initiated (false) for Square's SCA / liability-shift logic, // wired through as customer_details.customer_initiated on POST /v2/payments. @@ -103,7 +103,7 @@ type CreateCheckoutReq struct { // RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds). type RefundPaymentReq struct { PaymentID string - Amount int64 // in pence; REQUIRED — Square rejects a missing/zero amount_money (never "0 = full refund") + Amount int64 // in pence; REQUIRED — Square rejects a missing/zero amount_money (never "0 = full refund") IdempotencyKey string Reason string LocationID string // Square location ID (optional; defaults to main location) @@ -125,23 +125,23 @@ type PaymentResult struct { // ExpMonth/ExpYear are nil when the payment has no card details (e.g. a // non-card source). Square returns exp_month/exp_year only for card // payments, so a plain int could not distinguish 0 from an absent value. - ExpMonth *int - ExpYear *int - EntryMethod string // "KEYED", "ON_FILE", "EMV", "SWIPED", "CONTACTLESS" - CVVStatus string // "CVV_ACCEPTED", "CVV_REJECTED", "CVV_NOT_CHECKED" - AVSStatus string // "AVS_ACCEPTED", "AVS_REJECTED", "AVS_NOT_CHECKED" - TipAmount int64 // tip portion in pence - ReceiptURL string // link to Square hosted receipt - ReceiptNumber string // Square receipt number - SquarePayID string // Square's payment ID (same as ID in production) - Fees int64 // total processing fee in pence - BuyerEmail string // buyer email (if provided) - CustomerID string // Square customer ID (if linked) - LocationID string // Square location ID where payment was processed - CreatedAt string // ISO 8601 timestamp - UpdatedAt string // ISO 8601 timestamp - OrderID string // Square order ID (if linked to an order) - ReferenceID string // client-specified reference (booking ID etc.) + ExpMonth *int + ExpYear *int + EntryMethod string // "KEYED", "ON_FILE", "EMV", "SWIPED", "CONTACTLESS" + CVVStatus string // "CVV_ACCEPTED", "CVV_REJECTED", "CVV_NOT_CHECKED" + AVSStatus string // "AVS_ACCEPTED", "AVS_REJECTED", "AVS_NOT_CHECKED" + TipAmount int64 // tip portion in pence + ReceiptURL string // link to Square hosted receipt + ReceiptNumber string // Square receipt number + SquarePayID string // Square's payment ID (same as ID in production) + Fees int64 // total processing fee in pence + BuyerEmail string // buyer email (if provided) + CustomerID string // Square customer ID (if linked) + LocationID string // Square location ID where payment was processed + CreatedAt string // ISO 8601 timestamp + UpdatedAt string // ISO 8601 timestamp + OrderID string // Square order ID (if linked to an order) + ReferenceID string // client-specified reference (booking ID etc.) } // CheckoutResult maps to Square's TerminalCheckout object.