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) && 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"), diff --git a/pkg/currencyx/code.go b/pkg/currencyx/code.go index b3f68b3a32..da261a08bb 100644 --- a/pkg/currencyx/code.go +++ b/pkg/currencyx/code.go @@ -3,24 +3,110 @@ 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) + _ models.Equaler[Code] = (*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) Equal(other Code) bool { + return c == other +} + +func (c Code) Type() CurrencyType { + if len(c) == 3 { + return CurrencyTypeFiat + } + + 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 + 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..a943effc6b --- /dev/null +++ b/pkg/currencyx/code_test.go @@ -0,0 +1,148 @@ +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) + }) + } +} + +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)) + }) + } +} + +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()) + require.Equal(t, testCase.expected == currencyx.CurrencyTypeFiat, testCase.code.IsFiat()) + require.Equal(t, testCase.expected == currencyx.CurrencyTypeCustom, testCase.code.IsCustom()) + }) + } +} 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 == "" {