From ab0d023a99fab646b60a21aa2715b04cf950416d Mon Sep 17 00:00:00 2001 From: Peter Turi Date: Thu, 23 Jul 2026 12:38:25 +0200 Subject: [PATCH] feat(customcurrencies): add configuration flag --- .agents/skills/charges/SKILL.md | 2 +- api/v3/handlers/currencies/create.go | 5 +++ .../handlers/currencies/create_cost_basis.go | 5 +++ api/v3/handlers/currencies/handler.go | 18 ++++++---- api/v3/handlers/currencies/handler_test.go | 34 +++++++++++++++++++ api/v3/handlers/currencies/list_test.go | 2 +- api/v3/server/server.go | 2 +- app/common/charges.go | 33 +++++++++++------- app/config/config_test.go | 1 + app/config/credits.go | 2 ++ config.example.yaml | 4 +++ e2e/config.yaml | 1 + .../charges/creditpurchase/service/service.go | 9 +++-- .../charges/flatfee/service/service.go | 3 ++ .../charges/service/creditpurchase_test.go | 13 +++---- .../charges/service/invoicable_test.go | 22 +++++------- .../charges/usagebased/service/service.go | 10 ++++-- openmeter/server/router/router.go | 1 + quickstart/config.yaml | 1 + 19 files changed, 120 insertions(+), 48 deletions(-) create mode 100644 api/v3/handlers/currencies/handler_test.go diff --git a/.agents/skills/charges/SKILL.md b/.agents/skills/charges/SKILL.md index f3ae16e94c..104264b876 100644 --- a/.agents/skills/charges/SKILL.md +++ b/.agents/skills/charges/SKILL.md @@ -642,7 +642,7 @@ Use these conventions for lifecycle tests: - for credit-only charges (usage-based or flat fee), handler callbacks must not return credit allocations above the requested amount; exact allocation paths must return allocations that sum to the requested amount - for flat fee credit-only tests, use `mustAdvanceFlatFeeCharges(...)` helper — it filters the advance result to flat fee charges only - for credit-purchase state-machine unit tests, use testify `mock.Mock` with `On(...).Run(...).Return(...).Once()` for expected handler callbacks so missing or unexpected calls fail; in service-suite tests, leave callbacks unset when validating that a flow fails before callbacks, because the shared `CreditPurchaseTestHandler` already errors if an unset callback is invoked -- keep custom-currency support disabled on default charge service instances so tests continue covering the production unsupported boundary; when a test needs it enabled, use a semantic suite helper that constructs and enables the service instead of repeating `SetEnableCustomCurrency` interface assertions at call sites +- keep custom-currency support disabled on default charge service instances so tests continue covering the production unsupported boundary; production initialization flows through `credits.customCurrenciesEnabled` into each type-specific service config, while tests should use a semantic suite helper that constructs an enabled service instead of repeating `SetEnableCustomCurrency` interface assertions at call sites - when testing timestamp truncation, use sub-second fixtures and assert the persisted charge/run fields are second-aligned after create/advance - `time.Time` fields on domain models are value typed; use `s.False(ts.IsZero())` instead of `s.NotNil(ts)` when asserting they are populated - cover the temporary shrink/extend remap path as well; it synthesizes new intents and must normalize the replacement period ends before re-create diff --git a/api/v3/handlers/currencies/create.go b/api/v3/handlers/currencies/create.go index 36ac0030e4..6508c04b37 100644 --- a/api/v3/handlers/currencies/create.go +++ b/api/v3/handlers/currencies/create.go @@ -13,6 +13,7 @@ import ( "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" ) type ( @@ -24,6 +25,10 @@ type ( func (h *handler) CreateCurrency() CreateCurrencyHandler { return httptransport.NewHandler( func(ctx context.Context, r *http.Request) (CreateCurrencyRequest, error) { + if !h.customCurrenciesEnabled { + return CreateCurrencyRequest{}, models.NewGenericValidationError(errCustomCurrenciesDisabled) + } + ns, err := h.resolveNamespace(ctx) if err != nil { return CreateCurrencyRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) diff --git a/api/v3/handlers/currencies/create_cost_basis.go b/api/v3/handlers/currencies/create_cost_basis.go index 72600aa5c8..4b9c6b9c16 100644 --- a/api/v3/handlers/currencies/create_cost_basis.go +++ b/api/v3/handlers/currencies/create_cost_basis.go @@ -13,6 +13,7 @@ import ( "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" ) type ( @@ -24,6 +25,10 @@ type ( func (h *handler) CreateCostBasis() CreateCostBasisHandler { return httptransport.NewHandlerWithArgs( func(ctx context.Context, r *http.Request, currencyID string) (CreateCostBasisRequest, error) { + if !h.customCurrenciesEnabled { + return CreateCostBasisRequest{}, models.NewGenericValidationError(errCustomCurrenciesDisabled) + } + ns, err := h.resolveNamespace(ctx) if err != nil { return CreateCostBasisRequest{}, err diff --git a/api/v3/handlers/currencies/handler.go b/api/v3/handlers/currencies/handler.go index a284c6fba0..aacece795a 100644 --- a/api/v3/handlers/currencies/handler.go +++ b/api/v3/handlers/currencies/handler.go @@ -2,11 +2,14 @@ package currencies import ( "context" + "errors" "github.com/openmeterio/openmeter/openmeter/currencies" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" ) +var errCustomCurrenciesDisabled = errors.New("custom currencies are not enabled on this deployment of OpenMeter") + type Handler interface { ListCurrencies() ListCurrenciesHandler CreateCurrency() CreateCurrencyHandler @@ -16,19 +19,22 @@ type Handler interface { } type handler struct { - resolveNamespace func(ctx context.Context) (string, error) - options []httptransport.HandlerOption - service currencies.Service + resolveNamespace func(ctx context.Context) (string, error) + options []httptransport.HandlerOption + service currencies.Service + customCurrenciesEnabled bool } func New( resolveNamespace func(ctx context.Context) (string, error), currencyService currencies.Service, + customCurrenciesEnabled bool, options ...httptransport.HandlerOption, ) Handler { return &handler{ - resolveNamespace: resolveNamespace, - options: options, - service: currencyService, + resolveNamespace: resolveNamespace, + options: options, + service: currencyService, + customCurrenciesEnabled: customCurrenciesEnabled, } } diff --git a/api/v3/handlers/currencies/handler_test.go b/api/v3/handlers/currencies/handler_test.go new file mode 100644 index 0000000000..817bbf3e1f --- /dev/null +++ b/api/v3/handlers/currencies/handler_test.go @@ -0,0 +1,34 @@ +package currencies + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCustomCurrencyMutationsAreDisabledByDefault(t *testing.T) { + handler := New(func(context.Context) (string, error) { + return "test", nil + }, nil, false) + + t.Run("create currency", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/api/v3/openmeter/currencies/custom", nil) + response := httptest.NewRecorder() + + handler.CreateCurrency().ServeHTTP(response, request) + + require.Equal(t, http.StatusBadRequest, response.Code) + }) + + t.Run("create cost basis", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/api/v3/openmeter/currencies/custom/currency-id/cost-bases", nil) + response := httptest.NewRecorder() + + handler.CreateCostBasis().With("currency-id").ServeHTTP(response, request) + + require.Equal(t, http.StatusBadRequest, response.Code) + }) +} diff --git a/api/v3/handlers/currencies/list_test.go b/api/v3/handlers/currencies/list_test.go index 06eab50449..bfcb440852 100644 --- a/api/v3/handlers/currencies/list_test.go +++ b/api/v3/handlers/currencies/list_test.go @@ -53,7 +53,7 @@ func TestListCurrenciesFilterByType(t *testing.T) { service := &listCurrenciesService{} handler := New(func(context.Context) (string, error) { return "test", nil - }, service) + }, service, true) request := httptest.NewRequest(http.MethodGet, "/api/v3/currencies", nil) response := httptest.NewRecorder() diff --git a/api/v3/server/server.go b/api/v3/server/server.go index 30cb79f1f0..1bed7713b0 100644 --- a/api/v3/server/server.go +++ b/api/v3/server/server.go @@ -332,7 +332,7 @@ func NewServer(config *Config) (*Server, error) { plansHandler := planshandler.New(resolveNamespace, config.PlanService, config.UnitConfig.Enabled, httptransport.WithErrorHandler(config.ErrorHandler)) planAddonsHandler := planaddonshandler.New(resolveNamespace, config.PlanService, config.PlanAddonService, httptransport.WithErrorHandler(config.ErrorHandler)) taxcodesHandler := taxcodeshandler.New(resolveNamespace, config.TaxCodeService, httptransport.WithErrorHandler(config.ErrorHandler)) - currenciesHandler := currencieshandler.New(resolveNamespace, config.CurrencyService, httptransport.WithErrorHandler(config.ErrorHandler)) + currenciesHandler := currencieshandler.New(resolveNamespace, config.CurrencyService, config.Credits.CustomCurrenciesEnabled, httptransport.WithErrorHandler(config.ErrorHandler)) var chargesH chargeshandler.Handler if config.ChargeService != nil { diff --git a/app/common/charges.go b/app/common/charges.go index 06d58c9981..aa9455d1ba 100644 --- a/app/common/charges.go +++ b/app/common/charges.go @@ -213,15 +213,17 @@ func NewChargesFlatFeeService( locker *lockr.Locker, ratingService rating.Service, currenciesService currencies.Service, + creditsConfig config.CreditsConfiguration, ) (flatfee.Service, error) { flatFeeSvc, err := flatfeeservice.New(flatfeeservice.Config{ - Adapter: flatFeeAdapter, - Handler: flatFeeHandler, - Lineage: lineageService, - MetaAdapter: metaAdapter, - Locker: locker, - RatingService: ratingService, - Currencies: currenciesService, + Adapter: flatFeeAdapter, + Handler: flatFeeHandler, + Lineage: lineageService, + MetaAdapter: metaAdapter, + Locker: locker, + RatingService: ratingService, + Currencies: currenciesService, + CustomCurrenciesEnabled: creditsConfig.CustomCurrenciesEnabled, }) if err != nil { return nil, fmt.Errorf("failed to create charges flat fee service: %w", err) @@ -259,6 +261,7 @@ func NewChargesUsageBasedService( ratingService rating.Service, currenciesService currencies.Service, streamingConnector streaming.Connector, + creditsConfig config.CreditsConfiguration, ) (usagebased.Service, error) { usageBasedSvc, err := usagebasedservice.New(usagebasedservice.Config{ Adapter: usageBasedAdapter, @@ -272,6 +275,7 @@ func NewChargesUsageBasedService( RatingService: ratingService, Currencies: currenciesService, StreamingConnector: streamingConnector, + CustomCurrenciesEnabled: creditsConfig.CustomCurrenciesEnabled, }) if err != nil { return nil, fmt.Errorf("failed to create charges usage based service: %w", err) @@ -317,12 +321,14 @@ func NewChargesCreditPurchaseService( creditPurchaseHandler creditpurchase.Handler, lineageService lineage.Service, metaAdapter meta.Adapter, + creditsConfig config.CreditsConfiguration, ) (creditpurchase.Service, error) { creditPurchaseSvc, err := creditpurchaseservice.New(creditpurchaseservice.Config{ - Adapter: creditPurchaseAdapter, - Handler: creditPurchaseHandler, - Lineage: lineageService, - MetaAdapter: metaAdapter, + Adapter: creditPurchaseAdapter, + Handler: creditPurchaseHandler, + Lineage: lineageService, + MetaAdapter: metaAdapter, + CustomCurrenciesEnabled: creditsConfig.CustomCurrenciesEnabled, }) if err != nil { return nil, fmt.Errorf("failed to create charges credit purchase service: %w", err) @@ -478,7 +484,7 @@ func newChargesRegistry( return nil, err } - flatFeeSvc, err := NewChargesFlatFeeService(flatFeeAdapter, flatFeeHandler, lineageService, metaAdapter, locker, ratingService, currenciesService) + flatFeeSvc, err := NewChargesFlatFeeService(flatFeeAdapter, flatFeeHandler, lineageService, metaAdapter, locker, ratingService, currenciesService, creditsConfig) if err != nil { return nil, err } @@ -509,6 +515,7 @@ func newChargesRegistry( ratingService, currenciesService, streamingConnector, + creditsConfig, ) if err != nil { return nil, err @@ -523,7 +530,7 @@ func newChargesRegistry( return nil, err } - creditPurchaseSvc, err := NewChargesCreditPurchaseService(creditPurchaseAdapter, creditPurchaseHandler, lineageService, metaAdapter) + creditPurchaseSvc, err := NewChargesCreditPurchaseService(creditPurchaseAdapter, creditPurchaseHandler, lineageService, metaAdapter, creditsConfig) if err != nil { return nil, err } diff --git a/app/config/config_test.go b/app/config/config_test.go index 0f171f6db3..0036db03d8 100644 --- a/app/config/config_test.go +++ b/app/config/config_test.go @@ -195,6 +195,7 @@ func TestComplete(t *testing.T) { Credits: CreditsConfiguration{ Enabled: false, EnableCreditThenInvoice: false, + CustomCurrenciesEnabled: false, }, UnitConfig: UnitConfigConfiguration{ Enabled: false, diff --git a/app/config/credits.go b/app/config/credits.go index fbc18405fa..fd2b496e65 100644 --- a/app/config/credits.go +++ b/app/config/credits.go @@ -9,6 +9,7 @@ import ( type CreditsConfiguration struct { Enabled bool `yaml:"enabled"` EnableCreditThenInvoice bool `yaml:"enableCreditThenInvoice"` + CustomCurrenciesEnabled bool `yaml:"customCurrenciesEnabled"` } func (c CreditsConfiguration) Validate() error { @@ -24,4 +25,5 @@ func ConfigureCredits(v *viper.Viper, prefixes ...string) { v.SetDefault(prefixer("enabled"), false) v.SetDefault(prefixer("enableCreditThenInvoice"), false) + v.SetDefault(prefixer("customCurrenciesEnabled"), false) } diff --git a/config.example.yaml b/config.example.yaml index 3c882373b4..7528e9bd68 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -105,6 +105,10 @@ billing: credits: enabled: true enableCreditThenInvoice: false + # WARNING: EXPERIMENTAL. Enabling custom currencies before the full + # implementation is in place can cause database inconsistencies. + # DO NOT enable this in a production environment. + customCurrenciesEnabled: false # unitConfig gates the UnitConfig feature on rate cards (conversion + rounding # of metered quantities before rating). Off by default: unit_config is rejected diff --git a/e2e/config.yaml b/e2e/config.yaml index edf1542fbe..95fe5cd770 100644 --- a/e2e/config.yaml +++ b/e2e/config.yaml @@ -47,6 +47,7 @@ postgres: credits: enabled: true enableCreditThenInvoice: true + customCurrenciesEnabled: true unitConfig: enabled: true diff --git a/openmeter/billing/charges/creditpurchase/service/service.go b/openmeter/billing/charges/creditpurchase/service/service.go index 39bb6f5537..f168bd9650 100644 --- a/openmeter/billing/charges/creditpurchase/service/service.go +++ b/openmeter/billing/charges/creditpurchase/service/service.go @@ -17,6 +17,8 @@ type Config struct { Handler creditpurchase.Handler Lineage lineage.Service MetaAdapter meta.Adapter + + CustomCurrenciesEnabled bool } func (c Config) Validate() error { @@ -55,13 +57,16 @@ func New(config Config) (creditpurchase.Service, error) { return nil, fmt.Errorf("realizations: %w", err) } - return &service{ + svc := &service{ adapter: config.Adapter, handler: config.Handler, lineage: config.Lineage, metaAdapter: config.MetaAdapter, realizations: realizations, - }, nil + } + svc.enableCustomCurrency.Store(config.CustomCurrenciesEnabled) + + return svc, nil } type service struct { diff --git a/openmeter/billing/charges/flatfee/service/service.go b/openmeter/billing/charges/flatfee/service/service.go index 19c454c61d..724b27c68b 100644 --- a/openmeter/billing/charges/flatfee/service/service.go +++ b/openmeter/billing/charges/flatfee/service/service.go @@ -25,6 +25,8 @@ type Config struct { Locker *lockr.Locker RatingService rating.Service Currencies currencies.Service + + CustomCurrenciesEnabled bool } func (c Config) Validate() error { @@ -92,6 +94,7 @@ func New(config Config) (flatfee.Service, error) { costbasisResolver: costbasisResolver, } svc.creditNotesSupported.Store(charges.CreditNotesSupportedByLineUpdater) + svc.enableCustomCurrency.Store(config.CustomCurrenciesEnabled) return svc, nil } diff --git a/openmeter/billing/charges/service/creditpurchase_test.go b/openmeter/billing/charges/service/creditpurchase_test.go index da2e64dd8b..9f754696a0 100644 --- a/openmeter/billing/charges/service/creditpurchase_test.go +++ b/openmeter/billing/charges/service/creditpurchase_test.go @@ -55,17 +55,14 @@ func (s *CreditPurchaseTestSuite) getCustomCurrenciesEnabledCreditPurchaseServic s.T().Helper() creditPurchaseService, err := creditpurchaseservice.New(creditpurchaseservice.Config{ - Adapter: s.CreditPurchaseAdapter, - Handler: s.CreditPurchaseTestHandler, - Lineage: lineageService, - MetaAdapter: s.MetaAdapter, + Adapter: s.CreditPurchaseAdapter, + Handler: s.CreditPurchaseTestHandler, + Lineage: lineageService, + MetaAdapter: s.MetaAdapter, + CustomCurrenciesEnabled: true, }) s.Require().NoError(err) - customCurrencyEnabler, ok := creditPurchaseService.(customCurrencyEnabler) - s.Require().True(ok) - s.Require().NoError(customCurrencyEnabler.SetEnableCustomCurrency(s.T(), true)) - return creditPurchaseService } diff --git a/openmeter/billing/charges/service/invoicable_test.go b/openmeter/billing/charges/service/invoicable_test.go index a426d499bd..8c0aadafb0 100644 --- a/openmeter/billing/charges/service/invoicable_test.go +++ b/openmeter/billing/charges/service/invoicable_test.go @@ -3002,18 +3002,16 @@ func (s *InvoicableChargesTestSuite) TestFlatFeeCreditOnlyWithCustomCurrency() { Once() customCurrencyFlatFeeService, err := flatfeeservice.New(flatfeeservice.Config{ - Adapter: s.FlatFeeAdapter, - Handler: s.FlatFeeTestHandler, - Lineage: lineageMock, - MetaAdapter: s.MetaAdapter, - Locker: s.Locker, - RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: s.UnitConfigEnabled}), - Currencies: s.CurrencyService, + Adapter: s.FlatFeeAdapter, + Handler: s.FlatFeeTestHandler, + Lineage: lineageMock, + MetaAdapter: s.MetaAdapter, + Locker: s.Locker, + RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: s.UnitConfigEnabled}), + Currencies: s.CurrencyService, + CustomCurrenciesEnabled: true, }) s.Require().NoError(err) - customCurrencyEnabler, ok := customCurrencyFlatFeeService.(customCurrencyEnabler) - s.Require().True(ok) - s.Require().NoError(customCurrencyEnabler.SetEnableCustomCurrency(s.T(), true)) originalFlatFeeService := s.Charges.flatFeeService s.Charges.flatFeeService = customCurrencyFlatFeeService @@ -3172,11 +3170,9 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditOnlyWithCustomCurrency( RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: s.UnitConfigEnabled}), Currencies: s.CurrencyService, StreamingConnector: s.MockStreamingConnector, + CustomCurrenciesEnabled: true, }) s.Require().NoError(err) - customCurrencyEnabler, ok := customCurrencyUsageBasedService.(customCurrencyEnabler) - s.Require().True(ok) - s.Require().NoError(customCurrencyEnabler.SetEnableCustomCurrency(s.T(), true)) originalUsageBasedService := s.Charges.usageBasedService s.Charges.usageBasedService = customCurrencyUsageBasedService diff --git a/openmeter/billing/charges/usagebased/service/service.go b/openmeter/billing/charges/usagebased/service/service.go index b12621f046..619ee61fbd 100644 --- a/openmeter/billing/charges/usagebased/service/service.go +++ b/openmeter/billing/charges/usagebased/service/service.go @@ -31,8 +31,9 @@ type Config struct { FeatureService feature.FeatureConnector RatingService rating.Service Currencies currencies.Service + StreamingConnector streaming.Connector - StreamingConnector streaming.Connector + CustomCurrenciesEnabled bool } func (c Config) Validate() error { @@ -116,7 +117,7 @@ func New(config Config) (usagebased.Service, error) { return nil, err } - return &service{ + svc := &service{ adapter: config.Adapter, locker: config.Locker, metaAdapter: config.MetaAdapter, @@ -127,7 +128,10 @@ func New(config Config) (usagebased.Service, error) { rater: rater, runs: runs, costbasisResolver: costbasisResolver, - }, nil + } + svc.enableCustomCurrency.Store(config.CustomCurrenciesEnabled) + + return svc, nil } type service struct { diff --git a/openmeter/server/router/router.go b/openmeter/server/router/router.go index 0d7bcd68e7..fdb198d731 100644 --- a/openmeter/server/router/router.go +++ b/openmeter/server/router/router.go @@ -526,6 +526,7 @@ func NewRouter(config Config) (*Router, error) { router.currencyHandler = currencyhandler.New( resolveNamespace, config.CurrencyService, + config.Credits.CustomCurrenciesEnabled, httptransport.WithErrorHandler(config.ErrorHandler), ) diff --git a/quickstart/config.yaml b/quickstart/config.yaml index dc15158f10..dd0bb3bd9e 100644 --- a/quickstart/config.yaml +++ b/quickstart/config.yaml @@ -33,6 +33,7 @@ postgres: credits: enabled: true enableCreditThenInvoice: true + customCurrenciesEnabled: true meters: # Sample meter to count API requests