diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 4455e1e..f236a6f 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -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, diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index cdae39e..0072d5b 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -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) } diff --git a/backend/internal/square/square_http_client.go b/backend/internal/square/square_http_client.go index 0cc8725..801e235 100644 --- a/backend/internal/square/square_http_client.go +++ b/backend/internal/square/square_http_client.go @@ -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, diff --git a/backend/internal/square/square_http_client_test.go b/backend/internal/square/square_http_client_test.go index 86c81a0..5402d45 100644 --- a/backend/internal/square/square_http_client_test.go +++ b/backend/internal/square/square_http_client_test.go @@ -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]) + } +} diff --git a/backend/internal/square/types.go b/backend/internal/square/types.go index bcd6030..c3d9963 100644 --- a/backend/internal/square/types.go +++ b/backend/internal/square/types.go @@ -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)