Files
sendico/api/billing/fees/internal/service/fees/service_test.go
Stephan D 62a6631b9a
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
service backend
2025-11-07 18:35:26 +01:00

477 lines
13 KiB
Go

package fees
import (
"context"
"testing"
"time"
"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.SetOrganizationRef(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.SetOrganizationRef(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.SetOrganizationRef(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),
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
result := &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.SetOrganizationRef(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
}
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 s.plan == nil {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.GetOrganizationRef() != orgRef {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.Active {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
return nil, storage.ErrFeePlanNotFound
}
return s.plan, 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 *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) (*CalculationResult, error) {
s.called = true
s.gotPlan = plan
s.bookedAt = bookedAt
if s.err != nil {
return nil, s.err
}
return s.result, nil
}