package fees import ( "context" "errors" "testing" "time" "github.com/tech/sendico/billing/fees/internal/service/fees/types" "github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage/model" oracleclient "github.com/tech/sendico/fx/oracle/client" me "github.com/tech/sendico/pkg/messaging/envelope" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" "google.golang.org/protobuf/types/known/timestamppb" ) func TestQuoteFees_ComputesDerivedLines(t *testing.T) { t.Helper() now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC) orgRef := primitive.NewObjectID() plan := &model.FeePlan{ Active: true, EffectiveFrom: now.Add(-time.Hour), Rules: []model.FeeRule{ { RuleID: "capture_default", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.029", FixedAmount: "0.30", LedgerAccountRef: "acct:fees", LineType: "fee", EntrySide: "credit", Rounding: "half_up", Metadata: map[string]string{ "scale": "2", "tax_code": "VAT", "tax_rate": "0.20", }, EffectiveFrom: now.Add(-time.Hour), }, }, } plan.SetID(primitive.NewObjectID()) plan.OrganizationRef = &orgRef service := NewService( zap.NewNop(), &stubRepository{plans: &stubPlansStore{plan: plan}}, noopProducer{}, WithClock(fixedClock{now: now}), ) req := &feesv1.QuoteFeesRequest{ Meta: &feesv1.RequestMeta{ OrganizationRef: orgRef.Hex(), Trace: &tracev1.TraceContext{ TraceRef: "trace-capture", }, }, Intent: &feesv1.Intent{ Trigger: feesv1.Trigger_TRIGGER_CAPTURE, BaseAmount: &moneyv1.Money{ Amount: "100.00", Currency: "USD", }, BookedAt: timestamppb.New(now), Attributes: map[string]string{"channel": "card"}, }, } resp, err := service.QuoteFees(context.Background(), req) if err != nil { t.Fatalf("QuoteFees returned error: %v", err) } if resp.GetMeta().GetTrace().GetTraceRef() != "trace-capture" { t.Fatalf("expected trace_ref to round-trip, got %q", resp.GetMeta().GetTrace().GetTraceRef()) } if len(resp.GetLines()) != 1 { t.Fatalf("expected 1 derived line, got %d", len(resp.GetLines())) } line := resp.GetLines()[0] if got := line.GetMoney().GetAmount(); got != "3.20" { t.Fatalf("expected fee amount 3.20, got %s", got) } if line.GetMoney().GetCurrency() != "USD" { t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency()) } if line.GetLedgerAccountRef() != "acct:fees" { t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef()) } if meta := line.GetMeta(); meta["fee_rule_id"] != "capture_default" || meta["fee_plan_id"] != plan.GetID().Hex() || meta["tax_code"] != "VAT" { t.Fatalf("unexpected derived line metadata: %#v", meta) } if len(resp.GetApplied()) != 1 { t.Fatalf("expected 1 applied rule, got %d", len(resp.GetApplied())) } applied := resp.GetApplied()[0] if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" { t.Fatalf("applied rule metadata mismatch: %+v", applied) } if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP { t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding()) } if applied.GetParameters()["scale"] != "2" { t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters()) } } func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) { t.Helper() now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC) orgRef := primitive.NewObjectID() plan := &model.FeePlan{ Active: true, EffectiveFrom: now.Add(-24 * time.Hour), Rules: []model.FeeRule{ { RuleID: "base", Trigger: model.TriggerCapture, Priority: 1, Percentage: "0.10", LedgerAccountRef: "acct:base", Metadata: map[string]string{"scale": "2"}, Rounding: "half_even", EffectiveFrom: now.Add(-time.Hour), }, { RuleID: "future", Trigger: model.TriggerCapture, Priority: 2, Percentage: "0.50", LedgerAccountRef: "acct:future", Metadata: map[string]string{"scale": "2"}, Rounding: "half_even", EffectiveFrom: now.Add(time.Hour), }, { RuleID: "attr", Trigger: model.TriggerCapture, Priority: 3, Percentage: "0.30", LedgerAccountRef: "acct:attr", Metadata: map[string]string{"scale": "2"}, AppliesTo: map[string]string{"region": "eu"}, Rounding: "half_even", EffectiveFrom: now.Add(-time.Hour), }, }, } plan.SetID(primitive.NewObjectID()) plan.OrganizationRef = &orgRef service := NewService( zap.NewNop(), &stubRepository{plans: &stubPlansStore{plan: plan}}, noopProducer{}, WithClock(fixedClock{now: now}), ) req := &feesv1.QuoteFeesRequest{ Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()}, Intent: &feesv1.Intent{ Trigger: feesv1.Trigger_TRIGGER_CAPTURE, BaseAmount: &moneyv1.Money{ Amount: "50.00", Currency: "EUR", }, BookedAt: timestamppb.New(now), Attributes: map[string]string{"region": "us"}, }, } resp, err := service.QuoteFees(context.Background(), req) if err != nil { t.Fatalf("QuoteFees returned error: %v", err) } if len(resp.GetLines()) != 1 { t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines())) } line := resp.GetLines()[0] if line.GetLedgerAccountRef() != "acct:base" { t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef()) } if line.GetMoney().GetAmount() != "5.00" { t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount()) } } func TestQuoteFees_RoundingDown(t *testing.T) { t.Helper() now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC) orgRef := primitive.NewObjectID() plan := &model.FeePlan{ Active: true, EffectiveFrom: now.Add(-time.Hour), Rules: []model.FeeRule{ { RuleID: "round_down", Trigger: model.TriggerCapture, Priority: 1, FixedAmount: "0.015", LedgerAccountRef: "acct:round", Metadata: map[string]string{"scale": "2"}, Rounding: "down", EffectiveFrom: now.Add(-time.Hour), }, }, } plan.SetID(primitive.NewObjectID()) plan.OrganizationRef = &orgRef service := NewService( zap.NewNop(), &stubRepository{plans: &stubPlansStore{plan: plan}}, noopProducer{}, WithClock(fixedClock{now: now}), ) req := &feesv1.QuoteFeesRequest{ Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()}, Intent: &feesv1.Intent{ Trigger: feesv1.Trigger_TRIGGER_CAPTURE, BaseAmount: &moneyv1.Money{ Amount: "1.00", Currency: "USD", }, BookedAt: timestamppb.New(now), }, } resp, err := service.QuoteFees(context.Background(), req) if err != nil { t.Fatalf("QuoteFees returned error: %v", err) } if len(resp.GetLines()) != 1 { t.Fatalf("expected single derived line, got %d", len(resp.GetLines())) } if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" { t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount()) } } func TestQuoteFees_UsesInjectedCalculator(t *testing.T) { t.Helper() now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC) orgRef := primitive.NewObjectID() plan := &model.FeePlan{ Active: true, EffectiveFrom: now.Add(-time.Hour), Rules: []model.FeeRule{ { RuleID: "stub", Trigger: model.TriggerCapture, Priority: 1, Percentage: "0.01", LedgerAccountRef: "acct:stub", EffectiveFrom: now.Add(-time.Hour), }, }, } plan.SetID(primitive.NewObjectID()) plan.OrganizationRef = &orgRef result := &types.CalculationResult{ Lines: []*feesv1.DerivedPostingLine{ { LedgerAccountRef: "acct:stub", Money: &moneyv1.Money{ Amount: "1.23", Currency: "USD", }, }, }, Applied: []*feesv1.AppliedRule{ {RuleId: "stub"}, }, } calc := &stubCalculator{result: result} service := NewService( zap.NewNop(), &stubRepository{plans: &stubPlansStore{plan: plan}}, noopProducer{}, WithClock(fixedClock{now: now}), WithCalculator(calc), ) resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{ Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()}, Intent: &feesv1.Intent{ Trigger: feesv1.Trigger_TRIGGER_CAPTURE, BaseAmount: &moneyv1.Money{ Amount: "10.00", Currency: "USD", }, }, }) if err != nil { t.Fatalf("QuoteFees returned error: %v", err) } if !calc.called { t.Fatalf("expected calculator to be invoked") } if calc.gotPlan != plan { t.Fatalf("expected calculator to receive plan pointer") } if len(resp.GetLines()) != len(result.Lines) { t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines())) } if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" { t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef()) } } func TestQuoteFees_PopulatesFxUsed(t *testing.T) { t.Helper() now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC) orgRef := primitive.NewObjectID() plan := &model.FeePlan{ Active: true, EffectiveFrom: now.Add(-time.Hour), Rules: []model.FeeRule{ { RuleID: "fx_mark_up", Trigger: model.TriggerFXConversion, Priority: 1, Percentage: "0.03", LedgerAccountRef: "acct:fx", Metadata: map[string]string{"scale": "2"}, Rounding: "half_even", EffectiveFrom: now.Add(-time.Hour), }, }, } plan.SetID(primitive.NewObjectID()) plan.OrganizationRef = &orgRef fakeOracle := &oracleclient.Fake{ LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) { return &oracleclient.RateSnapshot{ Pair: req.Pair, Mid: "1.2300", SpreadBps: "12", Provider: "TestProvider", RateRef: "rate-ref-123", AsOf: now.Add(-2 * time.Minute), }, nil }, } service := NewService( zap.NewNop(), &stubRepository{plans: &stubPlansStore{plan: plan}}, noopProducer{}, WithClock(fixedClock{now: now}), WithOracleClient(fakeOracle), ) resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{ Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()}, Intent: &feesv1.Intent{ Trigger: feesv1.Trigger_TRIGGER_FX_CONVERSION, BaseAmount: &moneyv1.Money{ Amount: "100.00", Currency: "USD", }, Attributes: map[string]string{ "fx_base_currency": "USD", "fx_quote_currency": "EUR", "fx_provider": "TestProvider", "fx_side": "buy_base", }, }, }) if err != nil { t.Fatalf("QuoteFees returned error: %v", err) } if resp.GetFxUsed() == nil { t.Fatalf("expected FxUsed to be populated") } fx := resp.GetFxUsed() if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" { t.Fatalf("unexpected FxUsed payload: %+v", fx) } if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" { t.Fatalf("unexpected currency pair: %+v", fx.GetPair()) } } type stubRepository struct { plans storage.PlansStore } func (s *stubRepository) Ping(context.Context) error { return nil } func (s *stubRepository) Plans() storage.PlansStore { return s.plans } type stubPlansStore struct { plan *model.FeePlan globalPlan *model.FeePlan } func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error { return nil } func (s *stubPlansStore) Update(context.Context, *model.FeePlan) error { return nil } func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) { return nil, storage.ErrFeePlanNotFound } func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { if !orgRef.IsZero() { if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil { return plan, nil } else if !errors.Is(err, storage.ErrFeePlanNotFound) { return nil, err } } return s.FindActiveGlobalPlan(context.Background(), at) } func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { if s.plan == nil { return nil, storage.ErrFeePlanNotFound } if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) { return nil, storage.ErrFeePlanNotFound } if !s.plan.Active { return nil, storage.ErrFeePlanNotFound } if s.plan.EffectiveFrom.After(at) { return nil, storage.ErrFeePlanNotFound } if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) { return nil, storage.ErrFeePlanNotFound } return s.plan, nil } func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) { if s.globalPlan == nil { return nil, storage.ErrFeePlanNotFound } if !s.globalPlan.Active { return nil, storage.ErrFeePlanNotFound } if s.globalPlan.EffectiveFrom.After(at) { return nil, storage.ErrFeePlanNotFound } if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) { return nil, storage.ErrFeePlanNotFound } return s.globalPlan, nil } type noopProducer struct{} func (noopProducer) SendMessage(me.Envelope) error { return nil } type fixedClock struct { now time.Time } func (f fixedClock) Now() time.Time { return f.now } type stubCalculator struct { result *types.CalculationResult err error called bool gotPlan *model.FeePlan bookedAt time.Time } func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) { s.called = true s.gotPlan = plan s.bookedAt = bookedAt if s.err != nil { return nil, s.err } return s.result, nil }