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.
|
// plain "cnon:test-card"-style tokens without verification tokens.
|
||||||
SimulateVerificationRequired bool
|
SimulateVerificationRequired bool
|
||||||
// SimulateSavedCardVerificationRequired mirrors Square's SCA enforcement on
|
// SimulateSavedCardVerificationRequired mirrors Square's SCA enforcement on
|
||||||
// saved-card (ccof:) charges — the SCA-primary saved-card posture the
|
// saved-card charges — the SCA-primary saved-card posture the platform uses
|
||||||
// platform is moving toward (buyer verification on card-on-file charges,
|
// (buyer verification on card-on-file charges, not just new-card nonces).
|
||||||
// not just new-card nonces). When true, CreatePayment with a ccof: source
|
// When true, CreatePayment demands SCA on every saved-card charge, where a
|
||||||
// and NO VerificationToken is rejected with the same structured 400
|
// charge is a saved-card charge in one of two wire shapes:
|
||||||
// CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and a pending
|
// (a) a fresh cnon:-style tokenize-result as source_id + customer_id
|
||||||
// buyer-verification challenge is recorded for the card. A subsequent
|
// (Square's CURRENT contract: card.tokenize(verificationDetails,
|
||||||
// charge WITH a verification token resolves that challenge (see
|
// cardId) returns a one-time token sent as source_id with the card's
|
||||||
// resolveVerificationToken): an explicitly approved challenge, or a
|
// customer_id — the token IS the buyer verification, so this shape is
|
||||||
// stateless verify_mock_<prefix>_<amount>_ok token, lets the charge
|
// ACCEPTED without any verification_token). ONLY a genuine
|
||||||
// succeed; a denied challenge / _deny token is rejected with 400
|
// tokenize-result (cnon:sca-... — see isSCATokenizeResultSource) is
|
||||||
// VERIFICATION_TOKEN_INVALID. Cards marked via GrandfatherSavedCard bypass
|
// accepted: a RAW card.tokenize() nonce in the tokenize-result slot
|
||||||
// the gate entirely. Off by default — existing dev/test flows charge
|
// is REJECTED with CARD_DECLINED_VERIFICATION_REQUIRED, mirroring real
|
||||||
// saved cards without verification tokens, so flipping it on in a
|
// Square rejecting an unverified nonce as a card-on-file source
|
||||||
// prod-like test setup intentionally surfaces every ccof charge that would
|
// (money-F2 — the handler treats any non-empty new_card_token +
|
||||||
// be rejected by Square's SCA.
|
// 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
|
SimulateSavedCardVerificationRequired bool
|
||||||
// ChallengeResult configures the mock's SCA challenge outcome when a
|
// ChallengeResult configures the mock's SCA challenge outcome when a
|
||||||
// verification token is supplied on a gated charge. "" or "approve"
|
// verification token is supplied on a gated charge. "" or "approve"
|
||||||
@@ -428,6 +442,19 @@ func verificationTokenPrefixForSource(sourceID string) string {
|
|||||||
return ""
|
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
|
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
|
||||||
// a charge. savedCard=true resolves against the saved-card challenge ledger;
|
// a charge. savedCard=true resolves against the saved-card challenge ledger;
|
||||||
// savedCard=false (new-card nonce) treats any present token as satisfying the
|
// 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
|
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) {
|
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||||
if m.ShouldFail {
|
if m.ShouldFail {
|
||||||
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
|
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
|
// 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
|
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||||
// mock behaves identically to production (PCI-DSS parity).
|
// mock behaves identically to production (PCI-DSS parity).
|
||||||
if !isTokenLike(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(req.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
|
// Square's CreatePayment requires a positive amount_money — a missing or
|
||||||
// zero amount is rejected (400 INVALID_REQUEST_ERROR), never treated as a
|
// 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
|
// £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
|
// dev instead of minting a completed £0 payment that real Square would
|
||||||
// never accept (finding 2).
|
// never accept (finding 2).
|
||||||
if req.Amount <= 0 {
|
if body.AmountMoney.Amount <= 0 {
|
||||||
return nil, &squareAPIError{
|
return nil, &squareAPIError{
|
||||||
Code: "INVALID_REQUEST_ERROR",
|
Code: "INVALID_REQUEST_ERROR",
|
||||||
Category: "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
|
// VALUE_TOO_LONG; the mock mirrors the rejection with the same structured
|
||||||
// error so dev parity catches over-length keys (the real client always
|
// error so dev parity catches over-length keys (the real client always
|
||||||
// derives ≤45-char keys, so this only fires on a caller bug).
|
// 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{
|
return nil, &squareAPIError{
|
||||||
Code: "VALUE_TOO_LONG",
|
Code: "VALUE_TOO_LONG",
|
||||||
Detail: "idempotency_key must be 45 characters or fewer",
|
Detail: "idempotency_key must be 45 characters or fewer",
|
||||||
Category: "INVALID_REQUEST_ERROR",
|
Category: "INVALID_REQUEST_ERROR",
|
||||||
StatusCode: http.StatusBadRequest,
|
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
|
// 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
|
// card reference (ccof:) that could be replayed. Log only its prefix and
|
||||||
// length for debugging (S-2).
|
// length for debugging (S-2).
|
||||||
sourcePrefix := ""
|
sourcePrefix := ""
|
||||||
if len(req.SourceID) > 8 {
|
if len(body.SourceID) > 8 {
|
||||||
sourcePrefix = req.SourceID[:8] + "..."
|
sourcePrefix = body.SourceID[:8] + "..."
|
||||||
} else {
|
} 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)
|
mockSleep(1 * time.Second)
|
||||||
|
|
||||||
m.mu.Lock()
|
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
|
// 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
|
// cnon on pending-reuse) surfaces the real prod rejection in dev instead of
|
||||||
// succeeding where prod would strand the row pending for the sweep.
|
// succeeding where prod would strand the row pending for the sweep.
|
||||||
if req.IdempotencyKey != "" {
|
if body.IdempotencyKey != "" {
|
||||||
if existing, ok := m.paymentByKey[req.IdempotencyKey]; ok {
|
if existing, ok := m.paymentByKey[body.IdempotencyKey]; ok {
|
||||||
if storedSource, hasSource := m.paymentSource[req.IdempotencyKey]; hasSource && storedSource != "" && storedSource != req.SourceID {
|
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)", req.IdempotencyKey, tokenPrefix(req.SourceID), tokenPrefix(storedSource))
|
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(req.IdempotencyKey)
|
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
|
return existing, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror Square's SCA enforcement on NEW-CARD charges (opt-in toggle, off
|
// 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
|
// by default): a cnon: charge without 3DS/SCA verification is rejected
|
||||||
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the
|
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
|
||||||
// buyer must re-verify and re-tokenize, NOT retry the same request (the
|
// must re-verify and re-tokenize, NOT retry the same request (the code is
|
||||||
// code is in definitivePaymentCodes). A present verification token (e.g.
|
// in definitivePaymentCodes). A present verification token (e.g.
|
||||||
// verify_mock_...) satisfies the gate exactly as production accepts a
|
// verify_mock_...) satisfies the gate exactly as production accepts a
|
||||||
// Square-issued verification_token on the CreatePayment body.
|
// Square-issued verification_token on the CreatePayment body. EXEMPTION: a
|
||||||
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") {
|
// cnon: source carrying customer_id is the CURRENT saved-card SCA
|
||||||
if req.VerificationToken == "" {
|
// 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{
|
return nil, &squareAPIError{
|
||||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||||
Category: "PAYMENT_METHOD_ERROR",
|
Category: "PAYMENT_METHOD_ERROR",
|
||||||
@@ -607,36 +680,74 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
StatusCode: http.StatusBadRequest,
|
StatusCode: http.StatusBadRequest,
|
||||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
||||||
}
|
}
|
||||||
}
|
default:
|
||||||
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, false); err != nil {
|
if err := m.resolveVerificationToken(body.VerificationToken, body.SourceID, body.AmountMoney.Amount, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror Square's SCA enforcement on SAVED-CARD (ccof:) charges — the
|
// Mirror Square's SCA enforcement on SAVED-CARD charges — the SCA-primary
|
||||||
// SCA-primary saved-card posture (opt-in toggle, off by default; placement
|
// saved-card posture (opt-in toggle, off by default; placement AFTER the
|
||||||
// AFTER the customer_id gate above so a ccof charge without a customer is
|
// customer_id gate above so a ccof charge without a customer is still
|
||||||
// still MISSING_REQUIRED_PARAMETER, never verification-required). A ccof:
|
// MISSING_REQUIRED_PARAMETER, never verification-required). A charge is a
|
||||||
// charge without a 3DS/SCA verification token is rejected with the same
|
// saved-card charge in one of two wire shapes:
|
||||||
// structured 400 CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and
|
// (a) CURRENT contract: source_id is a fresh SCA tokenize-result (a
|
||||||
// a pending buyer-verification challenge is recorded for the card. A
|
// one-time cnon:-style token from card.tokenize(verificationDetails,
|
||||||
// subsequent charge WITH a verification token resolves the challenge (see
|
// cardId)) sent with the saved card's customer_id. The token only
|
||||||
// resolveVerificationToken). Grandfathered cards (GrandfatherSavedCard)
|
// exists after the buyer completed issuer verification, so it IS the
|
||||||
// bypass the gate.
|
// SCA proof — the charge is accepted without any verification_token.
|
||||||
if m.SimulateSavedCardVerificationRequired && strings.HasPrefix(req.SourceID, "ccof:") {
|
// The mock distinguishes a genuine tokenize-result (cnon:sca-...) from
|
||||||
if req.VerificationToken == "" {
|
// a RAW card.tokenize() nonce: real Square rejects an unverified nonce
|
||||||
if m.grandfatheredCards[req.SourceID] {
|
// as a card-on-file source (money-F2), so the mock does too.
|
||||||
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate bypassed: source %s is grandfathered", tokenPrefix(req.SourceID))
|
// (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 {
|
} else {
|
||||||
if m.ChallengeResult == "auto" {
|
if m.ChallengeResult == "auto" {
|
||||||
// "auto" config: the banking-app challenge resolves itself
|
// "auto" config: the banking-app challenge resolves itself
|
||||||
// as approved, so the next tokenized retry succeeds without
|
// as approved, so the next tokenized retry succeeds without
|
||||||
// an explicit ApprovePendingVerification call.
|
// an explicit ApprovePendingVerification call.
|
||||||
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: "approved"}
|
m.pendingChallenges[body.SourceID] = &pendingChallenge{outcome: "approved"}
|
||||||
} else {
|
} 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{
|
return nil, &squareAPIError{
|
||||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||||
Category: "PAYMENT_METHOD_ERROR",
|
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"),
|
err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
default:
|
||||||
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, true); err != nil {
|
if err := m.resolveVerificationToken(body.VerificationToken, body.SourceID, body.AmountMoney.Amount, true); err != nil {
|
||||||
return nil, err
|
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
|
// global) and reuses "cnon:test-card"-style tokens across tests, so
|
||||||
// default-on consumption would break those tests. Tests that need the
|
// default-on consumption would break those tests. Tests that need the
|
||||||
// single-use simulation flip the toggle on.
|
// single-use simulation flip the toggle on.
|
||||||
if strings.HasPrefix(req.SourceID, "cnon:") && m.SimulateSourceUsed {
|
if strings.HasPrefix(body.SourceID, "cnon:") && m.SimulateSourceUsed {
|
||||||
if m.usedSources[req.SourceID] {
|
if m.usedSources[body.SourceID] {
|
||||||
return nil, &squareAPIError{
|
return nil, &squareAPIError{
|
||||||
Code: "CARD_TOKEN_USED",
|
Code: "CARD_TOKEN_USED",
|
||||||
Category: "PAYMENT_METHOD_ERROR",
|
Category: "PAYMENT_METHOD_ERROR",
|
||||||
Detail: "The card nonce can no longer be used because it has been used to create a payment",
|
Detail: "The card nonce can no longer be used because it has been used to create a payment",
|
||||||
StatusCode: http.StatusBadRequest,
|
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()
|
now := clock.Now().UTC()
|
||||||
|
|
||||||
status := "COMPLETED"
|
status := "COMPLETED"
|
||||||
if req.Autocomplete != nil && !*req.Autocomplete {
|
if body.Autocomplete != nil && !*body.Autocomplete {
|
||||||
status = "APPROVED"
|
status = "APPROVED"
|
||||||
}
|
}
|
||||||
if m.ForcePaymentStatus != "" {
|
if m.ForcePaymentStatus != "" {
|
||||||
@@ -690,18 +801,18 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
status = m.ForcePaymentStatus
|
status = m.ForcePaymentStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
amount := req.Amount
|
amount := body.AmountMoney.Amount
|
||||||
tipAmount := int64(0)
|
tipAmount := int64(0)
|
||||||
if req.TipMoney != nil {
|
if body.TipMoney != nil {
|
||||||
tipAmount = *req.TipMoney
|
tipAmount = body.TipMoney.Amount
|
||||||
amount += tipAmount
|
amount += tipAmount
|
||||||
}
|
}
|
||||||
|
|
||||||
cardBrand, cardLast4 := detectCardInfo(req.SourceID)
|
cardBrand, cardLast4 := detectCardInfo(body.SourceID)
|
||||||
|
|
||||||
// Entry method: ON_FILE for card-on-file tokens, KEYED for nonces
|
// Entry method: ON_FILE for card-on-file tokens, KEYED for nonces
|
||||||
entryMethod := "KEYED"
|
entryMethod := "KEYED"
|
||||||
if len(req.SourceID) >= 5 && req.SourceID[:5] == "ccof:" {
|
if len(body.SourceID) >= 5 && body.SourceID[:5] == "ccof:" {
|
||||||
entryMethod = "ON_FILE"
|
entryMethod = "ON_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,10 +825,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
// see TestProcessingFeeSign_Parity_MockAndRealClientAgree.
|
// see TestProcessingFeeSign_Parity_MockAndRealClientAgree.
|
||||||
fees := amount*14/1000 + 25 // online rate: 1.4% + 25p
|
fees := amount*14/1000 + 25 // online rate: 1.4% + 25p
|
||||||
|
|
||||||
locationID := req.LocationID
|
locationID := body.LocationID
|
||||||
if locationID == "" {
|
|
||||||
locationID = "L_MOCK"
|
|
||||||
}
|
|
||||||
|
|
||||||
expMonth := 12
|
expMonth := 12
|
||||||
expYear := 2030
|
expYear := 2030
|
||||||
@@ -739,12 +847,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", now.UnixNano()),
|
ReceiptNumber: fmt.Sprintf("RCPT_mock_%d", now.UnixNano()),
|
||||||
SquarePayID: paymentID,
|
SquarePayID: paymentID,
|
||||||
Fees: fees,
|
Fees: fees,
|
||||||
BuyerEmail: req.BuyerEmail,
|
BuyerEmail: body.BuyerEmailAddress,
|
||||||
CustomerID: req.CustomerID,
|
CustomerID: body.CustomerID,
|
||||||
LocationID: locationID,
|
LocationID: locationID,
|
||||||
CreatedAt: now.Format(time.RFC3339),
|
CreatedAt: now.Format(time.RFC3339),
|
||||||
UpdatedAt: now.Format(time.RFC3339),
|
UpdatedAt: now.Format(time.RFC3339),
|
||||||
ReferenceID: req.ReferenceID,
|
ReferenceID: body.ReferenceID,
|
||||||
}
|
}
|
||||||
m.payments[paymentID] = result
|
m.payments[paymentID] = result
|
||||||
// SquarePayID is the same ID as the payment (paymentFromSquare sets
|
// 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
|
// client — reconcile/sweep code that resolves a stored square_payment_id
|
||||||
// via GetPayment behaves the same in mock and prod.
|
// via GetPayment behaves the same in mock and prod.
|
||||||
m.payments[result.SquarePayID] = result
|
m.payments[result.SquarePayID] = result
|
||||||
if req.IdempotencyKey != "" {
|
if body.IdempotencyKey != "" {
|
||||||
m.paymentByKey[req.IdempotencyKey] = result
|
m.paymentByKey[body.IdempotencyKey] = result
|
||||||
m.paymentSource[req.IdempotencyKey] = req.SourceID
|
m.paymentSource[body.IdempotencyKey] = body.SourceID
|
||||||
}
|
}
|
||||||
log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees)
|
log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees)
|
||||||
if m.FailAfterCommit {
|
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
|
// 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
|
// payment under the key. A same-key + same-source retry dedups to the
|
||||||
// committed payment instead of charging twice, exactly like prod.
|
// 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 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
|
return result, nil
|
||||||
|
|||||||
@@ -2649,3 +2649,241 @@ func TestDevClient_VerifyMockDenyTokenSuffix(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "COMPLETED", result.Status, "the _ok encoding of the same binding must succeed (control)")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,6 +213,20 @@ type sqMoney struct {
|
|||||||
|
|
||||||
// --- Payment types ---
|
// --- 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 {
|
type sqCreatePaymentRequest struct {
|
||||||
SourceID string `json:"source_id"`
|
SourceID string `json:"source_id"`
|
||||||
IdempotencyKey string `json:"idempotency_key"`
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
@@ -223,7 +237,7 @@ type sqCreatePaymentRequest struct {
|
|||||||
CustomerID string `json:"customer_id,omitempty"`
|
CustomerID string `json:"customer_id,omitempty"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
TipMoney *sqMoney `json:"tip_money,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"`
|
BuyerEmailAddress string `json:"buyer_email_address,omitempty"`
|
||||||
// CustomerDetails carries customer_initiated so Square classifies the
|
// CustomerDetails carries customer_initiated so Square classifies the
|
||||||
// charge as cardholder-initiated (SCA applies) rather than defaulting to a
|
// charge as cardholder-initiated (SCA applies) rather than defaulting to a
|
||||||
@@ -453,10 +467,12 @@ func validCardID(id string) bool {
|
|||||||
return validSquareID(id)
|
return validSquareID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
|
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces (new
|
||||||
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
|
// cards AND saved-card SCA tokenize-results — card.tokenize(verificationDetails,
|
||||||
// This is the single source of truth for token validation, shared by the real
|
// cardId) returns a fresh cnon:-style one-time token) and ccof:xxx card IDs.
|
||||||
// HTTP client and the dev mock so PCI-DSS parity holds in both builds.
|
// 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 {
|
func isTokenLike(s string) bool {
|
||||||
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
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
|
// charge) and replayPaymentByKeyHTTPWithClient (the identical-body replay), so
|
||||||
// a charge replayed from the stored snapshot produces BYTE-IDENTICAL JSON to
|
// a charge replayed from the stored snapshot produces BYTE-IDENTICAL JSON to
|
||||||
// the original — Square's idempotency dedup compares the full request body.
|
// 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 {
|
func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymentRequest {
|
||||||
body := sqCreatePaymentRequest{
|
body := sqCreatePaymentRequest{
|
||||||
SourceID: req.SourceID,
|
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,
|
// IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending forever,
|
||||||
// so the snapshot is never reconstructed from partial row data.
|
// so the snapshot is never reconstructed from partial row data.
|
||||||
//
|
//
|
||||||
// TODO (UNVERIFIED ASSUMPTION): this codebase assumes Square retains
|
// IDEMPOTENCY-KEY RETENTION ASSUMPTION (~24h). The identical-body replay's
|
||||||
// idempotency keys for ~24 hours. The stale-pending sweeps guard on that
|
// safety relies on Square retaining idempotency keys long enough that a
|
||||||
// window with three named constants (defined in handlers/payments): the keyed
|
// stale-pending row's replay still dedups to the original charge. This
|
||||||
// reconcile cutoff stalePendingKeyedAge (22h, sweep.go), the pass-2
|
// codebase assumes ~24 hours, and the stale-pending sweeps (defined in
|
||||||
// blind-fail cutoff stalePendingPaymentAge (24h, sweep.go), and the refund
|
// handlers/payments, which own those constants) guard on that window with
|
||||||
// age guard stalePendingRefundAge (23h, refunds.go). Square's public docs no
|
// safety margins: the keyed reconcile cutoff stalePendingKeyedAge (22h,
|
||||||
// longer state the exact retention window — confirm the current value with
|
// sweep.go), the pass-2 blind-fail cutoff stalePendingPaymentAge (24h,
|
||||||
// Square support and update the sweep age guards and this comment when
|
// sweep.go), and the refund age guard stalePendingRefundAge (23h, refunds.go).
|
||||||
// confirmed.
|
// 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) {
|
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
|
||||||
var req CreatePaymentReq
|
var req CreatePaymentReq
|
||||||
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
||||||
|
|||||||
@@ -2029,3 +2029,64 @@ func TestInvalidRequestError_IsCategoryNotCode(t *testing.T) {
|
|||||||
t.Error("INVALID_REQUEST_ERROR is a category, never a definitive payment code")
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ var ErrReplayKeyNotRetained = errors.New("square: no payment under idempotency k
|
|||||||
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
|
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
|
||||||
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
|
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
|
||||||
type CreatePaymentReq struct {
|
type CreatePaymentReq struct {
|
||||||
Amount int64 // in pence
|
Amount int64 // in pence
|
||||||
Currency string // "GBP"
|
Currency string // "GBP"
|
||||||
SourceID string // card token ("cnon:xxx" nonce) or card-on-file ID
|
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
|
IdempotencyKey string
|
||||||
ReferenceID string // booking ID or other reference
|
ReferenceID string // booking ID or other reference
|
||||||
Note string
|
Note string
|
||||||
// Autocomplete and TipMoney are valid Square wire fields that are
|
// Autocomplete and TipMoney are valid Square wire fields that are
|
||||||
// intentionally NOT populated by any current handler: online payments are
|
// intentionally NOT populated by any current handler: online payments are
|
||||||
// completed immediately (Autocomplete nil = Square default true, no
|
// 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).
|
// payments rather than split inside Square's CreatePayment (TipMoney nil).
|
||||||
// They are wired through the client into the request body for completeness
|
// They are wired through the client into the request body for completeness
|
||||||
// and future use — do not remove them.
|
// and future use — do not remove them.
|
||||||
Autocomplete *bool // nil (default) = true — complete immediately; false = approve only
|
Autocomplete *bool // nil (default) = true — complete immediately; false = approve only
|
||||||
TipMoney *int64 // optional tip amount in pence
|
TipMoney *int64 // optional tip amount in pence
|
||||||
CustomerID string // Square customer ID for card-on-file payments
|
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)
|
LocationID string // Square location ID (optional; defaults to main location)
|
||||||
VerificationToken string // 3DS / SCA verification token from buyer verification
|
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
|
BuyerEmail string // buyer email for receipt
|
||||||
// CustomerDetails classifies the charge as cardholder-initiated (true) or
|
// CustomerDetails classifies the charge as cardholder-initiated (true) or
|
||||||
// merchant-initiated (false) for Square's SCA / liability-shift logic,
|
// merchant-initiated (false) for Square's SCA / liability-shift logic,
|
||||||
// wired through as customer_details.customer_initiated on POST /v2/payments.
|
// 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).
|
// RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds).
|
||||||
type RefundPaymentReq struct {
|
type RefundPaymentReq struct {
|
||||||
PaymentID string
|
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
|
IdempotencyKey string
|
||||||
Reason string
|
Reason string
|
||||||
LocationID string // Square location ID (optional; defaults to main location)
|
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
|
// 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
|
// 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.
|
// payments, so a plain int could not distinguish 0 from an absent value.
|
||||||
ExpMonth *int
|
ExpMonth *int
|
||||||
ExpYear *int
|
ExpYear *int
|
||||||
EntryMethod string // "KEYED", "ON_FILE", "EMV", "SWIPED", "CONTACTLESS"
|
EntryMethod string // "KEYED", "ON_FILE", "EMV", "SWIPED", "CONTACTLESS"
|
||||||
CVVStatus string // "CVV_ACCEPTED", "CVV_REJECTED", "CVV_NOT_CHECKED"
|
CVVStatus string // "CVV_ACCEPTED", "CVV_REJECTED", "CVV_NOT_CHECKED"
|
||||||
AVSStatus string // "AVS_ACCEPTED", "AVS_REJECTED", "AVS_NOT_CHECKED"
|
AVSStatus string // "AVS_ACCEPTED", "AVS_REJECTED", "AVS_NOT_CHECKED"
|
||||||
TipAmount int64 // tip portion in pence
|
TipAmount int64 // tip portion in pence
|
||||||
ReceiptURL string // link to Square hosted receipt
|
ReceiptURL string // link to Square hosted receipt
|
||||||
ReceiptNumber string // Square receipt number
|
ReceiptNumber string // Square receipt number
|
||||||
SquarePayID string // Square's payment ID (same as ID in production)
|
SquarePayID string // Square's payment ID (same as ID in production)
|
||||||
Fees int64 // total processing fee in pence
|
Fees int64 // total processing fee in pence
|
||||||
BuyerEmail string // buyer email (if provided)
|
BuyerEmail string // buyer email (if provided)
|
||||||
CustomerID string // Square customer ID (if linked)
|
CustomerID string // Square customer ID (if linked)
|
||||||
LocationID string // Square location ID where payment was processed
|
LocationID string // Square location ID where payment was processed
|
||||||
CreatedAt string // ISO 8601 timestamp
|
CreatedAt string // ISO 8601 timestamp
|
||||||
UpdatedAt string // ISO 8601 timestamp
|
UpdatedAt string // ISO 8601 timestamp
|
||||||
OrderID string // Square order ID (if linked to an order)
|
OrderID string // Square order ID (if linked to an order)
|
||||||
ReferenceID string // client-specified reference (booking ID etc.)
|
ReferenceID string // client-specified reference (booking ID etc.)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckoutResult maps to Square's TerminalCheckout object.
|
// CheckoutResult maps to Square's TerminalCheckout object.
|
||||||
|
|||||||
Reference in New Issue
Block a user