openmeter / pkg /currencyx /allocation_test.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 9)
fea99b3 verified
Raw
History Blame Contribute Delete
12.3 kB
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))
}