carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 705acfadaa6268f1c775fa507be6cc2a60887902 parent ab55b8f
4 files changed, +270 −51
  • internal/billingstripe/client.go +89 −11
  • internal/billingstripe/client_test.go +107 −35
  • internal/billingstripe/invoices.go +16 −3
  • internal/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)
+ }
+}