From dad2590e63bcb981f61b8f9a3b25ab7a42e45a17 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 13:02:57 +0530 Subject: [PATCH 1/4] fix(billing): print the credit purchase amount as a whole number Checkout metadata is stored as JSON, so the synced Stripe amount total reads back as a float64. Formatting it with %d wrote descriptions like "at %!d(float64=1000)[usd]". Convert it to int64 first, the same way the product quantity is read. --- billing/checkout/service.go | 2 +- billing/checkout/service_test.go | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/billing/checkout/service.go b/billing/checkout/service.go index 1104d651f..3e1e8a8bd 100644 --- a/billing/checkout/service.go +++ b/billing/checkout/service.go @@ -642,7 +642,7 @@ func (s *Service) ensureCreditsForProduct(ctx context.Context, ch Checkout) erro description := fmt.Sprintf("addition of %d credits for %s", creditAmount, chProduct.Title) if price, pok := ch.Metadata[AmountTotalMetadataKey]; pok { if currency, cok := ch.Metadata[CurrencyMetadataKey].(string); cok { - description = fmt.Sprintf("addition of %d credits for %s at %d[%s]", creditAmount, chProduct.Title, price, currency) + description = fmt.Sprintf("addition of %d credits for %s at %d[%s]", creditAmount, chProduct.Title, cast.ToInt64(price), currency) } } initiatorID := "" diff --git a/billing/checkout/service_test.go b/billing/checkout/service_test.go index 96c13797a..52cf1441c 100644 --- a/billing/checkout/service_test.go +++ b/billing/checkout/service_test.go @@ -2,12 +2,15 @@ package checkout import ( "context" + "encoding/json" "io" "log/slog" "testing" + "github.com/raystack/frontier/billing/credit" "github.com/raystack/frontier/billing/customer" "github.com/raystack/frontier/billing/plan" + "github.com/raystack/frontier/billing/product" "github.com/raystack/frontier/billing/subscription" "github.com/raystack/frontier/core/authenticate" "github.com/stretchr/testify/require" @@ -81,3 +84,58 @@ func TestService_Apply_RejectsInactivePlan(t *testing.T) { require.ErrorIs(t, err, plan.ErrPlanInactive) } + +type fakeProductSvc struct { + ProductService + p product.Product +} + +func (f fakeProductSvc) GetByID(_ context.Context, _ string) (product.Product, error) { + return f.p, nil +} + +type fakeCreditSvc struct { + CreditService + added []credit.Credit +} + +func (f *fakeCreditSvc) Add(_ context.Context, cred credit.Credit) error { + f.added = append(f.added, cred) + return nil +} + +// Checkout metadata is stored as JSON, so by the time a synced checkout reaches +// ensureCreditsForProduct the Stripe amount total reads back as a float64. The +// credit description must still print it as a whole number, not %!d(float64=...). +func TestService_EnsureCreditsForProduct_DescribesAmountReadBackFromJSON(t *testing.T) { + stored, err := json.Marshal(map[string]any{ + AmountTotalMetadataKey: int64(1000), + CurrencyMetadataKey: "usd", + }) + require.NoError(t, err) + var md map[string]any + require.NoError(t, json.Unmarshal(stored, &md)) + + credits := &fakeCreditSvc{} + s := &Service{ + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + productService: fakeProductSvc{p: product.Product{ + ID: "prod-1", + Title: "Tokens", + Behavior: product.CreditBehavior, + Config: product.BehaviorConfig{CreditAmount: 10}, + }}, + creditService: credits, + } + + err = s.ensureCreditsForProduct(context.Background(), Checkout{ + ID: "ch-1", + CustomerID: "cust-1", + ProductID: "prod-1", + Metadata: md, + }) + + require.NoError(t, err) + require.Len(t, credits.added, 1) + require.Equal(t, "addition of 10 credits for Tokens at 1000[usd]", credits.added[0].Description) +} From 5fff6c0f54f2891e52b8c62f63bce4662434d1b5 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 13:05:18 +0530 Subject: [PATCH 2/4] test(billing): drop the credit description test --- billing/checkout/service_test.go | 58 -------------------------------- 1 file changed, 58 deletions(-) diff --git a/billing/checkout/service_test.go b/billing/checkout/service_test.go index 52cf1441c..96c13797a 100644 --- a/billing/checkout/service_test.go +++ b/billing/checkout/service_test.go @@ -2,15 +2,12 @@ package checkout import ( "context" - "encoding/json" "io" "log/slog" "testing" - "github.com/raystack/frontier/billing/credit" "github.com/raystack/frontier/billing/customer" "github.com/raystack/frontier/billing/plan" - "github.com/raystack/frontier/billing/product" "github.com/raystack/frontier/billing/subscription" "github.com/raystack/frontier/core/authenticate" "github.com/stretchr/testify/require" @@ -84,58 +81,3 @@ func TestService_Apply_RejectsInactivePlan(t *testing.T) { require.ErrorIs(t, err, plan.ErrPlanInactive) } - -type fakeProductSvc struct { - ProductService - p product.Product -} - -func (f fakeProductSvc) GetByID(_ context.Context, _ string) (product.Product, error) { - return f.p, nil -} - -type fakeCreditSvc struct { - CreditService - added []credit.Credit -} - -func (f *fakeCreditSvc) Add(_ context.Context, cred credit.Credit) error { - f.added = append(f.added, cred) - return nil -} - -// Checkout metadata is stored as JSON, so by the time a synced checkout reaches -// ensureCreditsForProduct the Stripe amount total reads back as a float64. The -// credit description must still print it as a whole number, not %!d(float64=...). -func TestService_EnsureCreditsForProduct_DescribesAmountReadBackFromJSON(t *testing.T) { - stored, err := json.Marshal(map[string]any{ - AmountTotalMetadataKey: int64(1000), - CurrencyMetadataKey: "usd", - }) - require.NoError(t, err) - var md map[string]any - require.NoError(t, json.Unmarshal(stored, &md)) - - credits := &fakeCreditSvc{} - s := &Service{ - log: slog.New(slog.NewTextHandler(io.Discard, nil)), - productService: fakeProductSvc{p: product.Product{ - ID: "prod-1", - Title: "Tokens", - Behavior: product.CreditBehavior, - Config: product.BehaviorConfig{CreditAmount: 10}, - }}, - creditService: credits, - } - - err = s.ensureCreditsForProduct(context.Background(), Checkout{ - ID: "ch-1", - CustomerID: "cust-1", - ProductID: "prod-1", - Metadata: md, - }) - - require.NoError(t, err) - require.Len(t, credits.added, 1) - require.Equal(t, "addition of 10 credits for Tokens at 1000[usd]", credits.added[0].Description) -} From a777b3a4c1dc5ce780ad23b8ec4a1c6181e00df9 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 13:09:29 +0530 Subject: [PATCH 3/4] fix(billing): show the credit purchase amount in the currency's major unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripe reports the amount total in the currency's minor unit, so a $10.00 purchase read "at 1000[usd]". Format it with the currency's symbol and decimals instead, so it reads "at $10.00" (and "¥1,000" for a zero-decimal currency). An unknown currency code keeps the old format. --- billing/checkout/service.go | 21 +++++++++++++++++++-- go.mod | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/billing/checkout/service.go b/billing/checkout/service.go index 3e1e8a8bd..4f404f558 100644 --- a/billing/checkout/service.go +++ b/billing/checkout/service.go @@ -5,12 +5,16 @@ import ( "errors" "fmt" "log/slog" + "math" "strings" "sync" "time" "github.com/robfig/cron/v3" "github.com/stripe/stripe-go/v79" + "golang.org/x/text/currency" + "golang.org/x/text/language" + "golang.org/x/text/message" "github.com/raystack/frontier/billing" billingerrors "github.com/raystack/frontier/billing/errors" @@ -641,8 +645,8 @@ func (s *Service) ensureCreditsForProduct(ctx context.Context, ch Checkout) erro description := fmt.Sprintf("addition of %d credits for %s", creditAmount, chProduct.Title) if price, pok := ch.Metadata[AmountTotalMetadataKey]; pok { - if currency, cok := ch.Metadata[CurrencyMetadataKey].(string); cok { - description = fmt.Sprintf("addition of %d credits for %s at %d[%s]", creditAmount, chProduct.Title, cast.ToInt64(price), currency) + if cur, cok := ch.Metadata[CurrencyMetadataKey].(string); cok { + description = fmt.Sprintf("addition of %d credits for %s at %s", creditAmount, chProduct.Title, formatAmount(cast.ToInt64(price), cur)) } } initiatorID := "" @@ -666,6 +670,19 @@ func (s *Service) ensureCreditsForProduct(ctx context.Context, ch Checkout) erro return nil } +// formatAmount renders an amount in the currency's minor unit (as Stripe +// reports it) with its symbol and decimals, e.g. 1000 usd as $10.00 +func formatAmount(minor int64, cur string) string { + unit, err := currency.ParseISO(strings.ToUpper(cur)) + if err != nil { + return fmt.Sprintf("%d[%s]", minor, cur) + } + scale, _ := currency.Standard.Rounding(unit) + amount := float64(minor) / math.Pow10(scale) + s := message.NewPrinter(language.English).Sprint(currency.NarrowSymbol(unit.Amount(amount))) + return strings.Replace(s, " ", "", 1) +} + func (s *Service) checkIfAlreadySubscribed(ctx context.Context, ch Checkout) (string, error) { // check if subscription exists subs, err := s.subscriptionService.List(ctx, subscription.Filter{ diff --git a/go.mod b/go.mod index 89528569a..d05d5be1a 100644 --- a/go.mod +++ b/go.mod @@ -224,6 +224,6 @@ require ( golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.40.0 gopkg.in/ini.v1 v1.67.0 // indirect ) From 56fa544e4400c982aa4808626ec3e4f30a6f050b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 13:16:15 +0530 Subject: [PATCH 4/4] fix(billing): show the credit purchase amount with its currency code A narrow symbol rendered every dollar currency as a bare $, so CAD, AUD and USD purchases read the same. Print the amount in the major unit with the ISO code instead, e.g. "10.00 USD", and drop the symbol printer. --- billing/checkout/service.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/billing/checkout/service.go b/billing/checkout/service.go index 4f404f558..ef240b748 100644 --- a/billing/checkout/service.go +++ b/billing/checkout/service.go @@ -13,8 +13,6 @@ import ( "github.com/robfig/cron/v3" "github.com/stripe/stripe-go/v79" "golang.org/x/text/currency" - "golang.org/x/text/language" - "golang.org/x/text/message" "github.com/raystack/frontier/billing" billingerrors "github.com/raystack/frontier/billing/errors" @@ -670,17 +668,15 @@ func (s *Service) ensureCreditsForProduct(ctx context.Context, ch Checkout) erro return nil } -// formatAmount renders an amount in the currency's minor unit (as Stripe -// reports it) with its symbol and decimals, e.g. 1000 usd as $10.00 +// formatAmount renders a Stripe amount, given in the currency's minor unit, +// in its major unit, e.g. 1000 usd as 10.00 USD func formatAmount(minor int64, cur string) string { unit, err := currency.ParseISO(strings.ToUpper(cur)) if err != nil { return fmt.Sprintf("%d[%s]", minor, cur) } scale, _ := currency.Standard.Rounding(unit) - amount := float64(minor) / math.Pow10(scale) - s := message.NewPrinter(language.English).Sprint(currency.NarrowSymbol(unit.Amount(amount))) - return strings.Replace(s, " ", "", 1) + return fmt.Sprintf("%.*f %s", scale, float64(minor)/math.Pow10(scale), unit) } func (s *Service) checkIfAlreadySubscribed(ctx context.Context, ch Checkout) (string, error) {