| package routingrules |
|
|
| import ( |
| "fmt" |
|
|
| "github.com/openmeterio/openmeter/openmeter/ledger" |
| "github.com/openmeterio/openmeter/pkg/models" |
| ) |
|
|
| type RoutingRule interface { |
| Validate(tx TxView) error |
| } |
|
|
| type Validator struct { |
| Rules []RoutingRule |
| } |
|
|
| var _ ledger.RoutingValidator = (*Validator)(nil) |
|
|
| func (v Validator) ValidateEntries(entries []ledger.EntryInput) error { |
| view, err := NewTxView(entries) |
| if err != nil { |
| return err |
| } |
|
|
| for _, rule := range v.Rules { |
| if err := rule.Validate(view); err != nil { |
| return err |
| } |
| } |
|
|
| return nil |
| } |
|
|
| type FuncRule func(tx TxView) error |
|
|
| func (f FuncRule) Validate(tx TxView) error { |
| return f(tx) |
| } |
|
|
| type RequireUniqueSubAccountsRule struct{} |
|
|
| type entryIdentityKey struct { |
| subAccountID string |
| identityKey string |
| } |
|
|
| func (r RequireUniqueSubAccountsRule) Validate(tx TxView) error { |
| seen := make(map[entryIdentityKey]struct{}, len(tx.Entries())) |
| for _, entry := range tx.Entries() { |
| subAccountID := entry.Entry().PostingAddress().SubAccountID() |
| identityKey := entry.Entry().IdentityKey() |
| key := entryIdentityKey{ |
| subAccountID: subAccountID, |
| identityKey: identityKey, |
| } |
| if _, ok := seen[key]; ok { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "duplicate_entry_identity", |
| "sub_account_id": subAccountID, |
| "identity_key": identityKey, |
| }) |
| } |
|
|
| seen[key] = struct{}{} |
| } |
|
|
| return nil |
| } |
|
|
| type AllowedAccountSetsRule struct { |
| Sets [][]ledger.AccountType |
| } |
|
|
| func (r AllowedAccountSetsRule) Validate(tx TxView) error { |
| present := tx.AccountTypes() |
| if len(present) == 0 { |
| return nil |
| } |
|
|
| for _, allowed := range r.Sets { |
| if sameAccountTypeSet(present, allowed) { |
| return nil |
| } |
| } |
|
|
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "account_type_combination_not_allowed", |
| "account_types": present, |
| }) |
| } |
|
|
| type RequireFlowDirectionRule struct { |
| From ledger.AccountType |
| To ledger.AccountType |
| } |
|
|
| func (r RequireFlowDirectionRule) Validate(tx TxView) error { |
| if !tx.HasAccountTypes(r.From, r.To) { |
| return nil |
| } |
|
|
| fromEntries := tx.EntriesOf(r.From) |
| toEntries := tx.EntriesOf(r.To) |
| if hasMixedSigns(fromEntries) || hasMixedSigns(toEntries) { |
| return nil |
| } |
|
|
| for _, entry := range fromEntries { |
| if !entry.Amount().IsNegative() { |
| if allEntriesPositive(fromEntries) && allEntriesNegative(toEntries) { |
| return nil |
| } |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "invalid_flow_direction", |
| "account_type": r.From, |
| "expected": "negative_or_positive_if_reversed", |
| "target_type": r.To, |
| }) |
| } |
| } |
|
|
| for _, entry := range toEntries { |
| if !entry.Amount().IsPositive() { |
| if allEntriesPositive(fromEntries) && allEntriesNegative(toEntries) { |
| return nil |
| } |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "invalid_flow_direction", |
| "account_type": r.To, |
| "expected": "positive_or_negative_if_reversed", |
| "source_type": r.From, |
| }) |
| } |
| } |
|
|
| return nil |
| } |
|
|
| func hasMixedSigns(entries []EntryView) bool { |
| hasPositive := false |
| hasNegative := false |
|
|
| for _, entry := range entries { |
| hasPositive = hasPositive || entry.Amount().IsPositive() |
| hasNegative = hasNegative || entry.Amount().IsNegative() |
| if hasPositive && hasNegative { |
| return true |
| } |
| } |
|
|
| return false |
| } |
|
|
| func allEntriesPositive(entries []EntryView) bool { |
| for _, entry := range entries { |
| if !entry.Amount().IsPositive() { |
| return false |
| } |
| } |
|
|
| return len(entries) > 0 |
| } |
|
|
| func allEntriesNegative(entries []EntryView) bool { |
| for _, entry := range entries { |
| if !entry.Amount().IsNegative() { |
| return false |
| } |
| } |
|
|
| return len(entries) > 0 |
| } |
|
|
| type RouteField string |
|
|
| const ( |
| RouteFieldCurrency RouteField = "currency" |
| RouteFieldTaxCode RouteField = "tax_code" |
| RouteFieldTaxBehavior RouteField = "tax_behavior" |
| RouteFieldFeatures RouteField = "features" |
| RouteFieldCostBasis RouteField = "cost_basis" |
| RouteFieldCreditPriority RouteField = "credit_priority" |
| RouteFieldTransactionAuthorizationStatus RouteField = "transaction_authorization_status" |
| ) |
|
|
| type RequireSameRouteRule struct { |
| Left ledger.AccountType |
| Right ledger.AccountType |
| Fields []RouteField |
| } |
|
|
| func (r RequireSameRouteRule) Validate(tx TxView) error { |
| if !tx.HasAccountTypes(r.Left, r.Right) { |
| return nil |
| } |
|
|
| return requireMatchingRouteFields(tx.EntriesOf(r.Left), tx.EntriesOf(r.Right), r.Left, r.Right, r.Fields) |
| } |
|
|
| type RequireTaxDimensionScopeRule struct{} |
|
|
| func (r RequireTaxDimensionScopeRule) Validate(tx TxView) error { |
| for _, entry := range tx.Entries() { |
| switch entry.AccountType() { |
| case ledger.AccountTypeCustomerAccrued, ledger.AccountTypeEarnings: |
| if entry.Route().TaxCode == nil && entry.Route().TaxBehavior != nil { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "tax_behavior_requires_tax_code_on_accrued_or_earnings", |
| "account_type": entry.AccountType(), |
| }) |
| } |
| default: |
| if entry.Route().TaxCode != nil || entry.Route().TaxBehavior != nil { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "tax_dimensions_only_allowed_on_accrued_or_earnings", |
| "account_type": entry.AccountType(), |
| }) |
| } |
| } |
| } |
|
|
| return nil |
| } |
|
|
| type RequireFeatureDimensionScopeRule struct{} |
|
|
| func (r RequireFeatureDimensionScopeRule) Validate(tx TxView) error { |
| for _, entry := range tx.Entries() { |
| if len(entry.Route().Features) == 0 { |
| continue |
| } |
|
|
| switch entry.AccountType() { |
| case ledger.AccountTypeCustomerFBO, ledger.AccountTypeCustomerReceivable: |
| continue |
| default: |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "features_only_allowed_on_fbo_or_receivable", |
| "account_type": entry.AccountType(), |
| }) |
| } |
| } |
|
|
| return nil |
| } |
|
|
| type RequireAccountAuthorizationStatusRule struct { |
| WhenHasAccountTypes []ledger.AccountType |
| AccountType ledger.AccountType |
| Expected ledger.TransactionAuthorizationStatus |
| } |
|
|
| func (r RequireAccountAuthorizationStatusRule) Validate(tx TxView) error { |
| if !tx.HasAccountTypes(r.WhenHasAccountTypes...) { |
| return nil |
| } |
|
|
| return requireAuthorizationStatus(tx.EntriesOf(r.AccountType), r.AccountType, r.Expected) |
| } |
|
|
| func sameRouteField(left ledger.Route, right ledger.Route, field RouteField) (bool, error) { |
| switch field { |
| case RouteFieldCurrency: |
| return left.Currency == right.Currency, nil |
| case RouteFieldTaxCode: |
| return optionalStringEqual(left.TaxCode, right.TaxCode), nil |
| case RouteFieldTaxBehavior: |
| return optionalTaxBehaviorEqual(left.TaxBehavior, right.TaxBehavior), nil |
| case RouteFieldFeatures: |
| return stringSliceEqual(left.Features, right.Features), nil |
| case RouteFieldCostBasis: |
| return optionalDecimalEqual(left.CostBasis, right.CostBasis), nil |
| case RouteFieldCreditPriority: |
| return optionalIntEqual(left.CreditPriority, right.CreditPriority), nil |
| case RouteFieldTransactionAuthorizationStatus: |
| return optionalTransactionAuthorizationStatusEqual(left.TransactionAuthorizationStatus, right.TransactionAuthorizationStatus), nil |
| default: |
| return false, fmt.Errorf("unknown route field: %s", field) |
| } |
| } |
|
|
| type RequireReceivableAuthorizationStageRule struct{} |
|
|
| func (r RequireReceivableAuthorizationStageRule) Validate(tx TxView) error { |
| accountTypes := tx.AccountTypes() |
| if len(accountTypes) != 1 || accountTypes[0] != ledger.AccountTypeCustomerReceivable { |
| return nil |
| } |
|
|
| negativeEntries, positiveEntries := entriesBySign(tx.EntriesOf(ledger.AccountTypeCustomerReceivable)) |
|
|
| if len(negativeEntries) == 0 || len(positiveEntries) == 0 { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "receivable_authorization_transition_requires_both_sides", |
| "account_type": ledger.AccountTypeCustomerReceivable, |
| }) |
| } |
|
|
| if allEntriesHaveAuthorizationStatus(negativeEntries, ledger.TransactionAuthorizationStatusAuthorized) && |
| allEntriesHaveAuthorizationStatus(positiveEntries, ledger.TransactionAuthorizationStatusOpen) { |
| return requireMatchingRouteFields( |
| negativeEntries, |
| positiveEntries, |
| ledger.AccountTypeCustomerReceivable, |
| ledger.AccountTypeCustomerReceivable, |
| []RouteField{ |
| RouteFieldCurrency, |
| RouteFieldTaxCode, |
| RouteFieldFeatures, |
| RouteFieldCostBasis, |
| RouteFieldCreditPriority, |
| }, |
| ) |
| } |
|
|
| if allEntriesHaveAuthorizationStatus(negativeEntries, ledger.TransactionAuthorizationStatusOpen) && |
| allEntriesHaveAuthorizationStatus(positiveEntries, ledger.TransactionAuthorizationStatusOpen) { |
| if err := requireKnownToUnknownCostBasisTranslationEitherDirection( |
| negativeEntries, |
| positiveEntries, |
| ledger.AccountTypeCustomerReceivable, |
| []RouteField{ |
| RouteFieldCurrency, |
| RouteFieldTaxCode, |
| RouteFieldCreditPriority, |
| RouteFieldTransactionAuthorizationStatus, |
| }, |
| ); err != nil { |
| return err |
| } |
|
|
| return nil |
| } |
|
|
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "unsupported_receivable_same_account_transition", |
| "account_type": ledger.AccountTypeCustomerReceivable, |
| }) |
| } |
|
|
| type RequireAccruedCostBasisTranslationRule struct{} |
|
|
| func (r RequireAccruedCostBasisTranslationRule) Validate(tx TxView) error { |
| accountTypes := tx.AccountTypes() |
| if len(accountTypes) != 1 || accountTypes[0] != ledger.AccountTypeCustomerAccrued { |
| return nil |
| } |
|
|
| negativeEntries, positiveEntries := entriesBySign(tx.EntriesOf(ledger.AccountTypeCustomerAccrued)) |
| if len(negativeEntries) == 0 || len(positiveEntries) == 0 { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "accrued_cost_basis_translation_requires_both_sides", |
| "account_type": ledger.AccountTypeCustomerAccrued, |
| }) |
| } |
|
|
| return requireKnownToUnknownCostBasisTranslationEitherDirection( |
| negativeEntries, |
| positiveEntries, |
| ledger.AccountTypeCustomerAccrued, |
| []RouteField{ |
| RouteFieldCurrency, |
| RouteFieldTaxCode, |
| RouteFieldTaxBehavior, |
| }, |
| ) |
| } |
|
|
| type RequireFBOCostBasisTranslationRule struct{} |
|
|
| func (r RequireFBOCostBasisTranslationRule) Validate(tx TxView) error { |
| accountTypes := tx.AccountTypes() |
| if len(accountTypes) != 1 || accountTypes[0] != ledger.AccountTypeCustomerFBO { |
| return nil |
| } |
|
|
| negativeEntries, positiveEntries := entriesBySign(tx.EntriesOf(ledger.AccountTypeCustomerFBO)) |
| if len(negativeEntries) == 0 || len(positiveEntries) == 0 { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "fbo_cost_basis_translation_requires_both_sides", |
| "account_type": ledger.AccountTypeCustomerFBO, |
| }) |
| } |
|
|
| return requireKnownToUnknownCostBasisTranslationEitherDirection( |
| positiveEntries, |
| negativeEntries, |
| ledger.AccountTypeCustomerFBO, |
| []RouteField{ |
| RouteFieldCurrency, |
| RouteFieldFeatures, |
| RouteFieldCreditPriority, |
| }, |
| ) |
| } |
|
|
| func entriesBySign(entries []EntryView) ([]EntryView, []EntryView) { |
| negativeEntries := make([]EntryView, 0, len(entries)) |
| positiveEntries := make([]EntryView, 0, len(entries)) |
|
|
| for _, entry := range entries { |
| switch { |
| case entry.Amount().IsNegative(): |
| negativeEntries = append(negativeEntries, entry) |
| case entry.Amount().IsPositive(): |
| positiveEntries = append(positiveEntries, entry) |
| } |
| } |
|
|
| return negativeEntries, positiveEntries |
| } |
|
|
| func requireAuthorizationStatus(entries []EntryView, accountType ledger.AccountType, expected ledger.TransactionAuthorizationStatus) error { |
| for _, entry := range entries { |
| if entry.Route().TransactionAuthorizationStatus == nil || *entry.Route().TransactionAuthorizationStatus != expected { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "transaction_authorization_status_mismatch", |
| "account_type": accountType, |
| "expected_transaction_auth_status": expected, |
| }) |
| } |
| } |
|
|
| return nil |
| } |
|
|
| func allEntriesHaveAuthorizationStatus(entries []EntryView, expected ledger.TransactionAuthorizationStatus) bool { |
| for _, entry := range entries { |
| if entry.Route().TransactionAuthorizationStatus == nil || *entry.Route().TransactionAuthorizationStatus != expected { |
| return false |
| } |
| } |
|
|
| return true |
| } |
|
|
| func requireKnownToUnknownCostBasisTranslation(knownEntries, unknownEntries []EntryView, accountType ledger.AccountType, fields []RouteField) error { |
| for _, entry := range knownEntries { |
| if entry.Route().CostBasis == nil { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "known_cost_basis_required", |
| "account_type": accountType, |
| }) |
| } |
| } |
|
|
| for _, entry := range unknownEntries { |
| if entry.Route().CostBasis != nil { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "unknown_cost_basis_required", |
| "account_type": accountType, |
| }) |
| } |
| } |
|
|
| return requireMatchingRouteFields(knownEntries, unknownEntries, accountType, accountType, fields) |
| } |
|
|
| func requireKnownToUnknownCostBasisTranslationEitherDirection(leftEntries, rightEntries []EntryView, accountType ledger.AccountType, fields []RouteField) error { |
| if err := requireKnownToUnknownCostBasisTranslation(leftEntries, rightEntries, accountType, fields); err == nil { |
| return nil |
| } |
|
|
| return requireKnownToUnknownCostBasisTranslation(rightEntries, leftEntries, accountType, fields) |
| } |
|
|
| func requireMatchingRouteFields(leftEntries, rightEntries []EntryView, leftType, rightType ledger.AccountType, fields []RouteField) error { |
| for _, left := range leftEntries { |
| matched, err := hasMatchingRouteFields(left, rightEntries, fields) |
| if err != nil { |
| return err |
| } |
| if !matched { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "route_field_mismatch", |
| "left_type": leftType, |
| "right_type": rightType, |
| "fields": fields, |
| }) |
| } |
| } |
|
|
| for _, right := range rightEntries { |
| matched, err := hasMatchingRouteFields(right, leftEntries, fields) |
| if err != nil { |
| return err |
| } |
| if !matched { |
| return ledger.ErrRoutingRuleViolated.WithAttrs(models.Attributes{ |
| "reason": "route_field_mismatch", |
| "left_type": leftType, |
| "right_type": rightType, |
| "fields": fields, |
| }) |
| } |
| } |
|
|
| return nil |
| } |
|
|
| func hasMatchingRouteFields(entry EntryView, candidates []EntryView, fields []RouteField) (bool, error) { |
| for _, candidate := range candidates { |
| matches := true |
| for _, field := range fields { |
| same, err := sameRouteField(entry.Route(), candidate.Route(), field) |
| if err != nil { |
| return false, err |
| } |
| if !same { |
| matches = false |
| break |
| } |
| } |
| if matches { |
| return true, nil |
| } |
| } |
|
|
| return false, nil |
| } |
|
|
| func sameAccountTypeSet(left []ledger.AccountType, right []ledger.AccountType) bool { |
| if len(left) != len(right) { |
| return false |
| } |
|
|
| index := make(map[ledger.AccountType]struct{}, len(left)) |
| for _, item := range left { |
| index[item] = struct{}{} |
| } |
|
|
| for _, item := range right { |
| if _, ok := index[item]; !ok { |
| return false |
| } |
| } |
|
|
| return true |
| } |
|
|