Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/charges/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions api/v3/handlers/currencies/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions api/v3/handlers/currencies/create_cost_basis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down
18 changes: 12 additions & 6 deletions api/v3/handlers/currencies/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}
34 changes: 34 additions & 0 deletions api/v3/handlers/currencies/handler_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
2 changes: 1 addition & 1 deletion api/v3/handlers/currencies/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion api/v3/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 20 additions & 13 deletions app/common/charges.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -509,6 +515,7 @@ func newChargesRegistry(
ratingService,
currenciesService,
streamingConnector,
creditsConfig,
)
if err != nil {
return nil, err
Expand All @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions app/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ func TestComplete(t *testing.T) {
Credits: CreditsConfiguration{
Enabled: false,
EnableCreditThenInvoice: false,
CustomCurrenciesEnabled: false,
},
UnitConfig: UnitConfigConfiguration{
Enabled: false,
Expand Down
2 changes: 2 additions & 0 deletions app/config/credits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
4 changes: 4 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions e2e/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ postgres:
credits:
enabled: true
enableCreditThenInvoice: true
customCurrenciesEnabled: true

unitConfig:
enabled: true
Expand Down
9 changes: 7 additions & 2 deletions openmeter/billing/charges/creditpurchase/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type Config struct {
Handler creditpurchase.Handler
Lineage lineage.Service
MetaAdapter meta.Adapter

CustomCurrenciesEnabled bool
}

func (c Config) Validate() error {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions openmeter/billing/charges/flatfee/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type Config struct {
Locker *lockr.Locker
RatingService rating.Service
Currencies currencies.Service

CustomCurrenciesEnabled bool
}

func (c Config) Validate() error {
Expand Down Expand Up @@ -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
}
Expand Down
13 changes: 5 additions & 8 deletions openmeter/billing/charges/service/creditpurchase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
22 changes: 9 additions & 13 deletions openmeter/billing/charges/service/invoicable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading