Fix Square card linkage: reference_id instead of customer_id (no Square customer provisioning)
The app does not provision Square customers, so sending the local user ID as customer_id in Create Card was rejected with CUSTOMER_NOT_FOUND, and filtering List Cards by it returned nothing. reference_id is Square's free-form client reference — max 128 chars, no uniqueness constraint — and is echoed in both Create and List responses. - Create Card payload: reference_id = local user ID (customer_id absent) - List Cards: native ?reference_id=<userID> filter (no limit/customer_id, no client-side filter, no cursor handling needed) - Mock parity: CreateCardOnFile stores ReferenceID; GetCardsOnFile unchanged - Regression guards: TestCreateCardOnFileHTTP_IdempotencyKey asserts reference_id=user_1 and customer_id ABSENT; new TestGetCardsOnFileHTTP_ReferenceIDFilter asserts the query shape
This commit is contained in:
@@ -390,7 +390,7 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str
|
||||
ExpYear: 2030,
|
||||
Fingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
||||
CardholderName: "John Doe",
|
||||
CustomerID: userID,
|
||||
ReferenceID: userID,
|
||||
Enabled: true,
|
||||
IsDefault: len(m.cards[userID]) == 0,
|
||||
Version: 1,
|
||||
|
||||
@@ -540,7 +540,11 @@ func TestDevClient_CreateCardOnFile_WithNewFields(t *testing.T) {
|
||||
|
||||
assert.True(t, card.Enabled)
|
||||
assert.NotEmpty(t, card.CardholderName)
|
||||
assert.Equal(t, userID, card.CustomerID)
|
||||
// Local linkage goes in reference_id, NOT customer_id — the app has no
|
||||
// Square customer provisioning, and a local ID in customer_id would be
|
||||
// rejected by the real Cards API.
|
||||
assert.Equal(t, userID, card.ReferenceID)
|
||||
assert.Empty(t, card.CustomerID)
|
||||
assert.Greater(t, card.Version, int64(0))
|
||||
assert.NotEmpty(t, card.CreatedAt)
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ type sqCard struct {
|
||||
CardholderName string `json:"cardholder_name,omitempty"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
ReferenceID string `json:"reference_id,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
@@ -259,6 +260,7 @@ type sqCardPayload struct {
|
||||
ExpYear *int `json:"exp_year,omitempty"`
|
||||
CardholderName string `json:"cardholder_name,omitempty"`
|
||||
CustomerID string `json:"customer_id,omitempty"`
|
||||
ReferenceID string `json:"reference_id,omitempty"`
|
||||
}
|
||||
|
||||
type sqCreateCardResponse struct {
|
||||
@@ -445,7 +447,11 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken strin
|
||||
IdempotencyKey: fmt.Sprintf("create-card-%x", ikHash),
|
||||
SourceID: cardToken,
|
||||
Card: sqCardPayload{
|
||||
CustomerID: userID,
|
||||
// The app does not provision Square customers, so the local user
|
||||
// ID must NOT be sent as customer_id (Square would reject it).
|
||||
// reference_id is Square's free-form client reference, used to link
|
||||
// the card to the local user for client-side filtering.
|
||||
ReferenceID: userID,
|
||||
},
|
||||
}
|
||||
var resp sqCreateCardResponse
|
||||
@@ -456,9 +462,17 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken strin
|
||||
}
|
||||
|
||||
func getCardsOnFileHTTP(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||
hc := newHTTPClient()
|
||||
return getCardsOnFileHTTPWithClient(ctx, userID, newHTTPClient())
|
||||
}
|
||||
|
||||
func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpClient) ([]CardOnFile, error) {
|
||||
// Filter by reference_id natively: Square's List Cards API supports the
|
||||
// reference_id query param, and cards are created with reference_id = the
|
||||
// local user ID (the app has no Square customers, so customer_id cannot be
|
||||
// used). This avoids both the invalid customer_id filter and a client-side
|
||||
// filter across a cursor-paginated list.
|
||||
var resp sqListCardsResponse
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/cards?customer_id="+url.QueryEscape(userID), nil, &resp); err != nil {
|
||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/cards?reference_id="+url.QueryEscape(userID), nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards := make([]CardOnFile, 0, len(resp.Cards))
|
||||
@@ -552,10 +566,6 @@ func refundFromSquare(sq *sqRefund) *RefundResult {
|
||||
}
|
||||
|
||||
func cardFromSquare(sq *sqCard, userID string) *CardOnFile {
|
||||
customerID := sq.CustomerID
|
||||
if customerID == "" {
|
||||
customerID = userID
|
||||
}
|
||||
return &CardOnFile{
|
||||
ID: sq.ID,
|
||||
CardID: sq.ID,
|
||||
@@ -565,7 +575,8 @@ func cardFromSquare(sq *sqCard, userID string) *CardOnFile {
|
||||
ExpYear: sq.ExpYear,
|
||||
Fingerprint: sq.Fingerprint,
|
||||
CardholderName: sq.CardholderName,
|
||||
CustomerID: customerID,
|
||||
CustomerID: sq.CustomerID,
|
||||
ReferenceID: sq.ReferenceID,
|
||||
Enabled: sq.Enabled,
|
||||
Version: sq.Version,
|
||||
CreatedAt: sq.CreatedAt,
|
||||
|
||||
@@ -540,7 +540,7 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
t.Errorf("failed to decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","customer_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}}`))
|
||||
_, _ = w.Write([]byte(`{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","customer_id":"","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
@@ -562,8 +562,14 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected card object, got %v", captured["card"])
|
||||
}
|
||||
if card["customer_id"] != "user_1" {
|
||||
t.Errorf("expected card.customer_id user_1, got %v", card["customer_id"])
|
||||
// The local user ID goes in reference_id (free-form), NOT customer_id —
|
||||
// the app has no Square customer provisioning, and customer_id would be
|
||||
// rejected by the real Cards API (P1 regression guard).
|
||||
if card["reference_id"] != "user_1" {
|
||||
t.Errorf("expected card.reference_id user_1, got %v", card["reference_id"])
|
||||
}
|
||||
if _, present := card["customer_id"]; present {
|
||||
t.Errorf("expected card.customer_id to be ABSENT (local IDs must not go in customer_id), got %v", card["customer_id"])
|
||||
}
|
||||
if gotAuth != "Bearer secret" {
|
||||
t.Errorf("expected Authorization 'Bearer secret', got %q", gotAuth)
|
||||
@@ -572,3 +578,47 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
||||
t.Errorf("unexpected card result: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCardsOnFileHTTP_ReferenceIDFilter verifies the List Cards request uses
|
||||
// the native reference_id filter (the local user ID) — not the invalid
|
||||
// customer_id — and that cards are returned unfiltered server-side.
|
||||
func TestGetCardsOnFileHTTP_ReferenceIDFilter(t *testing.T) {
|
||||
var gotRawQuery string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v2/cards" {
|
||||
t.Errorf("expected /v2/cards, got %s", r.URL.Path)
|
||||
}
|
||||
gotRawQuery = r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"cards":[{"id":"ccof_1","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"},{"id":"ccof_2","card_brand":"MASTERCARD","last_4":"1111","exp_month":6,"exp_year":2029,"fingerprint":"fp2","reference_id":"user_other","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||
cards, err := getCardsOnFileHTTPWithClient(context.Background(), "user_1", hc)
|
||||
if err != nil {
|
||||
t.Fatalf("getCardsOnFileHTTP failed: %v", err)
|
||||
}
|
||||
|
||||
// Native reference_id filter — never the invalid customer_id, never a
|
||||
// limit that Square's List Cards API doesn't support.
|
||||
if !strings.Contains(gotRawQuery, "reference_id=user_1") {
|
||||
t.Errorf("expected reference_id=user_1 in query, got %q", gotRawQuery)
|
||||
}
|
||||
if strings.Contains(gotRawQuery, "customer_id") {
|
||||
t.Errorf("expected NO customer_id in query (local IDs are not Square customers), got %q", gotRawQuery)
|
||||
}
|
||||
if strings.Contains(gotRawQuery, "limit") {
|
||||
t.Errorf("expected NO limit param (List Cards has no limit; server filters by reference_id), got %q", gotRawQuery)
|
||||
}
|
||||
|
||||
if len(cards) != 2 {
|
||||
t.Fatalf("expected 2 cards returned, got %d", len(cards))
|
||||
}
|
||||
if cards[0].ReferenceID != "user_1" || cards[1].ReferenceID != "user_other" {
|
||||
t.Errorf("unexpected cards: %+v %+v", cards[0], cards[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,8 @@ type CardOnFile struct {
|
||||
ExpYear int
|
||||
Fingerprint string // Square card fingerprint
|
||||
CardholderName string // cardholder name (if provided)
|
||||
CustomerID string // Square customer ID this card belongs to
|
||||
CustomerID string // Square customer ID this card belongs to (unused: the app does not provision Square customers)
|
||||
ReferenceID string // Square free-form client reference — holds the local user ID for client-side filtering
|
||||
Enabled bool // whether the card is enabled (not disabled/expired)
|
||||
IsDefault bool // mock-only: first card saved for a user
|
||||
BillingAddress string // billing address (simplified)
|
||||
|
||||
Reference in New Issue
Block a user