| package ledger_test |
|
|
| import ( |
| "testing" |
| "time" |
|
|
| "github.com/alpacahq/alpacadecimal" |
| "github.com/samber/mo" |
| "github.com/stretchr/testify/require" |
|
|
| "github.com/openmeterio/openmeter/openmeter/ledger" |
| "github.com/openmeterio/openmeter/pkg/currencyx" |
| "github.com/openmeterio/openmeter/pkg/models" |
| ) |
|
|
| func TestEntryMatchesImpactFilter(t *testing.T) { |
| t.Parallel() |
|
|
| taxCode := "tax-standard" |
| otherTaxCode := "tax-reduced" |
| taxBehavior := ledger.TaxBehaviorInclusive |
| otherTaxBehavior := ledger.TaxBehaviorExclusive |
| priority := 10 |
| otherPriority := 20 |
| costBasis := alpacadecimal.NewFromInt(1) |
| otherCostBasis := alpacadecimal.NewFromInt(2) |
| authStatus := ledger.TransactionAuthorizationStatusOpen |
| otherAuthStatus := ledger.TransactionAuthorizationStatusAuthorized |
|
|
| entry := mustImpactTestEntry(t, ledger.AccountTypeCustomerFBO, ledger.Route{ |
| Currency: currencyx.Code("USD"), |
| TaxCode: &taxCode, |
| TaxBehavior: &taxBehavior, |
| Features: []string{"feature-a", "feature-b"}, |
| CostBasis: &costBasis, |
| CreditPriority: &priority, |
| TransactionAuthorizationStatus: &authStatus, |
| }) |
|
|
| tests := []struct { |
| name string |
| filter ledger.ImpactFilter |
| want bool |
| }{ |
| { |
| name: "empty filter matches", |
| want: true, |
| }, |
| { |
| name: "account type matches", |
| filter: ledger.ImpactFilter{ |
| AccountType: ledger.AccountTypeCustomerFBO, |
| }, |
| want: true, |
| }, |
| { |
| name: "account type mismatch", |
| filter: ledger.ImpactFilter{ |
| AccountType: ledger.AccountTypeCustomerAccrued, |
| }, |
| }, |
| { |
| name: "currency matches", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{Currency: currencyx.Code("USD")}, |
| }, |
| want: true, |
| }, |
| { |
| name: "currency mismatch", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{Currency: currencyx.Code("EUR")}, |
| }, |
| }, |
| { |
| name: "tax code matches", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxCode: mo.Some(&taxCode)}, |
| }, |
| want: true, |
| }, |
| { |
| name: "tax code mismatch", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxCode: mo.Some(&otherTaxCode)}, |
| }, |
| }, |
| { |
| name: "nil tax code required rejects non-nil route tax code", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxCode: mo.Some[*string](nil)}, |
| }, |
| }, |
| { |
| name: "tax behavior matches", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxBehavior: mo.Some(&taxBehavior)}, |
| }, |
| want: true, |
| }, |
| { |
| name: "tax behavior mismatch", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxBehavior: mo.Some(&otherTaxBehavior)}, |
| }, |
| }, |
| { |
| name: "nil tax behavior required rejects non-nil route tax behavior", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxBehavior: mo.Some[*ledger.TaxBehavior](nil)}, |
| }, |
| }, |
| { |
| name: "features match regardless of filter order", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{Features: mo.Some([]string{"feature-b", "feature-a"})}, |
| }, |
| want: true, |
| }, |
| { |
| name: "features mismatch", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{Features: mo.Some([]string{"feature-c"})}, |
| }, |
| }, |
| { |
| name: "cost basis absent filter ignores route cost basis", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{}, |
| }, |
| want: true, |
| }, |
| { |
| name: "cost basis matches", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{CostBasis: mo.Some(&costBasis)}, |
| }, |
| want: true, |
| }, |
| { |
| name: "cost basis mismatch", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{CostBasis: mo.Some(&otherCostBasis)}, |
| }, |
| }, |
| { |
| name: "nil cost basis filter rejects non-nil route cost basis", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{CostBasis: mo.Some[*alpacadecimal.Decimal](nil)}, |
| }, |
| }, |
| { |
| name: "credit priority matches", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{CreditPriority: &priority}, |
| }, |
| want: true, |
| }, |
| { |
| name: "credit priority mismatch", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{CreditPriority: &otherPriority}, |
| }, |
| }, |
| { |
| name: "authorization status matches", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TransactionAuthorizationStatus: &authStatus}, |
| }, |
| want: true, |
| }, |
| { |
| name: "authorization status mismatch", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TransactionAuthorizationStatus: &otherAuthStatus}, |
| }, |
| }, |
| { |
| name: "multiple fields match together", |
| filter: ledger.ImpactFilter{ |
| AccountType: ledger.AccountTypeCustomerFBO, |
| Route: ledger.RouteFilter{ |
| Currency: currencyx.Code("USD"), |
| TaxCode: mo.Some(&taxCode), |
| TaxBehavior: mo.Some(&taxBehavior), |
| Features: mo.Some([]string{"feature-b", "feature-a"}), |
| CostBasis: mo.Some(&costBasis), |
| CreditPriority: &priority, |
| }, |
| }, |
| want: true, |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| t.Parallel() |
|
|
| require.Equal(t, tt.want, ledger.EntryMatchesImpactFilter(entry, tt.filter)) |
| }) |
| } |
| } |
|
|
| func TestEntryMatchesImpactFilter_NilRouteFields(t *testing.T) { |
| t.Parallel() |
|
|
| taxCode := "tax-standard" |
| taxBehavior := ledger.TaxBehaviorInclusive |
| priority := 10 |
| authStatus := ledger.TransactionAuthorizationStatusOpen |
|
|
| entry := mustImpactTestEntry(t, ledger.AccountTypeCustomerReceivable, ledger.Route{ |
| Currency: currencyx.Code("USD"), |
| }) |
|
|
| tests := []struct { |
| name string |
| filter ledger.ImpactFilter |
| want bool |
| }{ |
| { |
| name: "tax code required but route has nil", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxCode: mo.Some(&taxCode)}, |
| }, |
| }, |
| { |
| name: "nil tax code required and route has nil", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxCode: mo.Some[*string](nil)}, |
| }, |
| want: true, |
| }, |
| { |
| name: "tax behavior required but route has nil", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxBehavior: mo.Some(&taxBehavior)}, |
| }, |
| }, |
| { |
| name: "nil tax behavior required and route has nil", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TaxBehavior: mo.Some[*ledger.TaxBehavior](nil)}, |
| }, |
| want: true, |
| }, |
| { |
| name: "nil cost basis required and route has nil", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{CostBasis: mo.Some[*alpacadecimal.Decimal](nil)}, |
| }, |
| want: true, |
| }, |
| { |
| name: "credit priority required but route has nil", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{CreditPriority: &priority}, |
| }, |
| }, |
| { |
| name: "authorization status required but route has nil", |
| filter: ledger.ImpactFilter{ |
| Route: ledger.RouteFilter{TransactionAuthorizationStatus: &authStatus}, |
| }, |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| t.Parallel() |
|
|
| require.Equal(t, tt.want, ledger.EntryMatchesImpactFilter(entry, tt.filter)) |
| }) |
| } |
| } |
|
|
| func TestTransactionImpact(t *testing.T) { |
| t.Parallel() |
|
|
| priorityOne := 1 |
| priorityTwo := 2 |
|
|
| tx := impactTestTransaction{ |
| entries: []ledger.Entry{ |
| mustImpactTestEntry(t, ledger.AccountTypeCustomerFBO, ledger.Route{ |
| Currency: currencyx.Code("USD"), |
| CreditPriority: &priorityOne, |
| }, alpacadecimal.NewFromInt(10)), |
| mustImpactTestEntry(t, ledger.AccountTypeCustomerFBO, ledger.Route{ |
| Currency: currencyx.Code("USD"), |
| CreditPriority: &priorityTwo, |
| }, alpacadecimal.NewFromInt(-3)), |
| mustImpactTestEntry(t, ledger.AccountTypeCustomerFBO, ledger.Route{ |
| Currency: currencyx.Code("EUR"), |
| }, alpacadecimal.NewFromInt(7)), |
| mustImpactTestEntry(t, ledger.AccountTypeCustomerAccrued, ledger.Route{ |
| Currency: currencyx.Code("USD"), |
| }, alpacadecimal.NewFromInt(20)), |
| }, |
| } |
|
|
| tests := []struct { |
| name string |
| filter ledger.ImpactFilter |
| want alpacadecimal.Decimal |
| }{ |
| { |
| name: "empty filter sums all entries", |
| want: alpacadecimal.NewFromInt(34), |
| }, |
| { |
| name: "account type filter sums matching account type", |
| filter: ledger.ImpactFilter{ |
| AccountType: ledger.AccountTypeCustomerFBO, |
| }, |
| want: alpacadecimal.NewFromInt(14), |
| }, |
| { |
| name: "account type and currency filter sum matching entries", |
| filter: ledger.ImpactFilter{ |
| AccountType: ledger.AccountTypeCustomerFBO, |
| Route: ledger.RouteFilter{ |
| Currency: currencyx.Code("USD"), |
| }, |
| }, |
| want: alpacadecimal.NewFromInt(7), |
| }, |
| { |
| name: "route priority filter sum matching entries", |
| filter: ledger.ImpactFilter{ |
| AccountType: ledger.AccountTypeCustomerFBO, |
| Route: ledger.RouteFilter{ |
| CreditPriority: &priorityOne, |
| }, |
| }, |
| want: alpacadecimal.NewFromInt(10), |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| t.Parallel() |
|
|
| require.True(t, tt.want.Equal(ledger.TransactionImpact(tx, tt.filter))) |
| }) |
| } |
| } |
|
|
| type impactTestTransaction struct { |
| entries []ledger.Entry |
| } |
|
|
| func (t impactTestTransaction) Cursor() ledger.TransactionCursor { |
| return ledger.TransactionCursor{ |
| BookedAt: t.BookedAt(), |
| CreatedAt: t.BookedAt(), |
| ID: t.ID(), |
| } |
| } |
|
|
| func (t impactTestTransaction) BookedAt() time.Time { |
| return time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) |
| } |
|
|
| func (t impactTestTransaction) Entries() []ledger.Entry { |
| return t.entries |
| } |
|
|
| func (t impactTestTransaction) ID() models.NamespacedID { |
| return models.NamespacedID{Namespace: "ns", ID: "tx-id"} |
| } |
|
|
| func (t impactTestTransaction) Annotations() models.Annotations { |
| return nil |
| } |
|
|
| var _ ledger.Transaction = impactTestTransaction{} |
|
|
| type impactTestEntry struct { |
| id models.NamespacedID |
| txID models.NamespacedID |
| address ledger.PostingAddress |
| amount alpacadecimal.Decimal |
| identity string |
| metadata models.Annotations |
| } |
|
|
| func (e impactTestEntry) ID() models.NamespacedID { |
| return e.id |
| } |
|
|
| func (e impactTestEntry) TransactionID() models.NamespacedID { |
| return e.txID |
| } |
|
|
| func (e impactTestEntry) PostingAddress() ledger.PostingAddress { |
| return e.address |
| } |
|
|
| func (e impactTestEntry) Amount() alpacadecimal.Decimal { |
| return e.amount |
| } |
|
|
| func (e impactTestEntry) IdentityKey() string { |
| return e.identity |
| } |
|
|
| func (e impactTestEntry) SchemaVersion() ledger.EntrySchemaVersion { |
| return ledger.EntrySchemaVersionCurrent |
| } |
|
|
| func (e impactTestEntry) SourceChargeID() *string { |
| return nil |
| } |
|
|
| func (e impactTestEntry) SpendChargeID() *string { |
| return nil |
| } |
|
|
| func (e impactTestEntry) Annotations() models.Annotations { |
| return e.metadata |
| } |
|
|
| var _ ledger.Entry = impactTestEntry{} |
|
|
| type impactTestAddress struct { |
| subAccountID string |
| accountType ledger.AccountType |
| route ledger.SubAccountRoute |
| } |
|
|
| func (a impactTestAddress) SubAccountID() string { |
| return a.subAccountID |
| } |
|
|
| func (a impactTestAddress) AccountType() ledger.AccountType { |
| return a.accountType |
| } |
|
|
| func (a impactTestAddress) Route() ledger.SubAccountRoute { |
| return a.route |
| } |
|
|
| func (a impactTestAddress) Equal(other ledger.PostingAddress) bool { |
| return a.SubAccountID() == other.SubAccountID() && |
| a.AccountType() == other.AccountType() && |
| a.Route().ID() == other.Route().ID() |
| } |
|
|
| var _ ledger.PostingAddress = impactTestAddress{} |
|
|
| func mustImpactTestEntry(t *testing.T, accountType ledger.AccountType, route ledger.Route, amount ...alpacadecimal.Decimal) ledger.Entry { |
| t.Helper() |
|
|
| normalizedRoute, err := route.Normalize() |
| require.NoError(t, err) |
|
|
| routingKey, err := ledger.BuildRoutingKey(normalizedRoute) |
| require.NoError(t, err) |
|
|
| subAccountRoute, err := ledger.NewSubAccountRouteFromData("route-id", routingKey, normalizedRoute) |
| require.NoError(t, err) |
|
|
| entryAmount := alpacadecimal.NewFromInt(1) |
| if len(amount) > 0 { |
| entryAmount = amount[0] |
| } |
|
|
| return impactTestEntry{ |
| id: models.NamespacedID{Namespace: "ns", ID: "entry-id"}, |
| txID: models.NamespacedID{Namespace: "ns", ID: "tx-id"}, |
| address: impactTestAddress{ |
| subAccountID: "sub-account-id", |
| accountType: accountType, |
| route: subAccountRoute, |
| }, |
| amount: entryAmount, |
| } |
| } |
|
|