From 5c8527158e2ad20e462fbda9f9176a8dc1987964 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Tue, 21 Jul 2026 10:18:44 +0200 Subject: [PATCH 1/6] refactor: validation of currencyx.Code --- pkg/currencyx/code.go | 71 +++++++++++++++++++++++++++++++-- pkg/currencyx/code_test.go | 81 ++++++++++++++++++++++++++++++++++++++ pkg/currencyx/currency.go | 45 ++++++--------------- 3 files changed, 162 insertions(+), 35 deletions(-) create mode 100644 pkg/currencyx/code_test.go diff --git a/pkg/currencyx/code.go b/pkg/currencyx/code.go index b3f68b3a32..9b0866a3f4 100644 --- a/pkg/currencyx/code.go +++ b/pkg/currencyx/code.go @@ -3,24 +3,89 @@ package currencyx import ( "errors" "fmt" + "strings" "github.com/invopop/gobl/currency" + + "github.com/openmeterio/openmeter/pkg/models" ) -var _ fmt.Stringer = (*Code)(nil) +var ( + _ fmt.Stringer = (*Code)(nil) + _ models.Validator = (*Code)(nil) +) // Code represents a fiat or custom currency code. Code values used directly as // Currency values are treated as fiat currencies for backwards compatibility. type Code currency.Code +const ( + CustomCurrencyCodeMinLength = 4 + CustomCurrencyCodeMaxLength = 24 +) + func (c Code) String() string { return string(c) } func (c Code) Validate() error { + var errs []error + if c == "" { - return errors.New("currency code is required") + errs = append(errs, errors.New("currency code is required")) + + return models.NewNillableGenericValidationError(errors.Join(errs...)) + } + + if len(c) == 3 { + if err := validateFiatCurrencyCode(c); err != nil { + errs = append(errs, err) + } + } else { + if err := validateCustomCurrencyCode(c); err != nil { + errs = append(errs, err) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func validateFiatCurrencyCode(code Code) error { + if len(code) != 3 { + return fmt.Errorf("invalid fiat currency code: %s", code) + } + + definition := currency.Get(currency.Code(code)) + if definition == nil || definition.ISONumeric == "" { + return fmt.Errorf("invalid fiat currency code: %s", code) + } + + return nil +} + +func validateCustomCurrencyCode(code Code) error { + var errs []error + + if code == "" { + errs = append(errs, errors.New("currency code is required")) + } + + codeString := code.String() + if len(codeString) != len(strings.TrimSpace(codeString)) { + errs = append(errs, fmt.Errorf("invalid currency code: cannot contain leading or trailing spaces: %s", code)) + } + + if strings.Contains(codeString, "|") { + errs = append(errs, fmt.Errorf("invalid currency code: cannot contain route delimiter: %s", code)) + } + + if fiatDefinition := currency.Get(currency.Code(code)); fiatDefinition != nil { + errs = append(errs, fmt.Errorf("currency code %s is a fiat currency", code)) + } + + if codeLength := len(codeString); codeLength < CustomCurrencyCodeMinLength || codeLength > CustomCurrencyCodeMaxLength { + errs = append(errs, fmt.Errorf("invalid currency code: it must be between %d and %d characters", CustomCurrencyCodeMinLength, CustomCurrencyCodeMaxLength)) } - return currency.Code(c).Validate() + return errors.Join(errs...) } diff --git a/pkg/currencyx/code_test.go b/pkg/currencyx/code_test.go new file mode 100644 index 0000000000..1623cc490d --- /dev/null +++ b/pkg/currencyx/code_test.go @@ -0,0 +1,81 @@ +package currencyx_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestCodeValidate(t *testing.T) { + testCases := []struct { + name string + code currencyx.Code + expectedError string + }{ + { + name: "empty", + expectedError: "currency code is required", + }, + { + name: "valid fiat", + code: "USD", + }, + { + name: "unknown three-character code", + code: "ZZZ", + expectedError: "invalid fiat currency code", + }, + { + name: "non-fiat three-character code", + code: "BTC", + expectedError: "invalid fiat currency code", + }, + { + name: "custom minimum length", + code: "TOKN", + }, + { + name: "custom maximum length", + code: currencyx.Code(strings.Repeat("A", currencyx.CustomCurrencyCodeMaxLength)), + }, + { + name: "custom code too short", + code: "AB", + expectedError: "between 4 and 24 characters", + }, + { + name: "custom code too long", + code: currencyx.Code(strings.Repeat("A", currencyx.CustomCurrencyCodeMaxLength+1)), + expectedError: "between 4 and 24 characters", + }, + { + name: "custom code contains route delimiter", + code: "CRE|DITS", + expectedError: "cannot contain route delimiter", + }, + { + name: "custom code contains surrounding whitespace", + code: " CREDITS", + expectedError: "cannot contain leading or trailing spaces", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + err := testCase.code.Validate() + if testCase.expectedError == "" { + require.NoError(t, err) + + return + } + + require.Error(t, err) + require.True(t, models.IsGenericValidationError(err)) + require.Contains(t, err.Error(), testCase.expectedError) + }) + } +} diff --git a/pkg/currencyx/currency.go b/pkg/currencyx/currency.go index 3a447a7558..fc7b6158c9 100644 --- a/pkg/currencyx/currency.go +++ b/pkg/currencyx/currency.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "slices" - "strings" "github.com/alpacahq/alpacadecimal" "github.com/invopop/gobl/currency" @@ -180,11 +179,13 @@ func (f *FiatCurrency) Validate() error { var errs []error - if f.def == nil || f.def.ISOCode == "" { - errs = append(errs, errors.New("invalid fiat currency: empty code")) - } + if f.def == nil { + errs = append(errs, errors.New("fiat currency is not initialized")) + } else { + if err := validateFiatCurrencyCode(Code(f.def.ISOCode)); err != nil { + errs = append(errs, err) + } - if f.def != nil { if f.def.Name == "" { errs = append(errs, errors.New("invalid fiat currency: empty name")) } @@ -194,11 +195,12 @@ func (f *FiatCurrency) Validate() error { } func newFiatCurrency(code string) (Currency, error) { - definition := currency.Get(currency.Code(code)) - if definition == nil || definition.ISONumeric == "" { - return nil, fmt.Errorf("invalid fiat currency code: %s", code) + if err := validateFiatCurrencyCode(Code(code)); err != nil { + return nil, err } + definition := currency.Get(currency.Code(code)) + if definition.Subunits > math.MaxInt32 { return nil, fmt.Errorf("value %d overflows int32", definition.Subunits) } @@ -275,12 +277,7 @@ func (c *CustomCurrency) ValidateWith(v ...models.ValidatorFunc[Currency]) error return models.Validate[Currency](c, v...) } -const ( - CustomCurrencyCodeMinLength = 4 - CustomCurrencyCodeMaxLength = 24 - - CustomCurrencyMaxPrecision uint32 = 12 -) +const CustomCurrencyMaxPrecision uint32 = 12 func (c *CustomCurrency) Validate() error { if c == nil { @@ -294,24 +291,8 @@ func (c *CustomCurrency) Validate() error { } if c.def != nil { - if c.def.ISOCode == "" { - errs = append(errs, errors.New("code is required")) - } - - if len(c.def.ISOCode) != len(strings.TrimSpace(c.def.ISOCode.String())) { - errs = append(errs, fmt.Errorf("invalid currency code: cannot contain leading or trailing spaces: %s", c.def.ISOCode)) - } - - if strings.Contains(c.def.ISOCode.String(), "|") { - errs = append(errs, fmt.Errorf("invalid currency code: cannot contain route delimiter: %s", c.def.ISOCode)) - } - - if fiatDef := currency.Get(c.def.ISOCode); fiatDef != nil { - errs = append(errs, fmt.Errorf("currency code %s is a fiat currency", c.def.ISOCode)) - } - - if cl := len(c.def.ISOCode); cl < CustomCurrencyCodeMinLength || cl > CustomCurrencyCodeMaxLength { - errs = append(errs, fmt.Errorf("invalid currency code: it must be between %d and %d characters", CustomCurrencyCodeMinLength, CustomCurrencyCodeMaxLength)) + if err := validateCustomCurrencyCode(Code(c.def.ISOCode)); err != nil { + errs = append(errs, err) } if c.def.Name == "" { From 2dd88e8c87f7a6860137d04247a6caa87c794482 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Tue, 21 Jul 2026 10:24:42 +0200 Subject: [PATCH 2/6] refactor: add Equaler support to currencyx.Code --- pkg/currencyx/code.go | 9 +++++++-- pkg/currencyx/code_test.go | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/pkg/currencyx/code.go b/pkg/currencyx/code.go index 9b0866a3f4..1dcaba42a3 100644 --- a/pkg/currencyx/code.go +++ b/pkg/currencyx/code.go @@ -11,8 +11,9 @@ import ( ) var ( - _ fmt.Stringer = (*Code)(nil) - _ models.Validator = (*Code)(nil) + _ fmt.Stringer = (*Code)(nil) + _ models.Validator = (*Code)(nil) + _ models.Equaler[Code] = (*Code)(nil) ) // Code represents a fiat or custom currency code. Code values used directly as @@ -28,6 +29,10 @@ func (c Code) String() string { return string(c) } +func (c Code) Equal(other Code) bool { + return c == other +} + func (c Code) Validate() error { var errs []error diff --git a/pkg/currencyx/code_test.go b/pkg/currencyx/code_test.go index 1623cc490d..a7b48ca070 100644 --- a/pkg/currencyx/code_test.go +++ b/pkg/currencyx/code_test.go @@ -79,3 +79,43 @@ func TestCodeValidate(t *testing.T) { }) } } + +func TestCodeEqual(t *testing.T) { + testCases := []struct { + name string + code currencyx.Code + other currencyx.Code + expected bool + }{ + { + name: "same fiat code", + code: "USD", + other: "USD", + expected: true, + }, + { + name: "different fiat code", + code: "USD", + other: "EUR", + expected: false, + }, + { + name: "same custom code", + code: "CREDITS", + other: "CREDITS", + expected: true, + }, + { + name: "case sensitive", + code: "CREDITS", + other: "credits", + expected: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + require.Equal(t, testCase.expected, testCase.code.Equal(testCase.other)) + }) + } +} From 32f573d4b3572b82a5ccc8fb8f1e271b838fbddf Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Tue, 21 Jul 2026 10:30:41 +0200 Subject: [PATCH 3/6] refactor: add currency type helper to currencyx.Code --- pkg/currencyx/code.go | 8 ++++++++ pkg/currencyx/code_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/pkg/currencyx/code.go b/pkg/currencyx/code.go index 1dcaba42a3..dcebd3c766 100644 --- a/pkg/currencyx/code.go +++ b/pkg/currencyx/code.go @@ -33,6 +33,14 @@ func (c Code) Equal(other Code) bool { return c == other } +func (c Code) Type() CurrencyType { + if len(c) == 3 { + return CurrencyTypeFiat + } + + return CurrencyTypeCustom +} + func (c Code) Validate() error { var errs []error diff --git a/pkg/currencyx/code_test.go b/pkg/currencyx/code_test.go index a7b48ca070..e05f1f8957 100644 --- a/pkg/currencyx/code_test.go +++ b/pkg/currencyx/code_test.go @@ -119,3 +119,28 @@ func TestCodeEqual(t *testing.T) { }) } } + +func TestCodeType(t *testing.T) { + testCases := []struct { + name string + code currencyx.Code + expected currencyx.CurrencyType + }{ + { + name: "fiat", + code: "USD", + expected: currencyx.CurrencyTypeFiat, + }, + { + name: "custom", + code: "CREDITS", + expected: currencyx.CurrencyTypeCustom, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + require.Equal(t, testCase.expected, testCase.code.Type()) + }) + } +} From 333238612f7b463a6f4111a814dcd7d1efec0f10 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Tue, 21 Jul 2026 10:45:08 +0200 Subject: [PATCH 4/6] chore: regenerate currency equality helpers --- openmeter/billing/derived.gen.go | 4 ++-- openmeter/billing/models/stddetailedline/derived.gen.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openmeter/billing/derived.gen.go b/openmeter/billing/derived.gen.go index 75782d15d9..9354d58d14 100644 --- a/openmeter/billing/derived.gen.go +++ b/openmeter/billing/derived.gen.go @@ -17,7 +17,7 @@ func deriveEqualGatheringLineBase(this, that *GatheringLineBase) bool { this.ManagedBy == that.ManagedBy && this.Engine == that.Engine && this.InvoiceID == that.InvoiceID && - this.Currency == that.Currency && + this.Currency.Equal(that.Currency) && this.ServicePeriod.Equal(that.ServicePeriod) && this.InvoiceAt.Equal(that.InvoiceAt) && this.Price.Equal(&that.Price) && @@ -86,7 +86,7 @@ func deriveEqualLineBase(this, that *StandardLineBase) bool { this.ManagedBy == that.ManagedBy && this.Engine == that.Engine && this.InvoiceID == that.InvoiceID && - this.Currency == that.Currency && + this.Currency.Equal(that.Currency) && this.Period.Equal(that.Period) && this.InvoiceAt.Equal(that.InvoiceAt) && ((this.OverrideCollectionPeriodEnd == nil && that.OverrideCollectionPeriodEnd == nil) || (this.OverrideCollectionPeriodEnd != nil && that.OverrideCollectionPeriodEnd != nil && (*(this.OverrideCollectionPeriodEnd)).Equal(*(that.OverrideCollectionPeriodEnd)))) && diff --git a/openmeter/billing/models/stddetailedline/derived.gen.go b/openmeter/billing/models/stddetailedline/derived.gen.go index bd75b8fa19..288115b727 100644 --- a/openmeter/billing/models/stddetailedline/derived.gen.go +++ b/openmeter/billing/models/stddetailedline/derived.gen.go @@ -17,7 +17,7 @@ func deriveEqualBase(this, that *Base) bool { ((this.Index == nil && that.Index == nil) || (this.Index != nil && that.Index != nil && *(this.Index) == *(that.Index))) && this.PaymentTerm == that.PaymentTerm && this.ServicePeriod.Equal(that.ServicePeriod) && - this.Currency == that.Currency && + this.Currency.Equal(that.Currency) && this.PerUnitAmount.Equal(that.PerUnitAmount) && this.Quantity.Equal(that.Quantity) && this.Totals.Equal(that.Totals) && From b4ba9b49a1d2d599057d0f1f42ca6a2b3c3214b9 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Tue, 21 Jul 2026 11:12:09 +0200 Subject: [PATCH 5/6] refactor: add currency type predicates to currencyx.Code --- pkg/currencyx/code.go | 8 ++++++++ pkg/currencyx/code_test.go | 2 ++ 2 files changed, 10 insertions(+) diff --git a/pkg/currencyx/code.go b/pkg/currencyx/code.go index dcebd3c766..da261a08bb 100644 --- a/pkg/currencyx/code.go +++ b/pkg/currencyx/code.go @@ -41,6 +41,14 @@ func (c Code) Type() CurrencyType { return CurrencyTypeCustom } +func (c Code) IsFiat() bool { + return c.Type() == CurrencyTypeFiat +} + +func (c Code) IsCustom() bool { + return c.Type() == CurrencyTypeCustom +} + func (c Code) Validate() error { var errs []error diff --git a/pkg/currencyx/code_test.go b/pkg/currencyx/code_test.go index e05f1f8957..a943effc6b 100644 --- a/pkg/currencyx/code_test.go +++ b/pkg/currencyx/code_test.go @@ -141,6 +141,8 @@ func TestCodeType(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { require.Equal(t, testCase.expected, testCase.code.Type()) + require.Equal(t, testCase.expected == currencyx.CurrencyTypeFiat, testCase.code.IsFiat()) + require.Equal(t, testCase.expected == currencyx.CurrencyTypeCustom, testCase.code.IsCustom()) }) } } From 53c9d2d4b30638be9e040102e7a7b7d4aeea31fb Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Tue, 21 Jul 2026 11:19:37 +0200 Subject: [PATCH 6/6] fix: reject custom currencies in ledger validation currencyx.Code now accepts valid custom currency codes, but ledger accounting remains fiat-only because amount precision and booking semantics depend on fiat definitions. Centralize the boundary in ledger.ValidateCurrency and reuse it from customer balance validation so custom codes fail early with ErrCurrencyInvalid while malformed codes retain their existing validation behavior. Add coverage for valid fiat, invalid, and unsupported custom currency codes. --- openmeter/ledger/customerbalance/facade.go | 2 +- openmeter/ledger/customerbalance/service.go | 2 +- .../ledger/customerbalance/service_test.go | 10 +++++- openmeter/ledger/routing.go | 7 ++++ openmeter/ledger/routing_test.go | 36 +++++++++++++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/openmeter/ledger/customerbalance/facade.go b/openmeter/ledger/customerbalance/facade.go index 9af8ec0bb7..f28f68afaa 100644 --- a/openmeter/ledger/customerbalance/facade.go +++ b/openmeter/ledger/customerbalance/facade.go @@ -132,7 +132,7 @@ func (f *Facade) GetBalances(ctx context.Context, input GetBalancesInput) ([]Bal codes = dedupeCurrencies(input.Currencies.Codes) for _, code := range codes { - if err := code.Validate(); err != nil { + if err := ledger.ValidateCurrency(code); err != nil { return nil, fmt.Errorf("currency %q is not supported by ledger: %w", code, err) } } diff --git a/openmeter/ledger/customerbalance/service.go b/openmeter/ledger/customerbalance/service.go index df4a7ec3c8..8c85a4a67d 100644 --- a/openmeter/ledger/customerbalance/service.go +++ b/openmeter/ledger/customerbalance/service.go @@ -118,7 +118,7 @@ func (i GetBalanceServiceInput) Validate() error { errs = append(errs, fmt.Errorf("customer ID: %w", err)) } - if err := i.Currency.Validate(); err != nil { + if err := ledger.ValidateCurrency(i.Currency); err != nil { errs = append(errs, fmt.Errorf("currency: %w", err)) } diff --git a/openmeter/ledger/customerbalance/service_test.go b/openmeter/ledger/customerbalance/service_test.go index 9dbdf7caba..5027e81914 100644 --- a/openmeter/ledger/customerbalance/service_test.go +++ b/openmeter/ledger/customerbalance/service_test.go @@ -63,7 +63,15 @@ func TestGetBalanceServiceInputValidate(t *testing.T) { name: "invalid currency", input: GetBalanceServiceInput{ CustomerID: valid.CustomerID, - Currency: currencyx.Code("not-a-currency"), + Currency: currencyx.Code("INVALID|CURRENCY"), + }, + wantErr: true, + }, + { + name: "custom currency", + input: GetBalanceServiceInput{ + CustomerID: valid.CustomerID, + Currency: currencyx.Code("CREDITS"), }, wantErr: true, }, diff --git a/openmeter/ledger/routing.go b/openmeter/ledger/routing.go index 75ea30b5a7..f5323e6bba 100644 --- a/openmeter/ledger/routing.go +++ b/openmeter/ledger/routing.go @@ -494,6 +494,13 @@ func ValidateCurrency(value currencyx.Code) error { }) } + if !value.IsFiat() { + return ErrCurrencyInvalid.WithAttrs(models.Attributes{ + "currency": value, + "reason": "custom_currency_not_supported", + }) + } + return nil } diff --git a/openmeter/ledger/routing_test.go b/openmeter/ledger/routing_test.go index 3e63112be9..e18f5301ce 100644 --- a/openmeter/ledger/routing_test.go +++ b/openmeter/ledger/routing_test.go @@ -146,6 +146,42 @@ func TestTaxBehaviorValidate(t *testing.T) { require.Error(t, TaxBehavior("").Validate()) } +func TestValidateCurrency(t *testing.T) { + testCases := []struct { + name string + code currencyx.Code + wantErr bool + }{ + { + name: "fiat currency", + code: "USD", + }, + { + name: "custom currency", + code: "CREDITS", + wantErr: true, + }, + { + name: "invalid currency", + code: "INVALID|CURRENCY", + wantErr: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + err := ValidateCurrency(testCase.code) + if testCase.wantErr { + require.ErrorIs(t, err, ErrCurrencyInvalid) + + return + } + + require.NoError(t, err) + }) + } +} + func TestRouteValidate_InvalidTaxBehavior(t *testing.T) { r := Route{ Currency: currencyx.Code("USD"),