477 lines
13 KiB
Go
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
|
|
}
|