Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-links-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@stripe/link-cli': patch
'@stripe/link-sdk': patch
---

Expose the user address and balance eligibility returned by `user-info retrieve`.
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,10 @@ Key input field notes:

### user-info retrieve

- `user-info retrieve` returns the existing identity fields and can include `agent_wallet_spend_limits` and `agent_wallet_verification_requirement` enrichment.
- `user-info retrieve` returns the existing identity fields and can include `address`, `eligible_for_balance`, `agent_wallet_spend_limits`, and `agent_wallet_verification_requirement` enrichment.
- `address` contains nullable `line1`, `line2`, `city`, `state`, `postal_code`, and `country` fields. It is itself `null` when the user has no Person record. `eligible_for_balance` indicates whether balance is available for Agent Wallet usage.
- Spend limits contain per-transaction, daily, and 30-day values. Finite values are cents because `/userinfo` does not return currency. A `null` limit or remaining amount explicitly means unlimited; `used` remains numeric.
- Either enrichment object can be omitted independently when enrichment is disabled or unavailable. Do not interpret omission as unlimited or as a default verification status.
- Enrichment fields can be omitted when enrichment is disabled or unavailable. Do not interpret omission as an empty address, balance ineligibility, unlimited spend, or a default verification status.
- Verification status is one of `not_required`, `ssn_verification`, `identity_verification`, `contact_support`, or `complete`. `action_url` is nullable and directs the user to the required action when present. This is informational and does not change spend-request or `requires_action` handling.

### mpp pay
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,22 @@ You receive a verification URL and a short phrase. Visit the URL, log in to your
link-cli user-info retrieve --format json
```

In addition to identity fields, the response can include spend limits and verification requirements:
In addition to identity fields, the response can include the user's address,
balance eligibility, spend limits, and verification requirements:

```json
{
"email": "jane@example.com",
"name": "Jane Doe",
"address": {
"line1": "510 Townsend St",
"line2": null,
"city": "San Francisco",
"state": "CA",
"postal_code": "94103",
"country": "US"
},
"eligible_for_balance": true,
"agent_wallet_spend_limits": {
"per_transaction": { "limit": 50000 },
"daily": { "limit": 500000, "used": 120000, "remaining": 380000 },
Expand All @@ -136,7 +146,12 @@ In addition to identity fields, the response can include spend limits and verifi
}
```

Finite spend-limit values are cents because this response does not include a currency. A null limit or remaining amount means unlimited. The verification requirement's action_url is null when no action is available.
The address and `eligible_for_balance` fields are omitted when their enrichment
is unavailable. `address` is null and `eligible_for_balance` is false when the
user has no Person record. Finite spend-limit values are cents because this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is a "Person record"

response does not include a currency. A null limit or remaining amount means
unlimited. The verification requirement's action_url is null when no action is
available.

### List payment methods

Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2576,6 +2576,15 @@ describe('production mode', () => {
setResponseForUrl('/userinfo', 200, {
email: 'user@example.com',
name: 'Test User',
address: {
line1: '510 Townsend St',
line2: null,
city: 'San Francisco',
state: 'CA',
postal_code: '94103',
country: 'US',
},
eligible_for_balance: false,
agent_wallet_spend_limits: {
per_transaction: { limit: null },
daily: { limit: 500000, used: 120000, remaining: 380000 },
Expand All @@ -2594,6 +2603,15 @@ describe('production mode', () => {
expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout) as Record<string, unknown>;
expect(output.email).toBe('user@example.com');
expect(output.address).toEqual({
line1: '510 Townsend St',
line2: null,
city: 'San Francisco',
state: 'CA',
postal_code: '94103',
country: 'US',
});
expect(output.eligible_for_balance).toBe(false);
expect(output.agent_wallet_spend_limits).toEqual({
per_transaction: { limit: null },
daily: { limit: 500000, used: 120000, remaining: 380000 },
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/commands/user-info/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { IUserInfoResource } from '@stripe/link-sdk';
import { Cli } from 'incur';
import React from 'react';
import type { CliAuthStorage } from '../../auth/storage';
import { renderInteractive } from '../../utils/render-interactive';
import { requireAuth } from '../../utils/require-auth';
Expand All @@ -17,7 +16,7 @@ export function createUserInfoCli(

cli.command('retrieve', {
description:
'Retrieve user info, including optional Agent Wallet spend limits and verification requirements',
'Retrieve user info, including optional address, balance eligibility, Agent Wallet spend limits, and verification requirements',
outputPolicy: 'agent-only' as const,
middleware: [requireAuth(authStorage, envAccessToken)],
async run(c) {
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/commands/user-info/retrieve.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ describe('user-info retrieve component', () => {
email: 'jane@example.com',
name: 'Jane Doe',
phone: '+15555550123',
address: {
line1: '510 Townsend St',
line2: null,
city: 'San Francisco',
state: 'CA',
postal_code: '94103',
country: 'US',
},
eligible_for_balance: false,
agent_wallet_spend_limits: {
per_transaction: { limit: 50000 },
daily: { limit: 500000, used: 120000, remaining: 380000 },
Expand All @@ -38,6 +47,14 @@ describe('user-info retrieve component', () => {
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Agent Wallet Spend Limits');
expect(frame).toContain('Address');
expect(frame).toContain('Line 1: 510 Townsend St');
expect(frame).toContain('Line 2: Not set');
expect(frame).toContain('City: San Francisco');
expect(frame).toContain('State: CA');
expect(frame).toContain('Postal code: 94103');
expect(frame).toContain('Country: US');
expect(frame).toContain('Eligible for balance: No');
expect(frame).toContain('Per-transaction: 50000 cents');
expect(frame).toContain(
'Daily: limit 500000 cents, used 120000 cents, remaining 380000 cents',
Expand Down Expand Up @@ -92,6 +109,8 @@ describe('user-info retrieve component', () => {
const frame = lastFrame();
expect(frame).toContain('jane@example.com');
expect(frame).not.toContain('Agent Wallet');
expect(frame).not.toContain('Address');
expect(frame).not.toContain('Eligible for balance');
});
});

Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/commands/user-info/retrieve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,43 @@ export const UserInfoRetrieve: React.FC<UserInfoRetrieveProps> = ({
<Text dimColor>Phone: </Text>
{userInfo?.phone ?? <Text dimColor>Not set</Text>}
</Text>
{userInfo?.address && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Address</Text>
<Box flexDirection="column" paddingLeft={2}>
<Text>
<Text dimColor>Line 1: </Text>
{userInfo.address.line1 ?? <Text dimColor>Not set</Text>}
</Text>
<Text>
<Text dimColor>Line 2: </Text>
{userInfo.address.line2 ?? <Text dimColor>Not set</Text>}
</Text>
<Text>
<Text dimColor>City: </Text>
{userInfo.address.city ?? <Text dimColor>Not set</Text>}
</Text>
<Text>
<Text dimColor>State: </Text>
{userInfo.address.state ?? <Text dimColor>Not set</Text>}
</Text>
<Text>
<Text dimColor>Postal code: </Text>
{userInfo.address.postal_code ?? <Text dimColor>Not set</Text>}
</Text>
<Text>
<Text dimColor>Country: </Text>
{userInfo.address.country ?? <Text dimColor>Not set</Text>}
</Text>
</Box>
</Box>
)}
{userInfo?.eligible_for_balance !== undefined && (
<Text>
<Text dimColor>Eligible for balance: </Text>
{userInfo.eligible_for_balance ? 'Yes' : 'No'}
</Text>
)}
{userInfo?.agent_wallet_spend_limits && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Agent Wallet Spend Limits</Text>
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/user-info/schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// user-info retrieve has no input options beyond what incur provides.
// Output is a UserInfo object with optional identity fields and optional
// agent_wallet_spend_limits and agent_wallet_verification_requirement objects.
// Output is a UserInfo object with optional identity fields, address,
// eligible_for_balance, agent_wallet_spend_limits, and
// agent_wallet_verification_requirement fields.
14 changes: 13 additions & 1 deletion packages/sdk-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,13 +330,25 @@ type AgentWalletVerificationRequirement struct {
ActionURL *string `json:"action_url"`
}

// UserInfo contains identity and optional Agent Wallet enrichment fields.
// UserInfoAddress contains the authenticated Link user's postal address.
type UserInfoAddress struct {
Line1 *string `json:"line1"`
Line2 *string `json:"line2"`
City *string `json:"city"`
State *string `json:"state"`
PostalCode *string `json:"postal_code"`
Country *string `json:"country"`
}

// UserInfo contains identity and optional enrichment fields.
type UserInfo struct {
Email *string `json:"email,omitempty"`
Name *string `json:"name,omitempty"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
Phone *string `json:"phone,omitempty"`
Address *UserInfoAddress `json:"address,omitempty"`
EligibleForBalance *bool `json:"eligible_for_balance,omitempty"`
AgentWalletSpendLimits *AgentWalletSpendLimits `json:"agent_wallet_spend_limits,omitempty"`
AgentWalletVerificationRequirement *AgentWalletVerificationRequirement `json:"agent_wallet_verification_requirement,omitempty"`
}
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-go/user_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func (r *UserInfoResource) Retrieve(ctx context.Context) (*UserInfo, error) {
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
Phone *string `json:"phone"`
Address *UserInfoAddress `json:"address,omitempty"`
EligibleForBalance *bool `json:"eligible_for_balance,omitempty"`
AgentWalletSpendLimits *AgentWalletSpendLimits `json:"agent_wallet_spend_limits,omitempty"`
AgentWalletStepUp *AgentWalletVerificationRequirement `json:"agent_wallet_step_up,omitempty"`
}
Expand All @@ -37,6 +39,8 @@ func (r *UserInfoResource) Retrieve(ctx context.Context) (*UserInfo, error) {
FirstName: wire.FirstName,
LastName: wire.LastName,
Phone: wire.Phone,
Address: wire.Address,
EligibleForBalance: wire.EligibleForBalance,
AgentWalletSpendLimits: wire.AgentWalletSpendLimits,
AgentWalletVerificationRequirement: wire.AgentWalletStepUp,
}, nil
Expand Down
81 changes: 81 additions & 0 deletions packages/sdk-go/user_info_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package link

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestUserInfoRetrieveDecodesAddressAndBalanceEligibility(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/userinfo" {
t.Errorf("got path %q, want /userinfo", request.URL.Path)
response.WriteHeader(http.StatusNotFound)
return
}
_, _ = response.Write([]byte(`{
"email":"user@example.com",
"address":{
"line1":"510 Townsend St",
"line2":null,
"city":"San Francisco",
"state":"CA",
"postal_code":"94103",
"country":"US"
},
"eligible_for_balance":false
}`))
}))
defer server.Close()

client, err := NewClient(Options{AccessToken: "token", APIBaseURL: server.URL})
assertNoError(t, err)
result, err := client.UserInfo.Retrieve(context.Background())
assertNoError(t, err)

if result.Address == nil || result.Address.Line1 == nil || *result.Address.Line1 != "510 Townsend St" {
t.Fatalf("unexpected address: %#v", result.Address)
}
if result.Address.Line2 != nil {
t.Fatalf("got line2 %q, want nil", *result.Address.Line2)
}
if result.EligibleForBalance == nil || *result.EligibleForBalance {
t.Fatalf("got eligible_for_balance %#v, want false", result.EligibleForBalance)
}
}

func TestUserInfoRetrievePreservesOmittedEnrichment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_, _ = response.Write([]byte(`{"email":"user@example.com"}`))
}))
defer server.Close()

client, err := NewClient(Options{AccessToken: "token", APIBaseURL: server.URL})
assertNoError(t, err)
result, err := client.UserInfo.Retrieve(context.Background())
assertNoError(t, err)

if result.Address != nil || result.EligibleForBalance != nil {
t.Fatalf("unexpected enrichment: address=%#v eligible_for_balance=%#v", result.Address, result.EligibleForBalance)
}
}

func TestUserInfoRetrieveDecodesNullAddressWithIneligibility(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_, _ = response.Write([]byte(`{"address":null,"eligible_for_balance":false}`))
}))
defer server.Close()

client, err := NewClient(Options{AccessToken: "token", APIBaseURL: server.URL})
assertNoError(t, err)
result, err := client.UserInfo.Retrieve(context.Background())
assertNoError(t, err)

if result.Address != nil {
t.Fatalf("got address %#v, want nil", result.Address)
}
if result.EligibleForBalance == nil || *result.EligibleForBalance {
t.Fatalf("got eligible_for_balance %#v, want false", result.EligibleForBalance)
}
}
Loading
Loading