Skip to content
Merged
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
4 changes: 2 additions & 2 deletions openmeter/billing/derived.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion openmeter/billing/models/stddetailedline/derived.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion openmeter/ledger/customerbalance/facade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
2 changes: 1 addition & 1 deletion openmeter/ledger/customerbalance/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
10 changes: 9 additions & 1 deletion openmeter/ledger/customerbalance/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
7 changes: 7 additions & 0 deletions openmeter/ledger/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
36 changes: 36 additions & 0 deletions openmeter/ledger/routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
92 changes: 89 additions & 3 deletions pkg/currencyx/code.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
}
148 changes: 148 additions & 0 deletions pkg/currencyx/code_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
Loading
Loading