| package ledger_test |
|
|
| import ( |
| "testing" |
| "time" |
|
|
| "github.com/alpacahq/alpacadecimal" |
| "github.com/stretchr/testify/require" |
|
|
| "github.com/openmeterio/openmeter/openmeter/ledger" |
| ledgeraccount "github.com/openmeterio/openmeter/openmeter/ledger/account" |
| "github.com/openmeterio/openmeter/pkg/currencyx" |
| "github.com/openmeterio/openmeter/pkg/models" |
| ) |
|
|
| func TestEntryIdentityParts_Text(t *testing.T) { |
| sourceChargeID := "01JABCDEF0123456789ABCDEFG" |
| spendChargeID := "01JBCDEFG0123456789ABCDEFG" |
| collectionSource := "0" |
| correctionSource := "entry:1" |
|
|
| tests := []struct { |
| name string |
| parts ledger.EntryIdentityParts |
| expectedText ledger.EntryIdentityKeyText |
| expectedVersion ledger.EntryIdentityVersion |
| }{ |
| { |
| name: "empty v1", |
| expectedText: "", |
| expectedVersion: ledger.EntryIdentityVersion1, |
| }, |
| { |
| name: "collection source v1", |
| parts: ledger.EntryIdentityParts{ |
| CollectionSource: &collectionSource, |
| }, |
| expectedText: "collection-source:0", |
| expectedVersion: ledger.EntryIdentityVersion1, |
| }, |
| { |
| name: "correction source v1", |
| parts: ledger.EntryIdentityParts{ |
| CorrectionSource: &correctionSource, |
| }, |
| expectedText: "correction-source:entry:1", |
| expectedVersion: ledger.EntryIdentityVersion1, |
| }, |
| { |
| name: "source charge v2", |
| parts: ledger.EntryIdentityParts{ |
| SourceChargeID: &sourceChargeID, |
| }, |
| expectedText: "entry-identity:v2:||01JABCDEF0123456789ABCDEFG|", |
| expectedVersion: ledger.EntryIdentityVersion2, |
| }, |
| { |
| name: "collection source plus charge provenance v2", |
| parts: ledger.EntryIdentityParts{ |
| CollectionSource: &collectionSource, |
| SourceChargeID: &sourceChargeID, |
| SpendChargeID: &spendChargeID, |
| }, |
| expectedText: "entry-identity:v2:0||01JABCDEF0123456789ABCDEFG|01JBCDEFG0123456789ABCDEFG", |
| expectedVersion: ledger.EntryIdentityVersion2, |
| }, |
| { |
| name: "correction source plus spend provenance v2", |
| parts: ledger.EntryIdentityParts{ |
| CorrectionSource: &correctionSource, |
| SpendChargeID: &spendChargeID, |
| }, |
| expectedText: "entry-identity:v2:|entry%3A1||01JBCDEFG0123456789ABCDEFG", |
| expectedVersion: ledger.EntryIdentityVersion2, |
| }, |
| } |
|
|
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| actualText, actualVersion := tt.parts.Text() |
| require.Equal(t, tt.expectedText, actualText) |
| require.Equal(t, tt.expectedVersion, actualVersion) |
| require.Equal(t, tt.expectedVersion, actualText.Version()) |
|
|
| parsedVersion, parsedParts, err := actualText.Parse() |
| require.NoError(t, err) |
| require.Equal(t, tt.expectedVersion, parsedVersion) |
| require.Equal(t, tt.parts, parsedParts) |
| }) |
| } |
| } |
|
|
| func TestEntryIdentityKeyText_Parse(t *testing.T) { |
| t.Run("returns malformed v2 error", func(t *testing.T) { |
| _, _, err := ledger.EntryIdentityKeyText("entry-identity:v2:too|few").Parse() |
| require.ErrorContains(t, err, "invalid ledger entry identity key format") |
| }) |
| } |
|
|
| func TestValidateEntryIdentityKey(t *testing.T) { |
| sourceChargeID := "01JABCDEF0123456789ABCDEFG" |
| spendChargeID := "01JBCDEFG0123456789ABCDEFG" |
| collectionSource := "0" |
| address := testEntryIdentityAddress(t, ledger.AccountTypeCustomerFBO, "subaccount", ledger.Route{ |
| Currency: currencyx.Code("USD"), |
| }) |
| identityKey, _ := ledger.EntryIdentityParts{ |
| CollectionSource: &collectionSource, |
| SourceChargeID: &sourceChargeID, |
| SpendChargeID: &spendChargeID, |
| }.Text() |
|
|
| t.Run("accepts canonical identity", func(t *testing.T) { |
| entry := validationEntryInput{ |
| address: address, |
| amount: alpacadecimal.NewFromInt(1), |
| identityKey: string(identityKey), |
| sourceChargeID: &sourceChargeID, |
| spendChargeID: &spendChargeID, |
| } |
|
|
| require.NoError(t, ledger.ValidateEntryInput(t.Context(), entry)) |
| }) |
|
|
| t.Run("accepts current schema without charge provenance", func(t *testing.T) { |
| entry := validationEntryInput{ |
| address: address, |
| amount: alpacadecimal.NewFromInt(1), |
| identityKey: "collection-source:0", |
| } |
|
|
| require.NoError(t, ledger.ValidateEntryInput(t.Context(), entry)) |
| }) |
|
|
| t.Run("rejects provenance without v2 identity", func(t *testing.T) { |
| entry := validationEntryInput{ |
| address: address, |
| amount: alpacadecimal.NewFromInt(1), |
| identityKey: "collection-source:0", |
| sourceChargeID: &sourceChargeID, |
| } |
|
|
| err := ledger.ValidateEntryInput(t.Context(), entry) |
| require.Error(t, err) |
| require.ErrorContains(t, ledger.ValidateEntryIdentityKey(entry), "identity_key version must be 2") |
| }) |
|
|
| t.Run("rejects mismatched source charge", func(t *testing.T) { |
| otherSourceChargeID := "01JCDEFGH0123456789ABCDEFG" |
| entry := validationEntryInput{ |
| address: address, |
| amount: alpacadecimal.NewFromInt(1), |
| identityKey: string(identityKey), |
| sourceChargeID: &otherSourceChargeID, |
| spendChargeID: &spendChargeID, |
| } |
|
|
| err := ledger.ValidateEntryInput(t.Context(), entry) |
| require.Error(t, err) |
| require.ErrorContains(t, ledger.ValidateEntryIdentityKey(entry), "source_charge_id does not match identity_key") |
| }) |
|
|
| t.Run("rejects legacy schema with charge provenance", func(t *testing.T) { |
| entry := validationEntryInput{ |
| address: address, |
| amount: alpacadecimal.NewFromInt(1), |
| identityKey: "collection-source:0", |
| schemaVersion: ledger.EntrySchemaVersionLegacy, |
| sourceChargeID: &sourceChargeID, |
| } |
|
|
| err := ledger.ValidateEntryInput(t.Context(), entry) |
| require.Error(t, err) |
| require.ErrorContains(t, ledger.ValidateEntryIdentityKey(entry), "schema_version 1 cannot contain charge provenance") |
| }) |
|
|
| t.Run("rejects legacy schema with v2 identity", func(t *testing.T) { |
| entry := validationEntryInput{ |
| address: address, |
| amount: alpacadecimal.NewFromInt(1), |
| identityKey: string(identityKey), |
| schemaVersion: ledger.EntrySchemaVersionLegacy, |
| } |
|
|
| err := ledger.ValidateEntryInput(t.Context(), entry) |
| require.Error(t, err) |
| require.ErrorContains(t, ledger.ValidateEntryIdentityKey(entry), "identity_key version 2 requires schema_version 2") |
| }) |
|
|
| t.Run("rejects unsupported schema version", func(t *testing.T) { |
| entry := validationEntryInput{ |
| address: address, |
| amount: alpacadecimal.NewFromInt(1), |
| identityKey: "collection-source:0", |
| schemaVersion: ledger.EntrySchemaVersion(99), |
| } |
|
|
| err := ledger.ValidateEntryInput(t.Context(), entry) |
| require.Error(t, err) |
| require.ErrorContains(t, ledger.ValidateEntryIdentityKey(entry), "unsupported schema_version 99") |
| }) |
|
|
| t.Run("rejects non-canonical identity", func(t *testing.T) { |
| entry := validationEntryInput{ |
| address: address, |
| amount: alpacadecimal.NewFromInt(1), |
| identityKey: "entry-identity:v2:0||01JABCDEF0123456789ABCDEFG|01JBCDEFG0123456789ABCDEFG|extra", |
| sourceChargeID: &sourceChargeID, |
| spendChargeID: &spendChargeID, |
| } |
|
|
| err := ledger.ValidateEntryInput(t.Context(), entry) |
| require.Error(t, err) |
| require.ErrorContains(t, ledger.ValidateEntryIdentityKey(entry), "invalid ledger entry identity key format") |
| }) |
| } |
|
|
| type validationEntryInput struct { |
| address ledger.PostingAddress |
| amount alpacadecimal.Decimal |
| identityKey string |
| schemaVersion ledger.EntrySchemaVersion |
| sourceChargeID *string |
| spendChargeID *string |
| } |
|
|
| func (e validationEntryInput) PostingAddress() ledger.PostingAddress { |
| return e.address |
| } |
|
|
| func (e validationEntryInput) Amount() alpacadecimal.Decimal { |
| return e.amount |
| } |
|
|
| func (e validationEntryInput) IdentityKey() string { |
| return e.identityKey |
| } |
|
|
| func (e validationEntryInput) SchemaVersion() ledger.EntrySchemaVersion { |
| if e.schemaVersion == 0 { |
| return ledger.EntrySchemaVersionCurrent |
| } |
|
|
| return e.schemaVersion |
| } |
|
|
| func (e validationEntryInput) SourceChargeID() *string { |
| return e.sourceChargeID |
| } |
|
|
| func (e validationEntryInput) SpendChargeID() *string { |
| return e.spendChargeID |
| } |
|
|
| func (e validationEntryInput) Annotations() models.Annotations { |
| return nil |
| } |
|
|
| func testEntryIdentityAddress(t *testing.T, accountType ledger.AccountType, subAccountID string, route ledger.Route) ledger.PostingAddress { |
| t.Helper() |
|
|
| key, err := ledger.BuildRoutingKey(route) |
| require.NoError(t, err) |
|
|
| addr, err := ledgeraccount.NewAddressFromData(ledgeraccount.AddressData{ |
| SubAccountID: subAccountID, |
| AccountType: accountType, |
| Route: route, |
| RouteID: "route-" + subAccountID + "-" + time.Now().UTC().Format("150405.000000000"), |
| RoutingKey: key, |
| }) |
| require.NoError(t, err) |
|
|
| return addr |
| } |
|
|