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:
2026-08-22 00:34:50 +01:00
parent 2cdbad0cea
commit d25ba16aa7
5 changed files with 571 additions and 124 deletions
@@ -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"])
}
}