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 }