package currencyx_test import ( "cmp" "strings" "testing" "github.com/alpacahq/alpacadecimal" "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/pkg/currencyx" ) func TestAllocateByWeight(t *testing.T) { usd := testCurrencyFiat(t, "USD") t.Run("allocates exact proportional shares", func(t *testing.T) { allocations, err := currencyx.AllocateByWeight(usd, currencyx.WeightedAllocationInput[string]{ Amount: dec("6.00"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("10")}, {Key: "B", Weight: dec("5")}, }, }) require.NoError(t, err) requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{ {Key: "A", Amount: dec("4.00")}, {Key: "B", Amount: dec("2.00")}, }, allocations) }) t.Run("distributes rounded remainder by largest remainder", func(t *testing.T) { allocations, err := currencyx.AllocateByWeight(usd, currencyx.WeightedAllocationInput[string]{ Amount: dec("0.05"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("1")}, {Key: "B", Weight: dec("1")}, {Key: "C", Weight: dec("1")}, }, }) require.NoError(t, err) requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{ {Key: "A", Amount: dec("0.02")}, {Key: "B", Amount: dec("0.02")}, {Key: "C", Amount: dec("0.01")}, }, allocations) }) t.Run("uses key comparator as deterministic remainder tie-breaker", func(t *testing.T) { allocations, err := currencyx.AllocateByWeight(usd, currencyx.WeightedAllocationInput[string]{ Amount: dec("0.05"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "C", Weight: dec("1")}, {Key: "A", Weight: dec("1")}, {Key: "B", Weight: dec("1")}, }, CompareKey: cmp.Compare[string], }) require.NoError(t, err) requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{ {Key: "C", Amount: dec("0.01")}, {Key: "A", Amount: dec("0.02")}, {Key: "B", Amount: dec("0.02")}, }, allocations) }) t.Run("uses currency precision", func(t *testing.T) { jpy := testCurrencyFiat(t, "JPY") allocations, err := currencyx.AllocateByWeight(jpy, currencyx.WeightedAllocationInput[string]{ Amount: dec("5"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("1")}, {Key: "B", Weight: dec("1")}, {Key: "C", Weight: dec("1")}, }, }) require.NoError(t, err) requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{ {Key: "A", Amount: dec("2")}, {Key: "B", Amount: dec("2")}, {Key: "C", Amount: dec("1")}, }, allocations) }) t.Run("uses custom currency precision", func(t *testing.T) { custom := testCurrencyCustom(t, "CREDITS", 4) allocations, err := currencyx.AllocateByWeight(custom, currencyx.WeightedAllocationInput[string]{ Amount: dec("0.0005"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("1")}, {Key: "B", Weight: dec("1")}, }, }) require.NoError(t, err) requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{ {Key: "A", Amount: dec("0.0003")}, {Key: "B", Amount: dec("0.0002")}, }, allocations) }) t.Run("omits zero allocations", func(t *testing.T) { allocations, err := currencyx.AllocateByWeight(usd, currencyx.WeightedAllocationInput[string]{ Amount: dec("0.01"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("100")}, {Key: "B", Weight: dec("1")}, }, }) require.NoError(t, err) requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{ {Key: "A", Amount: dec("0.01")}, }, allocations) }) t.Run("supports dimensionless weights", func(t *testing.T) { allocations, err := currencyx.AllocateByWeight(usd, currencyx.WeightedAllocationInput[string]{ Amount: dec("100.00"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("1")}, {Key: "B", Weight: dec("2")}, {Key: "C", Weight: dec("3")}, }, }) require.NoError(t, err) requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{ {Key: "A", Amount: dec("16.67")}, {Key: "B", Amount: dec("33.33")}, {Key: "C", Amount: dec("50.00")}, }, allocations) }) } func TestAllocateByWeightValidation(t *testing.T) { usd := testCurrencyFiat(t, "USD") cases := []struct { name string input currencyx.WeightedAllocationInput[string] contains string }{ { name: "amount must be rounded", input: currencyx.WeightedAllocationInput[string]{ Amount: dec("1.001"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("2.00")}, }, }, contains: "amount must be rounded to currency precision", }, { name: "weight must be positive", input: currencyx.WeightedAllocationInput[string]{ Amount: dec("1.00"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("0.00")}, }, }, contains: "items[0].weight must be positive", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := currencyx.AllocateByWeight(usd, tc.input) require.Error(t, err) require.Contains(t, err.Error(), tc.contains) }) } t.Run("zero amount does not require items", func(t *testing.T) { allocations, err := currencyx.AllocateByWeight(usd, currencyx.WeightedAllocationInput[string]{ Amount: dec("0.00"), }) require.NoError(t, err) require.Empty(t, allocations) }) t.Run("weight does not need currency precision", func(t *testing.T) { allocations, err := currencyx.AllocateByWeight(usd, currencyx.WeightedAllocationInput[string]{ Amount: dec("1.00"), Items: []currencyx.WeightedAllocationItem[string]{ {Key: "A", Weight: dec("0.001")}, {Key: "B", Weight: dec("0.002")}, }, }) require.NoError(t, err) requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{ {Key: "A", Amount: dec("0.33")}, {Key: "B", Amount: dec("0.67")}, }, allocations) }) } func TestAllocateByAmount(t *testing.T) { usd := testCurrencyFiat(t, "USD") t.Run("allocates exact proportional shares", func(t *testing.T) { allocations, err := currencyx.AllocateByAmount(usd, currencyx.AmountAllocationInput[string]{ Amount: dec("6.00"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "A", Amount: dec("10.00")}, {Key: "B", Amount: dec("5.00")}, }, }) require.NoError(t, err) requireAmountAllocationsEqual(t, []currencyx.AmountAllocation[string]{ {Key: "A", Amount: dec("4.00")}, {Key: "B", Amount: dec("2.00")}, }, allocations) }) t.Run("allocates rounded shares by largest remainder", func(t *testing.T) { allocations, err := currencyx.AllocateByAmount(usd, currencyx.AmountAllocationInput[string]{ Amount: dec("5.00"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "A", Amount: dec("10.00")}, {Key: "B", Amount: dec("5.00")}, }, }) require.NoError(t, err) requireAmountAllocationsEqual(t, []currencyx.AmountAllocation[string]{ {Key: "A", Amount: dec("3.33")}, {Key: "B", Amount: dec("1.67")}, }, allocations) }) t.Run("uses key comparator as deterministic remainder tie-breaker", func(t *testing.T) { allocations, err := currencyx.AllocateByAmount(usd, currencyx.AmountAllocationInput[string]{ Amount: dec("0.05"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "C", Amount: dec("1.00")}, {Key: "A", Amount: dec("1.00")}, {Key: "B", Amount: dec("1.00")}, }, CompareKey: cmp.Compare[string], }) require.NoError(t, err) requireAmountAllocationsEqual(t, []currencyx.AmountAllocation[string]{ {Key: "C", Amount: dec("0.01")}, {Key: "A", Amount: dec("0.02")}, {Key: "B", Amount: dec("0.02")}, }, allocations) }) t.Run("uses currency precision", func(t *testing.T) { jpy := testCurrencyFiat(t, "JPY") allocations, err := currencyx.AllocateByAmount(jpy, currencyx.AmountAllocationInput[string]{ Amount: dec("5"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "A", Amount: dec("10")}, {Key: "B", Amount: dec("10")}, {Key: "C", Amount: dec("10")}, }, }) require.NoError(t, err) requireAmountAllocationsEqual(t, []currencyx.AmountAllocation[string]{ {Key: "A", Amount: dec("2")}, {Key: "B", Amount: dec("2")}, {Key: "C", Amount: dec("1")}, }, allocations) }) t.Run("omits zero allocations", func(t *testing.T) { allocations, err := currencyx.AllocateByAmount(usd, currencyx.AmountAllocationInput[string]{ Amount: dec("0.01"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "A", Amount: dec("100.00")}, {Key: "B", Amount: dec("1.00")}, }, }) require.NoError(t, err) requireAmountAllocationsEqual(t, []currencyx.AmountAllocation[string]{ {Key: "A", Amount: dec("0.01")}, }, allocations) }) } func TestAllocateByAmountValidation(t *testing.T) { usd := testCurrencyFiat(t, "USD") cases := []struct { name string input currencyx.AmountAllocationInput[string] contains string }{ { name: "amount must be rounded", input: currencyx.AmountAllocationInput[string]{ Amount: dec("1.001"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "A", Amount: dec("2.00")}, }, }, contains: "amount must be rounded to currency precision", }, { name: "item amount must be positive", input: currencyx.AmountAllocationInput[string]{ Amount: dec("1.00"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "A", Amount: dec("0.00")}, }, }, contains: "items[0].amount must be positive", }, { name: "item amount must be rounded", input: currencyx.AmountAllocationInput[string]{ Amount: dec("1.00"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "A", Amount: dec("2.001")}, }, }, contains: "items[0].amount must be rounded to currency precision", }, { name: "amount must not exceed total item amount", input: currencyx.AmountAllocationInput[string]{ Amount: dec("3.00"), Items: []currencyx.AmountAllocationItem[string]{ {Key: "A", Amount: dec("2.00")}, }, }, contains: "amount must not exceed total item amount", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := currencyx.AllocateByAmount(usd, tc.input) require.Error(t, err) require.Contains(t, err.Error(), tc.contains) }) } t.Run("zero amount does not require items", func(t *testing.T) { allocations, err := currencyx.AllocateByAmount(usd, currencyx.AmountAllocationInput[string]{ Amount: dec("0.00"), }) require.NoError(t, err) require.Empty(t, allocations) }) } func testCurrencyFiat(t *testing.T, code string) currencyx.Currency { t.Helper() currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). WithCode(currencyx.Code(code)). Build() require.NoError(t, err) return currency } func testCurrencyCustom(t *testing.T, code string, precision int32) currencyx.Currency { t.Helper() currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeCustom). WithCode(currencyx.Code(code)). WithName(code). WithPrecision(precision). Build() require.NoError(t, err) return currency } func requireAllocationsEqual[T comparable](t *testing.T, expected, actual []currencyx.WeightedAllocation[T]) { t.Helper() require.Len(t, actual, len(expected)) for i, expectedAllocation := range expected { require.Equal(t, expectedAllocation.Key, actual[i].Key) require.Truef( t, expectedAllocation.Amount.Equal(actual[i].Amount), "expected allocation %d amount %s, got %s", i, expectedAllocation.Amount.String(), actual[i].Amount.String(), ) } } func requireAmountAllocationsEqual[T comparable](t *testing.T, expected, actual []currencyx.AmountAllocation[T]) { t.Helper() require.Len(t, actual, len(expected)) for i, expectedAllocation := range expected { require.Equal(t, expectedAllocation.Key, actual[i].Key) require.Truef( t, expectedAllocation.Amount.Equal(actual[i].Amount), "expected allocation %d amount %s, got %s", i, expectedAllocation.Amount.String(), actual[i].Amount.String(), ) } } func dec(value string) alpacadecimal.Decimal { return alpacadecimal.RequireFromString(strings.TrimSpace(value)) }