The Stripe client names a currency on every checkout session, invoice and invoice item, and switches Adaptive Pricing off
A session is asked for in one currency with adaptive_pricing disabled, and one that comes back in another currency, with Adaptive Pricing on, with a conversion, or with no currency is refused. Metadata carries the price book and currency of a purchase priced from a book. Invoices and invoice items take their currency from the caller, with no default. Sessions, subscriptions and subscription items report their currency.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+270
−51
internal/billingstripe/client.go+89 −11internal/billingstripe/client_test.go+107 −35internal/billingstripe/invoices.go+16 −3internal/billingstripe/invoices_test.go+58 −2
diff --git a/internal/billingstripe/client.go b/internal/billingstripe/client.go| index 22d8696e1..c0c9824ec 100644 |
| --- a/internal/billingstripe/client.go |
| +++ b/internal/billingstripe/client.go |
| @@ -299,6 +299,15 @@ type Session struct { |
| ExpiresAt int64 |
| BillingCountry string |
| LiveMode bool |
| + // Currency is the session's own currency, lower case, as Stripe |
| + // reports it ("" when the response carried none). |
| + Currency string |
| + // AdaptivePricing is true when the response says Adaptive Pricing is |
| + // enabled for the session, and Converted when it carries a currency |
| + // conversion. Prices here are fixed per currency: a session with |
| + // either is not one this package asked for. |
| + AdaptivePricing bool |
| + Converted bool |
| } |
| // UnmarshalJSON decodes a Session strictly enough that a null response body, |
| @@ -318,7 +327,12 @@ func (s *Session) UnmarshalJSON(b []byte) error { |
| Metadata map[string]string `json:"metadata"` |
| ExpiresAt int64 `json:"expires_at"` |
| LiveMode *bool `json:"livemode"` |
| - CustomerDetails struct { |
| + Currency string `json:"currency"` |
| + AdaptivePricing *struct { |
| + Enabled bool `json:"enabled"` |
| + } `json:"adaptive_pricing"` |
| + CurrencyConversion json.RawMessage `json:"currency_conversion"` |
| + CustomerDetails struct { |
| Address struct { |
| Country string `json:"country"` |
| } `json:"address"` |
| @@ -343,6 +357,9 @@ func (s *Session) UnmarshalJSON(b []byte) error { |
| s.ClientReferenceID = raw.ClientReferenceID |
| s.Metadata = raw.Metadata |
| s.ExpiresAt, s.LiveMode, s.BillingCountry = raw.ExpiresAt, *raw.LiveMode, raw.CustomerDetails.Address.Country |
| + s.Currency = raw.Currency |
| + s.AdaptivePricing = raw.AdaptivePricing != nil && raw.AdaptivePricing.Enabled |
| + s.Converted = len(raw.CurrencyConversion) > 0 && string(raw.CurrencyConversion) != "null" |
| if raw.Subscription.Object != nil { |
| var sub Subscription |
| if err := json.Unmarshal(raw.Subscription.Object, &sub); err != nil { |
| @@ -361,6 +378,9 @@ type SubscriptionItem struct { |
| ID string |
| PriceID string |
| Quantity int64 |
| + // PriceCurrency is the currency of the item's price, lower case, as |
| + // Stripe reports it ("" when the response carried none). |
| + PriceCurrency string |
| } |
| func (it *SubscriptionItem) UnmarshalJSON(b []byte) error { |
| @@ -368,7 +388,8 @@ func (it *SubscriptionItem) UnmarshalJSON(b []byte) error { |
| ID string `json:"id"` |
| Quantity int64 `json:"quantity"` |
| Price struct { |
| - ID string `json:"id"` |
| + ID string `json:"id"` |
| + Currency string `json:"currency"` |
| } `json:"price"` |
| } |
| if err := json.Unmarshal(b, &raw); err != nil { |
| @@ -377,6 +398,7 @@ func (it *SubscriptionItem) UnmarshalJSON(b []byte) error { |
| it.ID = raw.ID |
| it.Quantity = raw.Quantity |
| it.PriceID = raw.Price.ID |
| + it.PriceCurrency = raw.Price.Currency |
| return nil |
| } |
| @@ -393,6 +415,9 @@ type Subscription struct { |
| Items []SubscriptionItem |
| LiveMode bool |
| ItemsHasMore bool |
| + // Currency is the subscription's own currency, lower case, as Stripe |
| + // reports it ("" when the response carried none). |
| + Currency string |
| } |
| // UnmarshalJSON decodes a Subscription strictly enough that a null response |
| @@ -411,6 +436,7 @@ func (s *Subscription) UnmarshalJSON(b []byte) error { |
| CancelAtPeriodEnd bool `json:"cancel_at_period_end"` |
| Metadata map[string]string `json:"metadata"` |
| LiveMode *bool `json:"livemode"` |
| + Currency string `json:"currency"` |
| Items struct { |
| Data []SubscriptionItem `json:"data"` |
| HasMore bool `json:"has_more"` |
| @@ -436,6 +462,7 @@ func (s *Subscription) UnmarshalJSON(b []byte) error { |
| s.Metadata = raw.Metadata |
| s.Items = raw.Items.Data |
| s.LiveMode, s.ItemsHasMore = *raw.LiveMode, raw.Items.HasMore |
| + s.Currency = raw.Currency |
| return nil |
| } |
| @@ -455,12 +482,36 @@ type CheckoutMetadata struct { |
| Plan string |
| Band string |
| IntentID string |
| + // PriceBook and Currency name the price book and the currency the |
| + // purchase was priced in. Both are set, or neither: neither is a |
| + // purchase under the terms' own EUR figures, written before price |
| + // books existed. |
| + PriceBook string |
| + Currency string |
| } |
| func (m CheckoutMetadata) valid() bool { |
| + if (m.PriceBook == "") != (m.Currency == "") || (m.Currency != "" && !validCurrency(m.Currency)) { |
| + return false |
| + } |
| return m.AccountSqid != "" && m.TermsVersion != "" && m.Plan != "" && m.Band != "" && validID(m.IntentID) |
| } |
| +// validCurrency accepts a lower-case three-letter code. Which currencies |
| +// are sold is the caller's rule; this only keeps anything else out of a |
| +// request. |
| +func validCurrency(c string) bool { |
| + if len(c) != 3 { |
| + return false |
| + } |
| + for i := 0; i < 3; i++ { |
| + if c[i] < 'a' || c[i] > 'z' { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| func (m CheckoutMetadata) set(form url.Values, prefix string) { |
| form.Set(prefix+"[account_sqid]", m.AccountSqid) |
| form.Set(prefix+"[terms_version]", m.TermsVersion) |
| @@ -469,13 +520,22 @@ func (m CheckoutMetadata) set(form url.Values, prefix string) { |
| if m.IntentID != "" { |
| form.Set(prefix+"[checkout_intent]", m.IntentID) |
| } |
| + if m.PriceBook != "" { |
| + form.Set(prefix+"[price_book]", m.PriceBook) |
| + form.Set(prefix+"[currency]", m.Currency) |
| + } |
| } |
| // CreateCheckoutParams. Deliberately absent: CustomerEmail (when neither |
| // CustomerID nor an email is set, Checkout collects the buyer's identity |
| -// itself), payment_method_types and currency_options (both forbidden by the |
| -// spec — the first overrides the Dashboard's dynamic methods, the second |
| -// disables Adaptive Pricing). |
| +// itself), payment_method_types (it overrides the Dashboard's dynamic |
| +// methods) and currency_options (a price here is in one currency). |
| +// |
| +// Prices are fixed in each currency they are sold in |
| +// (2026-10-01-multi-currency-price-book-design.md), so a session is created |
| +// in exactly the currency the buyer chose and Adaptive Pricing, which |
| +// would present and charge a converted amount, is switched off on every |
| +// session. |
| type CreateCheckoutParams struct { |
| PriceID string |
| Quantity int64 |
| @@ -491,9 +551,22 @@ type CreateCheckoutParams struct { |
| // 50% code works. |
| HouseCoupon string |
| - // LocalizeCurrency turns on Adaptive Pricing presentment for this |
| - // session. Optional; the price level is unaffected either way. |
| - LocalizeCurrency bool |
| + // Currency is the currency the session must be in: the price's own. |
| + // Required. It is sent to Stripe, which refuses a session whose price |
| + // is in another currency, and the session that comes back is checked |
| + // against it. |
| + Currency string |
| +} |
| + |
| +// CodeCurrencyMismatch: Stripe answered with a session that is not in the |
| +// currency asked for, or that has Adaptive Pricing or a conversion on it. |
| +const CodeCurrencyMismatch = "currency_mismatch" |
| + |
| +// FixedCurrency reports whether a session is in exactly currency, with no |
| +// Adaptive Pricing and no conversion: what every session this package |
| +// creates must be, and what a caller checks again when it reads one back. |
| +func (s *Session) FixedCurrency(currency string) bool { |
| + return validCurrency(currency) && s.Currency == currency && !s.AdaptivePricing && !s.Converted |
| } |
| // CreateCheckout starts a subscription-mode Checkout Session at exactly the |
| @@ -516,9 +589,14 @@ func (c *Client) CreateCheckout(ctx context.Context, p CreateCheckoutParams, ide |
| if !validID(p.CustomerID) { |
| return nil, &Error{Code: CodeInvalidID} |
| } |
| + if !validCurrency(p.Currency) || (p.Metadata.Currency != "" && p.Metadata.Currency != p.Currency) { |
| + return nil, &Error{Code: CodeInvalidParams} |
| + } |
| form := url.Values{} |
| form.Set("mode", "subscription") |
| + form.Set("currency", p.Currency) |
| + form.Set("adaptive_pricing[enabled]", "false") |
| form.Set("line_items[0][price]", p.PriceID) |
| form.Set("line_items[0][quantity]", strconv.FormatInt(p.Quantity, 10)) |
| form.Set("success_url", p.SuccessURL) |
| @@ -535,9 +613,6 @@ func (c *Client) CreateCheckout(ctx context.Context, p CreateCheckoutParams, ide |
| } else { |
| form.Set("allow_promotion_codes", "true") |
| } |
| - if p.LocalizeCurrency { |
| - form.Set("adaptive_pricing[enabled]", "true") |
| - } |
| p.Metadata.set(form, "metadata") |
| p.Metadata.set(form, "subscription_data[metadata]") |
| @@ -548,6 +623,9 @@ func (c *Client) CreateCheckout(ctx context.Context, p CreateCheckoutParams, ide |
| if err := validateRedirectURL(sess.URL, "checkout.stripe.com"); err != nil { |
| return nil, err |
| } |
| + if !sess.FixedCurrency(p.Currency) { |
| + return nil, &Error{Code: CodeCurrencyMismatch} |
| + } |
| return &sess, nil |
| } |
diff --git a/internal/billingstripe/client_test.go b/internal/billingstripe/client_test.go| index 234934023..52d76c681 100644 |
| --- a/internal/billingstripe/client_test.go |
| +++ b/internal/billingstripe/client_test.go |
| @@ -56,12 +56,14 @@ func TestCreateCheckout_FormAndHeaders(t *testing.T) { |
| "url": "https://checkout.stripe.com/c/pay/cs_test_1", |
| "status": "open", |
| "livemode": false, |
| + "currency": "eur", |
| }) |
| })) |
| defer srv.Close() |
| c := testClient(t, srv.URL) |
| p := CreateCheckoutParams{ |
| + Currency: "eur", |
| PriceID: "price_123", |
| Quantity: 3, |
| SuccessURL: "https://example.com/success", |
| @@ -101,19 +103,21 @@ func TestCreateCheckout_FormAndHeaders(t *testing.T) { |
| } |
| want := map[string]string{ |
| - "mode": "subscription", |
| - "line_items[0][price]": "price_123", |
| - "line_items[0][quantity]": "3", |
| - "success_url": "https://example.com/success", |
| - "cancel_url": "https://example.com/cancel", |
| - "automatic_tax[enabled]": "true", |
| - "client_reference_id": "ref_1", |
| - "customer": "cus_abc", |
| - "allow_promotion_codes": "true", |
| - "metadata[account_sqid]": "acct_1", |
| - "metadata[terms_version]": "v1", |
| - "metadata[plan]": "personal", |
| - "metadata[band]": "A", |
| + "mode": "subscription", |
| + "line_items[0][price]": "price_123", |
| + "line_items[0][quantity]": "3", |
| + "success_url": "https://example.com/success", |
| + "cancel_url": "https://example.com/cancel", |
| + "automatic_tax[enabled]": "true", |
| + "client_reference_id": "ref_1", |
| + "customer": "cus_abc", |
| + "allow_promotion_codes": "true", |
| + "currency": "eur", |
| + "adaptive_pricing[enabled]": "false", |
| + "metadata[account_sqid]": "acct_1", |
| + "metadata[terms_version]": "v1", |
| + "metadata[plan]": "personal", |
| + "metadata[band]": "A", |
| "subscription_data[metadata][account_sqid]": "acct_1", |
| "subscription_data[metadata][terms_version]": "v1", |
| "subscription_data[metadata][plan]": "personal", |
| @@ -125,7 +129,7 @@ func TestCreateCheckout_FormAndHeaders(t *testing.T) { |
| t.Errorf("form[%q] = %v, want [%q]", k, got, v) |
| } |
| } |
| - forbidden := []string{"payment_method_types", "currency_options", "discounts[0][coupon]", "customer_email", "adaptive_pricing[enabled]"} |
| + forbidden := []string{"payment_method_types", "currency_options", "discounts[0][coupon]", "customer_email", "metadata[currency]", "metadata[price_book]"} |
| for _, k := range forbidden { |
| if _, ok := gotForm[k]; ok { |
| t.Errorf("form unexpectedly contains %q", k) |
| @@ -138,12 +142,12 @@ func TestCreateCheckout_HouseCouponSuppressesPromotionCodes(t *testing.T) { |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| _ = r.ParseForm() |
| gotForm = map[string][]string(r.PostForm) |
| - _ = json.NewEncoder(w).Encode(map[string]any{"id": "cs_1", "url": "https://checkout.stripe.com/c/pay/cs_1", "livemode": false}) |
| + _ = json.NewEncoder(w).Encode(map[string]any{"id": "cs_1", "url": "https://checkout.stripe.com/c/pay/cs_1", "livemode": false, "currency": "eur"}) |
| })) |
| defer srv.Close() |
| c := testClient(t, srv.URL) |
| - p := CreateCheckoutParams{CustomerID: "cus_abc", |
| + p := CreateCheckoutParams{Currency: "eur", CustomerID: "cus_abc", |
| PriceID: "price_123", |
| Quantity: 1, |
| SuccessURL: "https://example.com/s", |
| @@ -162,25 +166,93 @@ func TestCreateCheckout_HouseCouponSuppressesPromotionCodes(t *testing.T) { |
| } |
| } |
| -func TestCreateCheckout_LocalizeCurrencyFlag(t *testing.T) { |
| - var gotForm map[string][]string |
| +// Prices are fixed per currency. Every session is asked for in one |
| +// currency with Adaptive Pricing off, a purchase priced from a book says |
| +// which book and currency in the metadata of the session and of the |
| +// subscription, and a session that comes back in another currency, with |
| +// Adaptive Pricing on, with a conversion, or with no currency at all, is |
| +// refused. |
| +func TestCreateCheckout_FixedCurrency(t *testing.T) { |
| + type response map[string]any |
| + ok := func(currency string) response { |
| + return response{"id": "cs_1", "url": "https://checkout.stripe.com/c/pay/cs_1", "livemode": false, "currency": currency} |
| + } |
| + with := func(r response, k string, v any) response { r[k] = v; return r } |
| + for name, tc := range map[string]struct { |
| + currency string |
| + answer response |
| + wantCode string |
| + }{ |
| + "usd": {"usd", ok("usd"), ""}, |
| + "gbp": {"gbp", ok("gbp"), ""}, |
| + "adaptive pricing reported off": {"gbp", with(ok("gbp"), "adaptive_pricing", map[string]any{"enabled": false}), ""}, |
| + "a null conversion": {"gbp", with(ok("gbp"), "currency_conversion", nil), ""}, |
| + "the session is in another": {"gbp", ok("eur"), CodeCurrencyMismatch}, |
| + "the session names no currency": {"gbp", response{"id": "cs_1", "url": "https://checkout.stripe.com/c/pay/cs_1", "livemode": false}, CodeCurrencyMismatch}, |
| + "adaptive pricing came back on": {"gbp", with(ok("gbp"), "adaptive_pricing", map[string]any{"enabled": true}), CodeCurrencyMismatch}, |
| + "the session carries a conversion": {"gbp", with(ok("gbp"), "currency_conversion", map[string]any{"source_currency": "gbp", "fx_rate": "1.2"}), CodeCurrencyMismatch}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + var gotForm map[string][]string |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + _ = r.ParseForm() |
| + gotForm = map[string][]string(r.PostForm) |
| + _ = json.NewEncoder(w).Encode(tc.answer) |
| + })) |
| + defer srv.Close() |
| + meta := validMetadata() |
| + meta.PriceBook, meta.Currency = "2026-10-01-draft1", tc.currency |
| + p := CreateCheckoutParams{CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: meta, Currency: tc.currency} |
| + sess, err := testClient(t, srv.URL).CreateCheckout(context.Background(), p, "idem-3") |
| + for k, want := range map[string]string{ |
| + "currency": tc.currency, "adaptive_pricing[enabled]": "false", |
| + "metadata[currency]": tc.currency, "metadata[price_book]": "2026-10-01-draft1", |
| + "subscription_data[metadata][currency]": tc.currency, "subscription_data[metadata][price_book]": "2026-10-01-draft1", |
| + } { |
| + if got := gotForm[k]; len(got) != 1 || got[0] != want { |
| + t.Errorf("form[%q] = %v, want %q", k, got, want) |
| + } |
| + } |
| + if tc.wantCode == "" { |
| + if err != nil || !sess.FixedCurrency(tc.currency) { |
| + t.Fatalf("CreateCheckout: %+v %v", sess, err) |
| + } |
| + return |
| + } |
| + if e, isErr := err.(*Error); !isErr || e.Code != tc.wantCode || sess != nil { |
| + t.Fatalf("CreateCheckout: %+v %v, want %s and no session", sess, err, tc.wantCode) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A currency that is not a lower-case three-letter code, or metadata that |
| +// disagrees with it or names a book and no currency, never reaches Stripe. |
| +func TestCreateCheckout_RefusesAMalformedCurrency(t *testing.T) { |
| + calls := 0 |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - _ = r.ParseForm() |
| - gotForm = map[string][]string(r.PostForm) |
| - _ = json.NewEncoder(w).Encode(map[string]any{"id": "cs_1", "url": "https://checkout.stripe.com/c/pay/cs_1", "livemode": false}) |
| + calls++ |
| + _ = json.NewEncoder(w).Encode(map[string]any{"id": "cs_1", "url": "https://checkout.stripe.com/c/pay/cs_1", "livemode": false, "currency": "eur"}) |
| })) |
| defer srv.Close() |
| c := testClient(t, srv.URL) |
| - p := CreateCheckoutParams{CustomerID: "cus_abc", |
| - PriceID: "price_123", Quantity: 1, |
| - SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", |
| - Metadata: validMetadata(), LocalizeCurrency: true, |
| - } |
| - if _, err := c.CreateCheckout(context.Background(), p, "idem-3"); err != nil { |
| - t.Fatalf("CreateCheckout: %v", err) |
| + base := CreateCheckoutParams{CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: validMetadata(), Currency: "eur"} |
| + for name, edit := range map[string]func(p *CreateCheckoutParams){ |
| + "no currency": func(p *CreateCheckoutParams) { p.Currency = "" }, |
| + "upper case": func(p *CreateCheckoutParams) { p.Currency = "EUR" }, |
| + "not a code": func(p *CreateCheckoutParams) { p.Currency = "euro" }, |
| + "metadata in another currency": func(p *CreateCheckoutParams) { p.Metadata.PriceBook, p.Metadata.Currency = "b1", "usd" }, |
| + "metadata with a book and no currency": func(p *CreateCheckoutParams) { p.Metadata.PriceBook = "b1" }, |
| + "metadata with a currency and no book": func(p *CreateCheckoutParams) { p.Metadata.Currency = "eur" }, |
| + } { |
| + p := base |
| + edit(&p) |
| + if _, err := c.CreateCheckout(context.Background(), p, "idem"); err == nil { |
| + t.Errorf("%s: accepted", name) |
| + } |
| } |
| - if got := gotForm["adaptive_pricing[enabled]"]; len(got) != 1 || got[0] != "true" { |
| - t.Errorf("adaptive_pricing[enabled] = %v", got) |
| + if calls != 0 { |
| + t.Fatalf("%d requests were sent", calls) |
| } |
| } |
| @@ -192,7 +264,7 @@ func TestCreateCheckout_RequiresIdempotencyKey(t *testing.T) { |
| })) |
| defer srv.Close() |
| c := testClient(t, srv.URL) |
| - p := CreateCheckoutParams{CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: validMetadata()} |
| + p := CreateCheckoutParams{Currency: "eur", CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: validMetadata()} |
| _, err := c.CreateCheckout(context.Background(), p, "") |
| if err == nil { |
| t.Fatal("expected an error for a missing idempotency key") |
| @@ -210,7 +282,7 @@ func TestCreateCheckout_MissingMetadataRejected(t *testing.T) { |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ })) |
| defer srv.Close() |
| c := testClient(t, srv.URL) |
| - p := CreateCheckoutParams{CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c"} |
| + p := CreateCheckoutParams{Currency: "eur", CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c"} |
| _, err := c.CreateCheckout(context.Background(), p, "idem") |
| if err == nil { |
| t.Fatal("expected an error for incomplete checkout metadata") |
| @@ -227,7 +299,7 @@ func TestCreateCheckout_MissingIntentIDRejectedWithoutHTTPCall(t *testing.T) { |
| c := testClient(t, srv.URL) |
| meta := validMetadata() |
| meta.IntentID = "" |
| - p := CreateCheckoutParams{CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: meta} |
| + p := CreateCheckoutParams{Currency: "eur", CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: meta} |
| _, err := c.CreateCheckout(context.Background(), p, "idem") |
| if err == nil { |
| t.Fatal("expected an error for a missing checkout intent id") |
| @@ -247,7 +319,7 @@ func TestCreateCheckout_InvalidIntentIDRejectedWithoutHTTPCall(t *testing.T) { |
| c := testClient(t, srv.URL) |
| meta := validMetadata() |
| meta.IntentID = "../../etc/passwd" |
| - p := CreateCheckoutParams{CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: meta} |
| + p := CreateCheckoutParams{Currency: "eur", CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: meta} |
| _, err := c.CreateCheckout(context.Background(), p, "idem") |
| if err == nil { |
| t.Fatal("expected an error for a malformed checkout intent id") |
| @@ -266,7 +338,7 @@ func TestCreateCheckout_RejectsUnexpectedRedirectHost(t *testing.T) { |
| })) |
| defer srv.Close() |
| c := testClient(t, srv.URL) |
| - p := CreateCheckoutParams{CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: validMetadata()} |
| + p := CreateCheckoutParams{Currency: "eur", CustomerID: "cus_abc", PriceID: "price_123", Quantity: 1, SuccessURL: "https://example.com/s", CancelURL: "https://example.com/c", Metadata: validMetadata()} |
| _, err := c.CreateCheckout(context.Background(), p, "idem") |
| e, ok := err.(*Error) |
| if !ok || e.Code != CodeUnexpectedRedirectURL { |
diff --git a/internal/billingstripe/invoices.go b/internal/billingstripe/invoices.go| index 5fbf0bd6e..b00b61069 100644 |
| --- a/internal/billingstripe/invoices.go |
| +++ b/internal/billingstripe/invoices.go |
| @@ -97,6 +97,12 @@ type DraftInvoiceParams struct { |
| Account string |
| Month string |
| CalculationDigest string |
| + // Currency is the currency of the invoice: the calculation's own. |
| + // Required, lower case. It is omitted from the encoding when empty so |
| + // that a caller hashing its parameters can leave it out for a request |
| + // made before currencies were a parameter; it is never empty on a |
| + // request that is sent. |
| + Currency string `json:",omitempty"` |
| } |
| func (c *Client) CreateDraftInvoice(ctx context.Context, p DraftInvoiceParams, key string) (*Invoice, error) { |
| @@ -106,8 +112,11 @@ func (c *Client) CreateDraftInvoice(ctx context.Context, p DraftInvoiceParams, k |
| if p.Subscription != "" && p.Subscription != p.SubscriptionRef { |
| return nil, &Error{Code: CodeInvalidParams} |
| } |
| + if !validCurrency(p.Currency) { |
| + return nil, &Error{Code: CodeInvalidParams} |
| + } |
| form := url.Values{ |
| - "customer": {p.Customer}, "currency": {"eur"}, |
| + "customer": {p.Customer}, "currency": {p.Currency}, |
| "auto_advance": {"false"}, "collection_method": {"charge_automatically"}, |
| "automatic_tax[enabled]": {"true"}, "pending_invoice_items_behavior": {"exclude"}, "discounts": {""}, |
| "metadata[account_sqid]": {p.Account}, "metadata[usage_month]": {p.Month}, "metadata[calculation_digest]": {p.CalculationDigest}, |
| @@ -257,15 +266,19 @@ func (x *InvoiceItem) UnmarshalJSON(data []byte) error { |
| type InvoiceItemParams struct { |
| Customer, Invoice, Meter, TaxCode, Description, CalculationDigest string |
| AmountCents, PeriodStart, PeriodEnd int64 |
| + // Currency is the currency of the item: the invoice's and the |
| + // calculation's own. Required, lower case; omitted from the encoding |
| + // when empty for the same reason as DraftInvoiceParams.Currency. |
| + Currency string `json:",omitempty"` |
| } |
| func (c *Client) CreateInvoiceItem(ctx context.Context, p InvoiceItemParams, key string) (*InvoiceItem, error) { |
| if !validStripeObject(p.Customer, "cus_") || !validStripeObject(p.Invoice, "in_") || !validStripeObject(p.TaxCode, "txcd_") || |
| - !validID(p.Meter) || !validDigest(p.CalculationDigest) || p.AmountCents <= 0 || p.PeriodStart <= 0 || p.PeriodEnd <= p.PeriodStart || len(p.Description) > 500 { |
| + !validID(p.Meter) || !validDigest(p.CalculationDigest) || p.AmountCents <= 0 || p.PeriodStart <= 0 || p.PeriodEnd <= p.PeriodStart || len(p.Description) > 500 || !validCurrency(p.Currency) { |
| return nil, &Error{Code: CodeInvalidParams} |
| } |
| form := url.Values{ |
| - "customer": {p.Customer}, "invoice": {p.Invoice}, "amount": {strconv.FormatInt(p.AmountCents, 10)}, "currency": {"eur"}, |
| + "customer": {p.Customer}, "invoice": {p.Invoice}, "amount": {strconv.FormatInt(p.AmountCents, 10)}, "currency": {p.Currency}, |
| "discountable": {"false"}, "tax_behavior": {"exclusive"}, "tax_code": {p.TaxCode}, |
| "description": {p.Description}, "period[start]": {strconv.FormatInt(p.PeriodStart, 10)}, "period[end]": {strconv.FormatInt(p.PeriodEnd, 10)}, |
| "metadata[meter]": {p.Meter}, "metadata[calculation_digest]": {p.CalculationDigest}, |
diff --git a/internal/billingstripe/invoices_test.go b/internal/billingstripe/invoices_test.go| index d3d3140d7..637597890 100644 |
| --- a/internal/billingstripe/invoices_test.go |
| +++ b/internal/billingstripe/invoices_test.go |
| @@ -13,11 +13,11 @@ import ( |
| const invoiceFixture = `{"id":"in_usage","customer":"cus_paid","subscription":"sub_paid","livemode":false,"status":"draft","currency":"eur","auto_advance":false,"automatic_tax":{"enabled":true,"status":null},"lines":{"data":[],"has_more":false}}` |
| func draftInvoiceFixture() DraftInvoiceParams { |
| - return DraftInvoiceParams{Customer: "cus_paid", Subscription: "sub_paid", SubscriptionRef: "sub_paid", Account: "bab", Month: "2026-09", CalculationDigest: strings.Repeat("a", 64)} |
| + return DraftInvoiceParams{Currency: "eur", Customer: "cus_paid", Subscription: "sub_paid", SubscriptionRef: "sub_paid", Account: "bab", Month: "2026-09", CalculationDigest: strings.Repeat("a", 64)} |
| } |
| func invoiceItemFixture() InvoiceItemParams { |
| - return InvoiceItemParams{Customer: "cus_paid", Invoice: "in_usage", Meter: "awake_seconds", TaxCode: "txcd_approved", AmountCents: 800, PeriodStart: 1788220800, PeriodEnd: 1790812800, CalculationDigest: strings.Repeat("a", 64)} |
| + return InvoiceItemParams{Currency: "eur", Customer: "cus_paid", Invoice: "in_usage", Meter: "awake_seconds", TaxCode: "txcd_approved", AmountCents: 800, PeriodStart: 1788220800, PeriodEnd: 1790812800, CalculationDigest: strings.Repeat("a", 64)} |
| } |
| func TestDraftInvoiceCannotCollectOrInheritUnrelatedItems(t *testing.T) { |
| @@ -304,3 +304,59 @@ func TestInvoicePaymentRequiresExplicitCallAndStableKey(t *testing.T) { |
| t.Fatalf("explicit payment: %+v %v calls=%d", got, err, calls) |
| } |
| } |
| + |
| +// An invoice and its items are created in the currency the caller names, |
| +// which is the calculation's. There is no default: a request with no |
| +// currency, or with something that is not a lower-case code, is never sent. |
| +// The parameters encode without the currency when it is empty, so a caller |
| +// hashing a request made before currencies were a parameter gets the hash |
| +// it always got. |
| +func TestInvoiceRequestsCarryTheirCurrency(t *testing.T) { |
| + var forms []url.Values |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + _ = r.ParseForm() |
| + forms = append(forms, r.PostForm) |
| + if r.URL.Path == "/v1/invoices" { |
| + w.Write([]byte(strings.Replace(invoiceFixture, `"currency":"eur"`, `"currency":"gbp"`, 1))) |
| + return |
| + } |
| + w.Write([]byte(`{"id":"ii_usage","invoice":"in_usage","customer":"cus_paid","livemode":false,"currency":"gbp","amount":800,"discountable":false}`)) |
| + })) |
| + defer srv.Close() |
| + c, ctx := testClient(t, srv.URL), context.Background() |
| + draft, item := draftInvoiceFixture(), invoiceItemFixture() |
| + draft.Currency, item.Currency = "gbp", "gbp" |
| + if inv, err := c.CreateDraftInvoice(ctx, draft, "k1"); err != nil || inv.Currency != "gbp" { |
| + t.Fatalf("draft in gbp: %+v %v", inv, err) |
| + } |
| + if it, err := c.CreateInvoiceItem(ctx, item, "k2"); err != nil || it.Currency != "gbp" { |
| + t.Fatalf("item in gbp: %+v %v", it, err) |
| + } |
| + if len(forms) != 2 || forms[0].Get("currency") != "gbp" || forms[1].Get("currency") != "gbp" { |
| + t.Fatalf("currency sent: %v", forms) |
| + } |
| + for _, bad := range []string{"", "GBP", "pound", "g b"} { |
| + draft.Currency, item.Currency = bad, bad |
| + if _, err := c.CreateDraftInvoice(ctx, draft, "k3"); err == nil { |
| + t.Errorf("draft with currency %q: accepted", bad) |
| + } |
| + if _, err := c.CreateInvoiceItem(ctx, item, "k4"); err == nil { |
| + t.Errorf("item with currency %q: accepted", bad) |
| + } |
| + } |
| + if len(forms) != 2 { |
| + t.Fatalf("a request with a malformed currency was sent (%d requests)", len(forms)) |
| + } |
| + // The encoding a caller hashes. |
| + draft.Currency, item.Currency = "", "" |
| + for _, v := range []any{draft, item} { |
| + raw, err := json.Marshal(v) |
| + if err != nil || strings.Contains(string(raw), "Currency") { |
| + t.Errorf("parameters with no currency encode as %s (%v)", raw, err) |
| + } |
| + } |
| + draft.Currency = "usd" |
| + if raw, _ := json.Marshal(draft); !strings.Contains(string(raw), `"Currency":"usd"`) { |
| + t.Errorf("parameters with a currency encode as %s", raw) |
| + } |
| +} |