From 5bebadf17c5403059dbfa53227807ff511bb4776 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 11 Dec 2025 23:30:42 +0100 Subject: [PATCH] ledger account reference removed --- .../service/fees/internal/calculator/impl.go | 4 -- .../service/fees/internal/resolver/impl.go | 12 ++-- .../fees/internal/resolver/resolver_test.go | 19 +++---- .../internal/service/fees/service_test.go | 12 ++-- api/billing/fees/storage/model/plan.go | 17 +++--- api/payments/orchestrator/config.yml | 3 + .../internal/server/internal/serverimp.go | 20 +++++++ .../internal/service/orchestrator/helpers.go | 16 ++++++ .../internal/service/orchestrator/options.go | 18 ++++++ .../service/orchestrator/quote_engine.go | 56 ++++++++++++++++++- .../internal/service/orchestrator/service.go | 13 +++-- 11 files changed, 148 insertions(+), 42 deletions(-) diff --git a/api/billing/fees/internal/service/fees/internal/calculator/impl.go b/api/billing/fees/internal/service/fees/internal/calculator/impl.go index 926752d..6bb32b3 100644 --- a/api/billing/fees/internal/service/fees/internal/calculator/impl.go +++ b/api/billing/fees/internal/service/fees/internal/calculator/impl.go @@ -90,10 +90,6 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte } ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef) - if ledgerAccountRef == "" { - c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID)) - continue - } amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule) if calcErr != nil { diff --git a/api/billing/fees/internal/service/fees/internal/resolver/impl.go b/api/billing/fees/internal/service/fees/internal/resolver/impl.go index 881980d..bcde1ef 100644 --- a/api/billing/fees/internal/service/fees/internal/resolver/impl.go +++ b/api/billing/fees/internal/service/fees/internal/resolver/impl.go @@ -38,23 +38,23 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver { } } -func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) { +func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) { if r.plans == nil { return nil, nil, merrors.InvalidArgument("fees: plans store is required") } // Try org-specific first if provided. - if orgID != nil && !orgID.IsZero() { - if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil { + if orgRef != nil && !orgRef.IsZero() { + if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil { if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { return plan, rule, nil } else if !errors.Is(selErr, ErrNoFeeRuleFound) { - r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex())) + r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex())) return nil, nil, selErr } - r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex())) + r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex())) } else if !errors.Is(err, storage.ErrFeePlanNotFound) { - r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex())) + r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex())) return nil, nil, err } } diff --git a/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go b/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go index bfc2e23..8ce3239 100644 --- a/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go +++ b/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go @@ -32,8 +32,8 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) { if err != nil { t.Fatalf("expected fallback to global, got error: %v", err) } - if !plan.GetOrganizationRef().IsZero() { - t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex()) + if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() { + t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex()) } if rule.RuleID != "global_capture" { t.Fatalf("unexpected rule selected: %s", rule.RuleID) @@ -59,8 +59,7 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) { {RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, }, } - orgPlan.SetOrganizationRef(org) - + orgPlan.OrganizationRef = &org store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}} resolver := New(store, zap.NewNop()) @@ -95,7 +94,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) { {RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, }, } - plan.SetOrganizationRef(org) + plan.OrganizationRef = &org store := &memoryPlansStore{plans: []*model.FeePlan{plan}} resolver := New(store, zap.NewNop()) @@ -136,7 +135,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) { {RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past}, }, } - orgPlan.SetOrganizationRef(org) + orgPlan.OrganizationRef = &org globalPlan := &model.FeePlan{ Active: true, @@ -221,7 +220,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) { {RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, }, } - p1.SetOrganizationRef(org) + p1.OrganizationRef = &org p2 := &model.FeePlan{ Active: true, EffectiveFrom: now.Add(-30 * time.Minute), @@ -229,7 +228,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) { {RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, }, } - p2.SetOrganizationRef(org) + p2.OrganizationRef = &org store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}} resolver := New(store, zap.NewNop()) @@ -263,7 +262,7 @@ func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.O func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { var matches []*model.FeePlan for _, plan := range m.plans { - if plan == nil || plan.GetOrganizationRef() != orgRef { + if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) { continue } if !plan.Active { @@ -289,7 +288,7 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) { var matches []*model.FeePlan for _, plan := range m.plans { - if plan == nil || !plan.GetOrganizationRef().IsZero() { + if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) { continue } if !plan.Active { diff --git a/api/billing/fees/internal/service/fees/service_test.go b/api/billing/fees/internal/service/fees/service_test.go index f53a170..3b58390 100644 --- a/api/billing/fees/internal/service/fees/service_test.go +++ b/api/billing/fees/internal/service/fees/service_test.go @@ -49,7 +49,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) { }, } plan.SetID(primitive.NewObjectID()) - plan.SetOrganizationRef(orgRef) + plan.OrganizationRef = &orgRef service := NewService( zap.NewNop(), @@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) { }, } plan.SetID(primitive.NewObjectID()) - plan.SetOrganizationRef(orgRef) + plan.OrganizationRef = &orgRef service := NewService( zap.NewNop(), @@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) { }, } plan.SetID(primitive.NewObjectID()) - plan.SetOrganizationRef(orgRef) + plan.OrganizationRef = &orgRef service := NewService( zap.NewNop(), @@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) { }, } plan.SetID(primitive.NewObjectID()) - plan.SetOrganizationRef(orgRef) + plan.OrganizationRef = &orgRef result := &types.CalculationResult{ Lines: []*feesv1.DerivedPostingLine{ @@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) { }, } plan.SetID(primitive.NewObjectID()) - plan.SetOrganizationRef(orgRef) + plan.OrganizationRef = &orgRef fakeOracle := &oracleclient.Fake{ LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) { @@ -452,7 +452,7 @@ func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.O if s.plan == nil { return nil, storage.ErrFeePlanNotFound } - if s.plan.GetOrganizationRef() != orgRef { + if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) { return nil, storage.ErrFeePlanNotFound } if !s.plan.Active { diff --git a/api/billing/fees/storage/model/plan.go b/api/billing/fees/storage/model/plan.go index 7a59492..22a76e5 100644 --- a/api/billing/fees/storage/model/plan.go +++ b/api/billing/fees/storage/model/plan.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/bson/primitive" ) const ( @@ -25,14 +26,14 @@ const ( // FeePlan describes a collection of fee rules for an organisation. type FeePlan struct { - storable.Base `bson:",inline" json:",inline"` - model.OrganizationBoundBase `bson:",inline" json:",inline"` - model.Describable `bson:",inline" json:",inline"` - Active bool `bson:"active" json:"active"` - EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` - EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` - Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"` - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + storable.Base `bson:",inline" json:",inline"` + model.Describable `bson:",inline" json:",inline"` + OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` + Active bool `bson:"active" json:"active"` + EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` + EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` + Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` } // Collection implements storable.Storable. diff --git a/api/payments/orchestrator/config.yml b/api/payments/orchestrator/config.yml index 06ae682..57f2d60 100644 --- a/api/payments/orchestrator/config.yml +++ b/api/payments/orchestrator/config.yml @@ -61,3 +61,6 @@ card_gateways: monetix: funding_address: "wallet_funding_monetix" fee_address: "wallet_fee_monetix" + +fee_ledger_accounts: + monetix: "ledger:fees:monetix" diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 54ae625..7d086fa 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -46,6 +46,7 @@ type config struct { Gateway clientConfig `yaml:"gateway"` Oracle clientConfig `yaml:"oracle"` CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"` + FeeAccounts map[string]string `yaml:"fee_ledger_accounts"` } type clientConfig struct { @@ -159,6 +160,9 @@ func (i *Imp) Start() error { if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 { opts = append(opts, orchestrator.WithCardGatewayRoutes(routes)) } + if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 { + opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts)) + } return orchestrator.NewService(logger, repo, opts...), nil } @@ -323,3 +327,19 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or } return result } + +func buildFeeLedgerAccounts(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + result := make(map[string]string, len(src)) + for key, account := range src { + k := strings.ToLower(strings.TrimSpace(key)) + v := strings.TrimSpace(account) + if k == "" || v == "" { + continue + } + result[k] = v + } + return result +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go index 9d6625a..c2e4f40 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/helpers.go @@ -383,6 +383,22 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic return breakdown } +func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine { + if account == "" || len(lines) == 0 { + return lines + } + for _, line := range lines { + if line == nil { + continue + } + if strings.TrimSpace(line.GetLedgerAccountRef()) != "" { + continue + } + line.LedgerAccountRef = account + } + return lines +} + func moneyEquals(a, b *moneyv1.Money) bool { if a == nil || b == nil { return false diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index 4b4d0da..fedd9a3 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -113,6 +113,24 @@ func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option { } } +// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees. +func WithFeeLedgerAccounts(routes map[string]string) Option { + return func(s *Service) { + if len(routes) == 0 { + return + } + s.deps.feeLedgerAccounts = make(map[string]string, len(routes)) + for k, v := range routes { + key := strings.ToLower(strings.TrimSpace(k)) + val := strings.TrimSpace(v) + if key == "" || val == "" { + continue + } + s.deps.feeLedgerAccounts[key] = val + } + } +} + // WithClock overrides the default clock. func WithClock(clock clockpkg.Clock) Option { return func(s *Service) { diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go index c647ca9..def97d2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go @@ -52,7 +52,9 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc } else if amount != nil { feeCurrency = amount.GetCurrency() } - feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency) + feeLines := cloneFeeLines(feeQuote.GetLines()) + s.assignFeeLedgerAccounts(intent, feeLines) + feeTotal := extractFeeTotal(feeLines, feeCurrency) var networkFee *chainv1.EstimateTransferFeeResponse if shouldEstimateNetworkFee(intent) { @@ -69,7 +71,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc DebitAmount: debitAmount, ExpectedSettlementAmount: settlementAmount, ExpectedFeeTotal: feeTotal, - FeeLines: cloneFeeLines(feeQuote.GetLines()), + FeeLines: feeLines, FeeRules: cloneFeeRules(feeQuote.GetApplied()), FxQuote: fxQuote, NetworkFee: networkFee, @@ -207,3 +209,53 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches } return quoteToProto(quote), nil } + +func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string { + if intent == nil || len(s.deps.feeLedgerAccounts) == 0 { + return "" + } + + key := s.gatewayKeyFromIntent(intent) + if key == "" { + return "" + } + return strings.TrimSpace(s.deps.feeLedgerAccounts[key]) +} + +func (s *Service) assignFeeLedgerAccounts(intent *orchestratorv1.PaymentIntent, lines []*feesv1.DerivedPostingLine) { + account := s.feeLedgerAccountForIntent(intent) + key := s.gatewayKeyFromIntent(intent) + + missing := 0 + for _, line := range lines { + if line == nil { + continue + } + if strings.TrimSpace(line.GetLedgerAccountRef()) == "" { + missing++ + } + } + if missing == 0 { + return + } + + if account == "" { + s.logger.Debug("no fee ledger account mapping found", zap.String("gateway", key), zap.Int("missing_lines", missing)) + return + } + assignLedgerAccounts(lines, account) + s.logger.Debug("applied fee ledger account mapping", zap.String("gateway", key), zap.String("ledger_account", account), zap.Int("lines", missing)) +} + +func (s *Service) gatewayKeyFromIntent(intent *orchestratorv1.PaymentIntent) string { + if intent == nil { + return "" + } + key := strings.TrimSpace(intent.GetAttributes()["gateway"]) + if key == "" { + if dest := intent.GetDestination(); dest != nil && dest.GetCard() != nil { + key = defaultCardGateway + } + } + return strings.ToLower(key) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 9d29218..2aa095b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -41,12 +41,13 @@ type Service struct { } type serviceDependencies struct { - fees feesDependency - ledger ledgerDependency - gateway gatewayDependency - oracle oracleDependency - mntx mntxDependency - cardRoutes map[string]CardGatewayRoute + fees feesDependency + ledger ledgerDependency + gateway gatewayDependency + oracle oracleDependency + mntx mntxDependency + cardRoutes map[string]CardGatewayRoute + feeLedgerAccounts map[string]string } type handlerSet struct {