From 0f094b3f2d251c535aecd71101bbf5841cb18277 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:03:11 +0000 Subject: [PATCH 1/2] feat: Support Link recurring spend requests on vault Link cards Stainless-Generated-From: bfdf48ecf725725b8b77f1c775dc51cf09cebe6b --- api.md | 2 + packages/pagination/pagination.go | 35 ++- pagination_empty_page_test.go | 126 +++++++++ vaultitem.go | 443 +++++++++++++++++++++++++++--- vaultitem_test.go | 4 + 5 files changed, 564 insertions(+), 46 deletions(-) create mode 100644 pagination_empty_page_test.go diff --git a/api.md b/api.md index fa85d489..895665a1 100644 --- a/api.md +++ b/api.md @@ -597,6 +597,8 @@ Response Types: - kernel.KernelCredentialVaultItemState - kernel.KernelWalletState - kernel.KernelWalletVaultItemSpec +- kernel.LinkShippingAddress +- kernel.LinkUserInfo - kernel.ManagedAuthCredentialVaultField - kernel.ManagedAuthCredentialVaultItemSpec - kernel.ManagedAuthCredentialVaultItemState diff --git a/packages/pagination/pagination.go b/packages/pagination/pagination.go index 9d81e76e..e2cfa88f 100644 --- a/packages/pagination/pagination.go +++ b/packages/pagination/pagination.go @@ -139,26 +139,27 @@ func (r *OffsetPagination[T]) UnmarshalJSON(data []byte) error { // there is no next page, this function will return a 'nil' for the page value, but // will not return an error func (r *OffsetPagination[T]) GetNextPage() (res *OffsetPagination[T], err error) { - if len(r.Items) == 0 { - return nil, nil - } cfg := r.cfg.Clone(r.cfg.Context) nextStr := r.res.Header.Get("X-Next-Offset") + if nextStr == "" && len(r.Items) == 0 { + return nil, nil + } next, err := strconv.ParseInt(nextStr, 10, 64) if err != nil { return nil, err } - length := int64(len(r.Items)) - if length > 0 && next != 0 { - err = cfg.Apply(option.WithQuery("offset", strconv.FormatInt(next, 10))) - if err != nil { - return nil, err - } - } else { + // An empty page can still have more pages behind it. Cursors can move in + // either direction, so only 0 or a repeated cursor ends pagination. + requested, _ := strconv.ParseInt(cfg.Request.URL.Query().Get("offset"), 10, 64) + if next == 0 || next == requested { return nil, nil } + err = cfg.Apply(option.WithQuery("offset", strconv.FormatInt(next, 10))) + if err != nil { + return nil, err + } var raw *http.Response cfg.ResponseInto = &raw cfg.ResponseBodyInto = &res @@ -195,13 +196,23 @@ func NewOffsetPaginationAutoPager[T any](page *OffsetPagination[T], err error) * } func (r *OffsetPaginationAutoPager[T]) Next() bool { - if r.page == nil || len(r.page.Items) == 0 { + if r.page == nil { return false } if r.idx >= len(r.page.Items) { r.idx = 0 r.page, r.err = r.page.GetNextPage() - if r.err != nil || r.page == nil || len(r.page.Items) == 0 { + if r.err != nil || r.page == nil { + return false + } + } + // if the API returned empty data then keep iterating + // until we either get more data or there are no more pages + // to fetch + for len(r.page.Items) == 0 { + r.idx = 0 + r.page, r.err = r.page.GetNextPage() + if r.err != nil || r.page == nil { return false } } diff --git a/pagination_empty_page_test.go b/pagination_empty_page_test.go new file mode 100644 index 00000000..06c7ec3a --- /dev/null +++ b/pagination_empty_page_test.go @@ -0,0 +1,126 @@ +package kernel_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" +) + +type emptyPageResponse struct { + body string + headers map[string]string +} + +// countEventsAcrossPages auto-pages the telemetry events endpoint against a +// server that serves pages keyed by the request's offset query value. +func countEventsAcrossPages(t *testing.T, pages map[string]emptyPageResponse) (events, requests int, err error) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + page, ok := pages[r.URL.Query().Get("offset")] + if !ok { + t.Errorf("unexpected request for offset %q", r.URL.Query().Get("offset")) + http.Error(w, "unexpected offset", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + for k, v := range page.headers { + w.Header().Set(k, v) + } + fmt.Fprint(w, page.body) + })) + defer srv.Close() + + client := kernel.NewClient(option.WithBaseURL(srv.URL), option.WithAPIKey("test-api-key"), option.WithMaxRetries(0)) + iter := client.Browsers.Telemetry.EventsAutoPaging(context.Background(), "browser", kernel.BrowserTelemetryEventsParams{}) + for iter.Next() { + events++ + } + return events, requests, iter.Err() +} + +const emptyPageEvent = `{"seq":1,"event":{"ts":1,"type":"page_load","category":"page","source":{"kind":"kernel_api"},"data":{}}}` + +func TestOffsetPaginationContinuesPastEmptyPages(t *testing.T) { + events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{ + "": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "20"}}, + "20": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "40"}}, + "40": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "60"}}, + "60": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "false", "X-Next-Offset": "0"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if events != 2 || requests != 4 { + t.Fatalf("got %d events in %d requests, want 2 events in 4 requests", events, requests) + } +} + +func TestOffsetPaginationEmptyFirstPageContinues(t *testing.T) { + events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{ + "": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "20"}}, + "20": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "false", "X-Next-Offset": "0"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if events != 1 || requests != 2 { + t.Fatalf("got %d events in %d requests, want 1 event in 2 requests", events, requests) + } +} + +func TestOffsetPaginationStopsOnEmptyLastPage(t *testing.T) { + events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{ + "": {"[]", map[string]string{"X-Has-More": "false", "X-Next-Offset": "0"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if events != 0 || requests != 1 { + t.Fatalf("got %d events in %d requests, want 0 events in 1 request", events, requests) + } +} + +func TestOffsetPaginationStopsOnEmptyPageWithoutCursor(t *testing.T) { + events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{ + "": {"[]", nil}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if events != 0 || requests != 1 { + t.Fatalf("got %d events in %d requests, want 0 events in 1 request", events, requests) + } +} + +func TestOffsetPaginationStopsWhenCursorDoesNotAdvance(t *testing.T) { + events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{ + "": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "20"}}, + "20": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "20"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if events != 1 || requests != 2 { + t.Fatalf("got %d events in %d requests, want 1 event in 2 requests", events, requests) + } +} + +func TestOffsetPaginationFollowsDescendingCursorAcrossEmptyPage(t *testing.T) { + events, requests, err := countEventsAcrossPages(t, map[string]emptyPageResponse{ + "": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "300"}}, + "300": {"[]", map[string]string{"X-Has-More": "true", "X-Next-Offset": "200"}}, + "200": {"[" + emptyPageEvent + "]", map[string]string{"X-Has-More": "false", "X-Next-Offset": "0"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if events != 2 || requests != 3 { + t.Fatalf("got %d events in %d requests, want 2 events in 3 requests", events, requests) + } +} diff --git a/vaultitem.go b/vaultitem.go index 69bcfe80..eca262c4 100644 --- a/vaultitem.go +++ b/vaultitem.go @@ -451,6 +451,8 @@ type CardVaultItemSpecUnion struct { // This field is from variant [CardVaultItemSpecLink]. Metadata map[string]string `json:"metadata"` // This field is from variant [CardVaultItemSpecLink]. + Recurring CardVaultItemSpecLinkRecurring `json:"recurring"` + // This field is from variant [CardVaultItemSpecLink]. Totals []CardVaultItemSpecLinkTotal `json:"totals"` // This field is from variant [CardVaultItemSpecAgentcard]. Merchant string `json:"merchant"` @@ -475,6 +477,7 @@ type CardVaultItemSpecUnion struct { Wallet respjson.Field LineItems respjson.Field Metadata respjson.Field + Recurring respjson.Field Totals respjson.Field Merchant respjson.Field CardID respjson.Field @@ -555,7 +558,8 @@ func (r CardVaultItemSpecUnion) ToParam() CardVaultItemSpecUnionParam { // virtual card otherwise. Callers never receive or choose how the credential is // delivered. type CardVaultItemSpecLink struct { - // Integer amount in minor currency units, at most 50000. + // Integer amount in minor currency units, at most 50000. With recurring, the + // spending limit per period, not a lifetime total. Amount int64 `json:"amount" api:"required"` Context string `json:"context" api:"required"` Currency string `json:"currency" api:"required"` @@ -570,7 +574,17 @@ type CardVaultItemSpecLink struct { Wallet string `json:"wallet" api:"required"` LineItems []CardVaultItemSpecLinkLineItem `json:"line_items"` Metadata map[string]string `json:"metadata"` - Totals []CardVaultItemSpecLinkTotal `json:"totals"` + // Recurring terms for a subscription or other repeated purchase. The amount + // becomes the spending limit for each period of interval_count intervals, for + // example every 2 weeks. The merchant schedules charges. The card stays ready + // after each charge until it expires or is deleted; do not create a new card each + // period. Deleting the card, its wallet or its vault first cancels the credential + // with Link, so later charges decline; if Link cannot be reached, deletion returns + // 409 provider_revocation_pending and should be retried. Deletion does not cancel + // the subscription with the merchant. Recurring terms cannot be changed once the + // card is authorized. + Recurring CardVaultItemSpecLinkRecurring `json:"recurring"` + Totals []CardVaultItemSpecLinkTotal `json:"totals"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { Amount respjson.Field @@ -583,6 +597,7 @@ type CardVaultItemSpecLink struct { Wallet respjson.Field LineItems respjson.Field Metadata respjson.Field + Recurring respjson.Field Totals respjson.Field ExtraFields map[string]respjson.Field raw string @@ -649,6 +664,35 @@ func (r *CardVaultItemSpecLinkLineItemTotal) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +// Recurring terms for a subscription or other repeated purchase. The amount +// becomes the spending limit for each period of interval_count intervals, for +// example every 2 weeks. The merchant schedules charges. The card stays ready +// after each charge until it expires or is deleted; do not create a new card each +// period. Deleting the card, its wallet or its vault first cancels the credential +// with Link, so later charges decline; if Link cannot be reached, deletion returns +// 409 provider_revocation_pending and should be retried. Deletion does not cancel +// the subscription with the merchant. Recurring terms cannot be changed once the +// card is authorized. +type CardVaultItemSpecLinkRecurring struct { + // Any of "day", "week", "month", "year". + Interval string `json:"interval" api:"required"` + // Number of intervals per period. Link defaults it to 1. + IntervalCount int64 `json:"interval_count"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Interval respjson.Field + IntervalCount respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r CardVaultItemSpecLinkRecurring) RawJSON() string { return r.JSON.raw } +func (r *CardVaultItemSpecLinkRecurring) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type CardVaultItemSpecLinkTotal struct { // Total amount in minor currency units. Amount int64 `json:"amount" api:"required"` @@ -776,6 +820,14 @@ func (u CardVaultItemSpecUnionParam) GetMetadata() map[string]string { return nil } +// Returns a pointer to the underlying variant's property, if present. +func (u CardVaultItemSpecUnionParam) GetRecurring() *CardVaultItemSpecLinkRecurringParam { + if vt := u.OfLink; vt != nil { + return &vt.Recurring + } + return nil +} + // Returns a pointer to the underlying variant's property, if present. func (u CardVaultItemSpecUnionParam) GetTotals() []CardVaultItemSpecLinkTotalParam { if vt := u.OfLink; vt != nil { @@ -919,7 +971,8 @@ func init() { // The properties Amount, Context, Currency, MerchantName, MerchantURL, // PaymentMethodID, Provider, Wallet are required. type CardVaultItemSpecLinkParam struct { - // Integer amount in minor currency units, at most 50000. + // Integer amount in minor currency units, at most 50000. With recurring, the + // spending limit per period, not a lifetime total. Amount int64 `json:"amount" api:"required"` Context string `json:"context" api:"required"` Currency string `json:"currency" api:"required"` @@ -933,7 +986,17 @@ type CardVaultItemSpecLinkParam struct { Wallet string `json:"wallet" api:"required"` LineItems []CardVaultItemSpecLinkLineItemParam `json:"line_items,omitzero"` Metadata map[string]string `json:"metadata,omitzero"` - Totals []CardVaultItemSpecLinkTotalParam `json:"totals,omitzero"` + // Recurring terms for a subscription or other repeated purchase. The amount + // becomes the spending limit for each period of interval_count intervals, for + // example every 2 weeks. The merchant schedules charges. The card stays ready + // after each charge until it expires or is deleted; do not create a new card each + // period. Deleting the card, its wallet or its vault first cancels the credential + // with Link, so later charges decline; if Link cannot be reached, deletion returns + // 409 provider_revocation_pending and should be retried. Deletion does not cancel + // the subscription with the merchant. Recurring terms cannot be changed once the + // card is authorized. + Recurring CardVaultItemSpecLinkRecurringParam `json:"recurring,omitzero"` + Totals []CardVaultItemSpecLinkTotalParam `json:"totals,omitzero"` // This field can be elided, and will marshal its zero value as "link". Provider constant.Link `json:"provider" default:"link"` paramObj @@ -987,6 +1050,39 @@ func (r *CardVaultItemSpecLinkLineItemTotalParam) UnmarshalJSON(data []byte) err return apijson.UnmarshalRoot(data, r) } +// Recurring terms for a subscription or other repeated purchase. The amount +// becomes the spending limit for each period of interval_count intervals, for +// example every 2 weeks. The merchant schedules charges. The card stays ready +// after each charge until it expires or is deleted; do not create a new card each +// period. Deleting the card, its wallet or its vault first cancels the credential +// with Link, so later charges decline; if Link cannot be reached, deletion returns +// 409 provider_revocation_pending and should be retried. Deletion does not cancel +// the subscription with the merchant. Recurring terms cannot be changed once the +// card is authorized. +// +// The property Interval is required. +type CardVaultItemSpecLinkRecurringParam struct { + // Any of "day", "week", "month", "year". + Interval string `json:"interval,omitzero" api:"required"` + // Number of intervals per period. Link defaults it to 1. + IntervalCount param.Opt[int64] `json:"interval_count,omitzero"` + paramObj +} + +func (r CardVaultItemSpecLinkRecurringParam) MarshalJSON() (data []byte, err error) { + type shadow CardVaultItemSpecLinkRecurringParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *CardVaultItemSpecLinkRecurringParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +func init() { + apijson.RegisterFieldValidator[CardVaultItemSpecLinkRecurringParam]( + "interval", "day", "week", "month", "year", + ) +} + // The properties Amount, DisplayText, Type are required. type CardVaultItemSpecLinkTotalParam struct { // Total amount in minor currency units. @@ -1421,7 +1517,7 @@ func (r *CredentialAccountVaultItem) UnmarshalJSON(data []byte) error { // expand parameter. type CredentialAccountVaultItemAvailableExpansion struct { Description string `json:"description" api:"required"` - // Any of "payment_methods". + // Any of "payment_methods", "link_user_info", "link_shipping_addresses". Type string `json:"type" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -1860,7 +1956,7 @@ func (r *CredentialVaultItem) UnmarshalJSON(data []byte) error { // expand parameter. type CredentialVaultItemAvailableExpansion struct { Description string `json:"description" api:"required"` - // Any of "payment_methods". + // Any of "payment_methods", "link_user_info", "link_shipping_addresses". Type string `json:"type" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -2475,7 +2571,10 @@ type FillVaultItemOperationRequestParam struct { // prefix or glob matching. When an item allows omitting it, the browser must have // exactly one open page. PageURL param.Opt[string] `json:"page_url,omitzero" format:"uri"` - // Total operation deadline in milliseconds, not a per-field timeout. + // Ignored. Fill sets no operation deadline; typing pace adapts to the length of + // the values instead. Accepted for compatibility. + // + // Deprecated: deprecated TimeoutMs param.Opt[int64] `json:"timeout_ms,omitzero"` // Field bindings, when the item's advertised fill operation requires them. Each // selector is searched in the main frame and all descendant frames and must @@ -2559,8 +2658,11 @@ const ( type KernelCardState struct { // Any of "kernel". Provider KernelCardStateProvider `json:"provider" api:"required"` - // pending_authorization on a Visa purchase waits for the cardholder to approve it - // through the spend_approval action; an unused link expires after 30 minutes. + // pending_authorization waits for cardholder approval on Visa purchases or fresh + // security-code collection on alias-mode purchases through the spend_approval + // action; an unused link expires after 30 minutes. Alias-mode cards become ready + // after collection and expire at the temporary security-code alias deadline, by + // default five minutes later. An expired alias purchase requires a new card. // recovery_required means verification, approving the purchase or issuing the // one-time code has an unresolved outcome. A server-classified initial attestation // failure before intent dispatch may advertise restart_verification to prepare a @@ -2605,8 +2707,11 @@ const ( KernelCardStateProviderKernel KernelCardStateProvider = "kernel" ) -// pending_authorization on a Visa purchase waits for the cardholder to approve it -// through the spend_approval action; an unused link expires after 30 minutes. +// pending_authorization waits for cardholder approval on Visa purchases or fresh +// security-code collection on alias-mode purchases through the spend_approval +// action; an unused link expires after 30 minutes. Alias-mode cards become ready +// after collection and expire at the temporary security-code alias deadline, by +// default five minutes later. An expired alias purchase requires a new card. // recovery_required means verification, approving the purchase or issuing the // one-time code has an unresolved outcome. A server-classified initial attestation // failure before intent dispatch may advertise restart_verification to prepare a @@ -2662,12 +2767,13 @@ func (r *KernelCardStateMasks) UnmarshalJSON(data []byte) error { // spend_approval action: the cardholder approves it with a Visa passkey, and // Kernel registers a Visa intent for one transaction up to the amount before // issuing the code. For supported cards whose network cannot be enrolled for -// agentic tokens, authorize prepares checkout details without issuing a one-time -// code. The card can be filled into one checkout before its expires_at, one hour -// after authorization. Fill types those details on merchant_url's origin. The -// amount, merchant_url and merchant_country are recorded but do not restrict -// payment requests sent by the browser. Card updates are not supported; delete and -// create a new item instead. +// agentic tokens, authorize returns pending_authorization with a spend_approval +// action to collect a fresh security code in a hosted field. Once collected, the +// card is ready and can be filled before its expires_at, by default five minutes +// after collection. An expired card requires a new purchase. Fill types those +// details on merchant_url's origin. The amount, merchant_url and merchant_country +// are recorded but do not restrict payment requests sent by the browser. Card +// updates are not supported; delete and create a new item instead. type KernelCardVaultItemSpec struct { // Integer amount in minor currency units (at most 1000000), bound to the one-time // code. @@ -2736,12 +2842,13 @@ const ( // spend_approval action: the cardholder approves it with a Visa passkey, and // Kernel registers a Visa intent for one transaction up to the amount before // issuing the code. For supported cards whose network cannot be enrolled for -// agentic tokens, authorize prepares checkout details without issuing a one-time -// code. The card can be filled into one checkout before its expires_at, one hour -// after authorization. Fill types those details on merchant_url's origin. The -// amount, merchant_url and merchant_country are recorded but do not restrict -// payment requests sent by the browser. Card updates are not supported; delete and -// create a new item instead. +// agentic tokens, authorize returns pending_authorization with a spend_approval +// action to collect a fresh security code in a hosted field. Once collected, the +// card is ready and can be filled before its expires_at, by default five minutes +// after collection. An expired card requires a new purchase. Fill types those +// details on merchant_url's origin. The amount, merchant_url and merchant_country +// are recorded but do not restrict payment requests sent by the browser. Card +// updates are not supported; delete and create a new item instead. // // The properties Amount, Currency, MerchantName, MerchantURL, Provider, Wallet are // required. @@ -3006,6 +3113,248 @@ func (r *KernelWalletVaultItemSpecParam) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +// One entry of Link's `GET /shipping_addresses` response, passed through as Link +// reports it. Kernel never stores it. +type LinkShippingAddress struct { + // Link shipping address ID. + ID string `json:"id" api:"required"` + IsDefault bool `json:"is_default" api:"required"` + Nickname string `json:"nickname" api:"required"` + // Link's nullable postal address fields. Omitted from the shipping address when + // Link reports null. + Address LinkShippingAddressAddress `json:"address"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + ID respjson.Field + IsDefault respjson.Field + Nickname respjson.Field + Address respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkShippingAddress) RawJSON() string { return r.JSON.raw } +func (r *LinkShippingAddress) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Link's nullable postal address fields. Omitted from the shipping address when +// Link reports null. +type LinkShippingAddressAddress struct { + // State, province or region. + AdministrativeArea string `json:"administrative_area" api:"required"` + CountryCode string `json:"country_code" api:"required"` + DependentLocality string `json:"dependent_locality" api:"required"` + Line1 string `json:"line_1" api:"required"` + Line2 string `json:"line_2" api:"required"` + // City or town. + Locality string `json:"locality" api:"required"` + Name string `json:"name" api:"required"` + PostalCode string `json:"postal_code" api:"required"` + SortingCode string `json:"sorting_code" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + AdministrativeArea respjson.Field + CountryCode respjson.Field + DependentLocality respjson.Field + Line1 respjson.Field + Line2 respjson.Field + Locality respjson.Field + Name respjson.Field + PostalCode respjson.Field + SortingCode respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkShippingAddressAddress) RawJSON() string { return r.JSON.raw } +func (r *LinkShippingAddressAddress) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Link's `GET /userinfo` response for the connected wallet, passed through as Link +// reports it when the `link_user_info` expansion is requested. Kernel never stores +// it. Null fields are null at Link; omitted fields were not reported. Amounts are +// in the minor unit of `default_spend_request_currency`. +type LinkUserInfo struct { + // Stable Link user ID. + ID string `json:"id" api:"required"` + Email string `json:"email" api:"required"` + FirstName string `json:"first_name" api:"required"` + LastName string `json:"last_name" api:"required"` + Name string `json:"name" api:"required"` + Phone string `json:"phone" api:"required"` + // The Link user's postal address. Omitted when Link reports none. + Address LinkUserInfoAddress `json:"address"` + // Link's agent wallet spend limits, per transaction and over rolling periods. + AgentWalletSpendLimits LinkUserInfoAgentWalletSpendLimits `json:"agent_wallet_spend_limits"` + // Verification Link requires from the user before the agent wallet can spend. + AgentWalletStepUp LinkUserInfoAgentWalletStepUp `json:"agent_wallet_step_up"` + // Lowercase ISO currency Link applies to a spend request created without one. + DefaultSpendRequestCurrency string `json:"default_spend_request_currency"` + // Whether the user can fund purchases from a Link balance. + EligibleForBalance bool `json:"eligible_for_balance"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + ID respjson.Field + Email respjson.Field + FirstName respjson.Field + LastName respjson.Field + Name respjson.Field + Phone respjson.Field + Address respjson.Field + AgentWalletSpendLimits respjson.Field + AgentWalletStepUp respjson.Field + DefaultSpendRequestCurrency respjson.Field + EligibleForBalance respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkUserInfo) RawJSON() string { return r.JSON.raw } +func (r *LinkUserInfo) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// The Link user's postal address. Omitted when Link reports none. +type LinkUserInfoAddress struct { + City string `json:"city" api:"required"` + Country string `json:"country" api:"required"` + Line1 string `json:"line1" api:"required"` + Line2 string `json:"line2" api:"required"` + PostalCode string `json:"postal_code" api:"required"` + State string `json:"state" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + City respjson.Field + Country respjson.Field + Line1 respjson.Field + Line2 respjson.Field + PostalCode respjson.Field + State respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkUserInfoAddress) RawJSON() string { return r.JSON.raw } +func (r *LinkUserInfoAddress) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Link's agent wallet spend limits, per transaction and over rolling periods. +type LinkUserInfoAgentWalletSpendLimits struct { + Daily LinkUserInfoAgentWalletSpendLimitsDaily `json:"daily" api:"required"` + PerTransaction LinkUserInfoAgentWalletSpendLimitsPerTransaction `json:"per_transaction" api:"required"` + ThirtyDay LinkUserInfoAgentWalletSpendLimitsThirtyDay `json:"thirty_day" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Daily respjson.Field + PerTransaction respjson.Field + ThirtyDay respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkUserInfoAgentWalletSpendLimits) RawJSON() string { return r.JSON.raw } +func (r *LinkUserInfoAgentWalletSpendLimits) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type LinkUserInfoAgentWalletSpendLimitsDaily struct { + // Null means unlimited. + Limit int64 `json:"limit" api:"required"` + // Amount still available in the rolling period; null means unlimited. + Remaining int64 `json:"remaining" api:"required"` + // Amount already spent in the rolling period. + Used int64 `json:"used" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Limit respjson.Field + Remaining respjson.Field + Used respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkUserInfoAgentWalletSpendLimitsDaily) RawJSON() string { return r.JSON.raw } +func (r *LinkUserInfoAgentWalletSpendLimitsDaily) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type LinkUserInfoAgentWalletSpendLimitsPerTransaction struct { + // Null means unlimited. + Limit int64 `json:"limit" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Limit respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkUserInfoAgentWalletSpendLimitsPerTransaction) RawJSON() string { return r.JSON.raw } +func (r *LinkUserInfoAgentWalletSpendLimitsPerTransaction) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type LinkUserInfoAgentWalletSpendLimitsThirtyDay struct { + // Null means unlimited. + Limit int64 `json:"limit" api:"required"` + // Amount still available in the rolling period; null means unlimited. + Remaining int64 `json:"remaining" api:"required"` + // Amount already spent in the rolling period. + Used int64 `json:"used" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Limit respjson.Field + Remaining respjson.Field + Used respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkUserInfoAgentWalletSpendLimitsThirtyDay) RawJSON() string { return r.JSON.raw } +func (r *LinkUserInfoAgentWalletSpendLimitsThirtyDay) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Verification Link requires from the user before the agent wallet can spend. +type LinkUserInfoAgentWalletStepUp struct { + // URL where the user completes verification; null when no action is available. + ActionURL string `json:"action_url" api:"required"` + // Link status such as not_required, ssn_verification, identity_verification, + // contact_support or complete. + Status string `json:"status" api:"required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + ActionURL respjson.Field + Status respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r LinkUserInfoAgentWalletStepUp) RawJSON() string { return r.JSON.raw } +func (r *LinkUserInfoAgentWalletStepUp) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type ManagedAuthCredentialVaultField struct { // Text, email, and password have form inputs; totp and passkey do not and are // omitted from both Kernel-hosted and customer React forms. Password, totp, and @@ -4376,6 +4725,8 @@ type VaultItemUnionSpec struct { // This field is from variant [CardVaultItemSpecUnion]. Metadata map[string]string `json:"metadata"` // This field is from variant [CardVaultItemSpecUnion]. + Recurring CardVaultItemSpecLinkRecurring `json:"recurring"` + // This field is from variant [CardVaultItemSpecUnion]. Totals []CardVaultItemSpecLinkTotal `json:"totals"` // This field is from variant [CardVaultItemSpecUnion]. Merchant string `json:"merchant"` @@ -4414,6 +4765,7 @@ type VaultItemUnionSpec struct { Wallet respjson.Field LineItems respjson.Field Metadata respjson.Field + Recurring respjson.Field Totals respjson.Field Merchant respjson.Field CardID respjson.Field @@ -4665,7 +5017,7 @@ func (r *VaultItemWallet) UnmarshalJSON(data []byte) error { // expand parameter. type VaultItemWalletAvailableExpansion struct { Description string `json:"description" api:"required"` - // Any of "payment_methods". + // Any of "payment_methods", "link_user_info", "link_shipping_addresses". Type string `json:"type" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -4708,12 +5060,22 @@ func (r *VaultItemWalletAvailableOperation) UnmarshalJSON(data []byte) error { // Live, non-persisted data requested through the item GET expand parameter. type VaultItemWalletExpanded struct { + // Link's saved shipping addresses, present when the `link_shipping_addresses` + // expansion is requested. + LinkShippingAddresses []LinkShippingAddress `json:"link_shipping_addresses"` + // Link's `GET /userinfo` response for the connected wallet, passed through as Link + // reports it when the `link_user_info` expansion is requested. Kernel never stores + // it. Null fields are null at Link; omitted fields were not reported. Amounts are + // in the minor unit of `default_spend_request_currency`. + LinkUserInfo LinkUserInfo `json:"link_user_info"` PaymentMethods []VaultPaymentMethod `json:"payment_methods"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { - PaymentMethods respjson.Field - ExtraFields map[string]respjson.Field - raw string + LinkShippingAddresses respjson.Field + LinkUserInfo respjson.Field + PaymentMethods respjson.Field + ExtraFields map[string]respjson.Field + raw string } `json:"-"` } @@ -4772,7 +5134,7 @@ func (r *VaultItemCard) UnmarshalJSON(data []byte) error { // expand parameter. type VaultItemCardAvailableExpansion struct { Description string `json:"description" api:"required"` - // Any of "payment_methods". + // Any of "payment_methods", "link_user_info", "link_shipping_addresses". Type string `json:"type" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -5314,6 +5676,8 @@ type VaultItemOperationResponseUnionSpec struct { // This field is from variant [CardVaultItemSpecUnion]. Metadata map[string]string `json:"metadata"` // This field is from variant [CardVaultItemSpecUnion]. + Recurring CardVaultItemSpecLinkRecurring `json:"recurring"` + // This field is from variant [CardVaultItemSpecUnion]. Totals []CardVaultItemSpecLinkTotal `json:"totals"` // This field is from variant [CardVaultItemSpecUnion]. Merchant string `json:"merchant"` @@ -5352,6 +5716,7 @@ type VaultItemOperationResponseUnionSpec struct { Wallet respjson.Field LineItems respjson.Field Metadata respjson.Field + Recurring respjson.Field Totals respjson.Field Merchant respjson.Field CardID respjson.Field @@ -5606,7 +5971,7 @@ func (r *VaultItemOperationResponseWalletVaultItem) UnmarshalJSON(data []byte) e // expand parameter. type VaultItemOperationResponseWalletVaultItemAvailableExpansion struct { Description string `json:"description" api:"required"` - // Any of "payment_methods". + // Any of "payment_methods", "link_user_info", "link_shipping_addresses". Type string `json:"type" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -5653,12 +6018,22 @@ func (r *VaultItemOperationResponseWalletVaultItemAvailableOperation) UnmarshalJ // Live, non-persisted data requested through the item GET expand parameter. type VaultItemOperationResponseWalletVaultItemExpanded struct { + // Link's saved shipping addresses, present when the `link_shipping_addresses` + // expansion is requested. + LinkShippingAddresses []LinkShippingAddress `json:"link_shipping_addresses"` + // Link's `GET /userinfo` response for the connected wallet, passed through as Link + // reports it when the `link_user_info` expansion is requested. Kernel never stores + // it. Null fields are null at Link; omitted fields were not reported. Amounts are + // in the minor unit of `default_spend_request_currency`. + LinkUserInfo LinkUserInfo `json:"link_user_info"` PaymentMethods []VaultPaymentMethod `json:"payment_methods"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { - PaymentMethods respjson.Field - ExtraFields map[string]respjson.Field - raw string + LinkShippingAddresses respjson.Field + LinkUserInfo respjson.Field + PaymentMethods respjson.Field + ExtraFields map[string]respjson.Field + raw string } `json:"-"` } @@ -5718,7 +6093,7 @@ func (r *VaultItemOperationResponseCardVaultItem) UnmarshalJSON(data []byte) err // expand parameter. type VaultItemOperationResponseCardVaultItemAvailableExpansion struct { Description string `json:"description" api:"required"` - // Any of "payment_methods". + // Any of "payment_methods", "link_user_info", "link_shipping_addresses". Type string `json:"type" api:"required"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { @@ -6409,7 +6784,7 @@ type VaultItemGetParams struct { Wait param.Opt[int64] `query:"wait,omitzero" json:"-"` // Live fields advertised by `available_expansions` to include in `expanded`. // - // Any of "payment_methods". + // Any of "payment_methods", "link_user_info", "link_shipping_addresses". Expand []string `query:"expand,omitzero" json:"-"` paramObj } diff --git a/vaultitem_test.go b/vaultitem_test.go index 270543ca..fcbc0fa1 100644 --- a/vaultitem_test.go +++ b/vaultitem_test.go @@ -90,6 +90,10 @@ func TestVaultItemUpdateWithOptionalParams(t *testing.T) { Metadata: map[string]string{ "foo": "string", }, + Recurring: kernel.CardVaultItemSpecLinkRecurringParam{ + Interval: "day", + IntervalCount: kernel.Int(1), + }, Totals: []kernel.CardVaultItemSpecLinkTotalParam{{ Amount: 0, DisplayText: "display_text", From 9e61b83a44d36cf3653077f8b36ca56ae34dd174 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:08:25 +0000 Subject: [PATCH 2/2] release: 0.130.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ README.md | 2 +- internal/version.go | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 65b6e7b4..37e99723 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.129.0" + ".": "0.130.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index aa086c90..b5388b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.130.0](https://github.com/kernel/kernel-go-sdk/compare/v0.129.0...v0.130.0) (2026-10-24) + + +### Features + +* Support Link recurring spend requests on vault Link cards ([0f094b3](https://github.com/kernel/kernel-go-sdk/commit/0f094b3f2d251c535aecd71101bbf5841cb18277)) + ## [0.129.0](https://github.com/kernel/kernel-go-sdk/compare/v0.128.0...v0.129.0) (2026-10-21) diff --git a/README.md b/README.md index 54e7a130..9dfc55ed 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Or to pin the version: ```sh -go get -u 'github.com/kernel/kernel-go-sdk@v0.129.0' +go get -u 'github.com/kernel/kernel-go-sdk@v0.130.0' ``` diff --git a/internal/version.go b/internal/version.go index dae76fee..7f40f5ec 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "0.129.0" // x-release-please-version +const PackageVersion = "0.130.0" // x-release-please-version