service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information populated at build time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
// Create initialises a version.Printer with the build details for this service.
func Create() version.Printer {
info := version.Info{
Program: "Sendico Billing Fees Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -0,0 +1,163 @@
package serverimp
import (
"context"
"os"
"strings"
"time"
"github.com/tech/sendico/billing/fees/internal/service/fees"
"github.com/tech/sendico/billing/fees/storage"
mongostorage "github.com/tech/sendico/billing/fees/storage/mongo"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
oracleClient oracleclient.Client
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Oracle OracleConfig `yaml:"oracle"`
}
type OracleConfig struct {
Address string `yaml:"address"`
DialTimeoutSecs int `yaml:"dial_timeout_seconds"`
CallTimeoutSecs int `yaml:"call_timeout_seconds"`
InsecureTransport bool `yaml:"insecure"`
}
func (c OracleConfig) dialTimeout() time.Duration {
if c.DialTimeoutSecs <= 0 {
return 5 * time.Second
}
return time.Duration(c.DialTimeoutSecs) * time.Second
}
func (c OracleConfig) callTimeout() time.Duration {
if c.CallTimeoutSecs <= 0 {
return 3 * time.Second
}
return time.Duration(c.CallTimeoutSecs) * time.Second
}
// Create initialises the billing fees server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
}
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
var oracleClient oracleclient.Client
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
defer cancel()
oc, err := oracleclient.New(dialCtx, oracleclient.Config{
Address: addr,
DialTimeout: cfg.Oracle.dialTimeout(),
CallTimeout: cfg.Oracle.callTimeout(),
Insecure: cfg.Oracle.InsecureTransport,
})
if err != nil {
i.logger.Warn("failed to initialise oracle client", zap.String("address", addr), zap.Error(err))
} else {
oracleClient = oc
i.oracleClient = oc
i.logger.Info("connected to oracle service", zap.String("address", addr))
}
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
opts := []fees.Option{}
if oracleClient != nil {
opts = append(opts, fees.WithOracleClient(oracleClient))
}
return fees.NewService(logger, repo, producer, opts...), nil
}
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{Config: &grpcapp.Config{}}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50060",
EnableReflection: true,
EnableHealth: true,
}
}
return cfg, nil
}

View File

@@ -0,0 +1,12 @@
package server
import (
serverimp "github.com/tech/sendico/billing/fees/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
// Create constructs the billing fees server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,449 @@
package fees
import (
"context"
"errors"
"math/big"
"sort"
"strconv"
"strings"
"time"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
dmath "github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
type Calculator interface {
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
}
// CalculationResult contains derived fee lines and audit metadata.
type CalculationResult struct {
Lines []*feesv1.DerivedPostingLine
Applied []*feesv1.AppliedRule
FxUsed *feesv1.FXUsed
}
// quoteCalculator is the default Calculator implementation.
type fxOracle interface {
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
}
type quoteCalculator struct {
logger mlogger.Logger
oracle fxOracle
}
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
return &quoteCalculator{
logger: logger.Named("calculator"),
oracle: oracle,
}
}
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
if plan == nil {
return nil, merrors.InvalidArgument("plan is required")
}
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
trigger := convertTrigger(intent.GetTrigger())
if trigger == model.TriggerUnspecified {
return nil, merrors.InvalidArgument("unsupported trigger")
}
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
if err != nil {
return nil, merrors.InvalidArgument("invalid base amount")
}
if baseAmount.Sign() < 0 {
return nil, merrors.InvalidArgument("base amount cannot be negative")
}
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
rules := make([]model.FeeRule, len(plan.Rules))
copy(rules, plan.Rules)
sort.SliceStable(rules, func(i, j int) bool {
if rules[i].Priority == rules[j].Priority {
return rules[i].RuleID < rules[j].RuleID
}
return rules[i].Priority < rules[j].Priority
})
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
applied := make([]*feesv1.AppliedRule, 0, len(rules))
planID := ""
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
planID = planRef.Hex()
}
for _, rule := range rules {
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
continue
}
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 {
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
}
continue
}
if amount.Sign() == 0 {
continue
}
currency := intent.GetBaseAmount().GetCurrency()
if override := strings.TrimSpace(rule.Currency); override != "" {
currency = override
}
entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
}
meta := map[string]string{
"fee_rule_id": rule.RuleID,
}
if planID != "" {
meta["fee_plan_id"] = planID
}
if rule.Metadata != nil {
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
meta["tax_code"] = taxCode
}
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
meta["tax_rate"] = taxRate
}
}
lines = append(lines, &feesv1.DerivedPostingLine{
LedgerAccountRef: ledgerAccountRef,
Money: &moneyv1.Money{
Amount: dmath.FormatRat(amount, scale),
Currency: currency,
},
LineType: mapLineType(rule.LineType),
Side: entrySide,
Meta: meta,
})
applied = append(applied, &feesv1.AppliedRule{
RuleId: rule.RuleID,
RuleVersion: planID,
Formula: rule.Formula,
Rounding: mapRoundingMode(rule.Rounding),
TaxCode: metadataValue(rule.Metadata, "tax_code"),
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
Parameters: cloneStringMap(rule.Metadata),
})
}
var fxUsed *feesv1.FXUsed
if trigger == model.TriggerFXConversion && c.oracle != nil {
fxUsed = c.buildFxUsed(ctx, intent)
}
return &CalculationResult{
Lines: lines,
Applied: applied,
FxUsed: fxUsed,
}, nil
}
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
scale, err := resolveRuleScale(rule, baseScale)
if err != nil {
return nil, 0, err
}
result := new(big.Rat)
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
percentageRat, perr := dmath.RatFromString(percentage)
if perr != nil {
return nil, 0, merrors.InvalidArgument("invalid percentage")
}
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
}
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
fixedRat, ferr := dmath.RatFromString(fixed)
if ferr != nil {
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
}
result = dmath.AddRat(result, fixedRat)
}
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
minRat, merr := dmath.RatFromString(minStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
}
if dmath.CmpRat(result, minRat) < 0 {
result = new(big.Rat).Set(minRat)
}
}
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
maxRat, merr := dmath.RatFromString(maxStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
}
if dmath.CmpRat(result, maxRat) > 0 {
result = new(big.Rat).Set(maxRat)
}
}
if result.Sign() < 0 {
result = new(big.Rat).Abs(result)
}
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
if rerr != nil {
return nil, 0, rerr
}
return rounded, scale, nil
}
const (
attrFxBaseCurrency = "fx_base_currency"
attrFxQuoteCurrency = "fx_quote_currency"
attrFxProvider = "fx_provider"
attrFxSide = "fx_side"
attrFxRateOverride = "fx_rate"
)
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
if intent == nil || c.oracle == nil {
return nil
}
attrs := intent.GetAttributes()
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
if base == "" || quote == "" {
return nil
}
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
provider := strings.TrimSpace(attrs[attrFxProvider])
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
Meta: oracleclient.RequestMeta{},
Pair: pair,
Provider: provider,
})
if err != nil {
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
return nil
}
if snapshot == nil {
return nil
}
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
if rateValue == "" {
rateValue = snapshot.Mid
}
if rateValue == "" {
rateValue = snapshot.Ask
}
if rateValue == "" {
rateValue = snapshot.Bid
}
return &feesv1.FXUsed{
Pair: pair,
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
Rate: &moneyv1.Decimal{Value: rateValue},
AsofUnixMs: snapshot.AsOf.UnixMilli(),
Provider: snapshot.Provider,
RateRef: snapshot.RateRef,
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
}
}
func parseFxSide(value string) fxv1.Side {
switch strings.ToLower(value) {
case "buy_base", "buy_base_sell_quote", "buy":
return fxv1.Side_BUY_BASE_SELL_QUOTE
case "sell_base", "sell_base_buy_quote", "sell":
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func inferScale(amount string) uint32 {
value := strings.TrimSpace(amount)
if value == "" {
return 0
}
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
value = value[:idx]
}
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
value = value[1:]
}
if dot := strings.IndexByte(value, '.'); dot >= 0 {
return uint32(len(value[dot+1:]))
}
return 0
}
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
if rule.Trigger != trigger {
return false
}
if rule.EffectiveFrom.After(bookedAt) {
return false
}
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
return false
}
return ruleMatchesAttributes(rule, attributes)
}
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
if rule.Metadata != nil {
for _, field := range []string{"scale", "decimals", "precision"} {
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
return parseScale(field, value)
}
}
}
return fallback, nil
}
func parseScale(field, value string) (uint32, error) {
clean := strings.TrimSpace(value)
if clean == "" {
return 0, merrors.InvalidArgument(field + " is empty")
}
parsed, err := strconv.ParseUint(clean, 10, 32)
if err != nil {
return 0, merrors.InvalidArgument("invalid " + field + " value")
}
return uint32(parsed), nil
}
func metadataValue(meta map[string]string, key string) string {
if meta == nil {
return ""
}
return strings.TrimSpace(meta[key])
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
cloned := make(map[string]string, len(src))
for k, v := range src {
cloned[k] = v
}
return cloned
}
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
if len(rule.AppliesTo) == 0 {
return true
}
for key, value := range rule.AppliesTo {
if attributes == nil {
return false
}
if attrValue, ok := attributes[key]; !ok || attrValue != value {
return false
}
}
return true
}
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:
return model.TriggerRefund
case feesv1.Trigger_TRIGGER_DISPUTE:
return model.TriggerDispute
case feesv1.Trigger_TRIGGER_PAYOUT:
return model.TriggerPayout
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
return model.TriggerFXConversion
default:
return model.TriggerUnspecified
}
}
func mapLineType(lineType string) accountingv1.PostingLineType {
switch strings.ToLower(lineType) {
case "tax":
return accountingv1.PostingLineType_POSTING_LINE_TAX
case "spread":
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case "reversal":
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_FEE
}
}
func mapEntrySide(entrySide string) accountingv1.EntrySide {
switch strings.ToLower(entrySide) {
case "debit":
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case "credit":
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func toDecimalRounding(mode string) dmath.RoundingMode {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "half_up":
return dmath.RoundingModeHalfUp
case "down":
return dmath.RoundingModeDown
case "half_even", "bankers":
return dmath.RoundingModeHalfEven
default:
return dmath.RoundingModeHalfEven
}
}
func mapRoundingMode(mode string) moneyv1.RoundingMode {
switch strings.ToLower(mode) {
case "half_up":
return moneyv1.RoundingMode_ROUND_HALF_UP
case "down":
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
}
}

View File

@@ -0,0 +1,71 @@
package fees
import (
"strconv"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
metricsOnce sync.Once
quoteRequestsTotal *prometheus.CounterVec
quoteLatency *prometheus.HistogramVec
)
func initMetrics() {
metricsOnce.Do(func() {
quoteRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: "billing",
Subsystem: "fees",
Name: "requests_total",
Help: "Total number of fee service requests processed.",
},
[]string{"call", "trigger", "status", "fx_used"},
)
quoteLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "billing",
Subsystem: "fees",
Name: "request_latency_seconds",
Help: "Latency of fee service requests.",
Buckets: prometheus.DefBuckets,
},
[]string{"call", "trigger", "status", "fx_used"},
)
})
}
func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxUsed bool, took time.Duration) {
triggerLabel := trigger.String()
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
triggerLabel = "TRIGGER_UNSPECIFIED"
}
fxLabel := strconv.FormatBool(fxUsed)
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
}
func statusFromError(err error) string {
if err == nil {
return "success"
}
st, ok := status.FromError(err)
if !ok {
return "error"
}
code := st.Code()
if code == codes.OK {
return "success"
}
return strings.ToLower(code.String())
}

View File

@@ -0,0 +1,37 @@
package fees
import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
clockpkg "github.com/tech/sendico/pkg/clock"
)
// Option configures a Service instance.
type Option func(*Service)
// WithClock sets a custom clock implementation.
func WithClock(clock clockpkg.Clock) Option {
return func(s *Service) {
if clock != nil {
s.clock = clock
}
}
}
// WithCalculator sets a custom calculator implementation.
func WithCalculator(calculator Calculator) Option {
return func(s *Service) {
if calculator != nil {
s.calculator = calculator
}
}
}
// WithOracleClient wires an FX oracle client for FX trigger evaluations.
func WithOracleClient(oracle oracleclient.Client) Option {
return func(s *Service) {
s.oracle = oracle
if qc, ok := s.calculator.(*quoteCalculator); ok {
qc.oracle = oracle
}
}
}

View File

@@ -0,0 +1,322 @@
package fees
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
"github.com/tech/sendico/billing/fees/storage"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/api/routers"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/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/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer msg.Producer
clock clockpkg.Clock
calculator Calculator
oracle oracleclient.Client
feesv1.UnimplementedFeeEngineServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("fees"),
storage: repo,
producer: producer,
clock: clockpkg.NewSystem(),
}
initMetrics()
for _, opt := range opts {
opt(svc)
}
if svc.clock == nil {
svc.clock = clockpkg.NewSystem()
}
if svc.calculator == nil {
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
}
return svc
}
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
feesv1.RegisterFeeEngineServer(reg, s)
})
}
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil {
trigger = req.GetIntent().GetTrigger()
}
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
}
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
}()
if err = s.validateQuoteRequest(req); err != nil {
return nil, err
}
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
lines, applied, fx, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
if computeErr != nil {
err = computeErr
return nil, err
}
resp = &feesv1.QuoteFeesResponse{
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
Lines: lines,
Applied: applied,
FxUsed: fx,
}
return resp, nil
}
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil {
trigger = req.GetIntent().GetTrigger()
}
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
}
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
}()
if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err
}
now := s.clock.Now()
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
if computeErr != nil {
err = computeErr
return nil, err
}
ttl := req.GetTtlMs()
if ttl <= 0 {
ttl = 60000
}
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(),
Intent: req.GetIntent(),
ExpiresAtUnixMs: expiresAt.UnixMilli(),
Trace: req.GetMeta().GetTrace(),
}
var token string
if token, err = encodeTokenPayload(payload); err != nil {
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err
}
resp = &feesv1.PrecomputeFeesResponse{
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
FeeQuoteToken: token,
ExpiresAt: timestamppb.New(expiresAt),
Lines: lines,
Applied: applied,
FxUsed: fx,
}
return resp, nil
}
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
if err == nil && resp != nil {
if !resp.GetValid() {
statusLabel = "invalid"
}
fxUsed = resp.GetFxUsed() != nil
if resp.GetIntent() != nil {
trigger = resp.GetIntent().GetTrigger()
}
}
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
}()
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err
}
now := s.clock.Now()
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil {
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
trigger = payload.Intent.GetTrigger()
if now.UnixMilli() > payload.ExpiresAtUnixMs {
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil
}
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil {
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
if computeErr != nil {
err = computeErr
return nil, err
}
resp = &feesv1.ValidateFeeTokenResponse{
Meta: &feesv1.ResponseMeta{Trace: payload.Trace},
Valid: true,
Intent: payload.Intent,
Lines: lines,
Applied: applied,
FxUsed: fx,
}
return resp, nil
}
func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
}
if req.GetIntent() == nil {
return status.Error(codes.InvalidArgument, "intent is required")
}
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
return status.Error(codes.InvalidArgument, "intent.trigger is required")
}
if req.GetIntent().GetBaseAmount() == nil {
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
}
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
}
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
}
return nil
}
func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
}
func (s *Service) computeQuote(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
return s.computeQuoteWithTime(ctx, orgRef, intent, overrides, trace, s.clock.Now())
}
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
bookedAt := now
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
bookedAt = intent.GetBookedAt().AsTime()
}
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
}
s.logger.Warn("failed to load active fee plan", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
}
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
if calcErr != nil {
if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
}
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
}
return result.Lines, result.Applied, result.FxUsed, nil
}
type feeQuoteTokenPayload struct {
OrganizationRef string `json:"organization_ref"`
Intent *feesv1.Intent `json:"intent"`
ExpiresAtUnixMs int64 `json:"expires_at_unix_ms"`
Trace *tracev1.TraceContext `json:"trace,omitempty"`
}
func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", merrors.Internal("fees: failed to serialize token payload")
}
return base64.StdEncoding.EncodeToString(data), nil
}
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
var payload feeQuoteTokenPayload
data, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return payload, merrors.InvalidArgument("fees: invalid token encoding")
}
if err := json.Unmarshal(data, &payload); err != nil {
return payload, merrors.InvalidArgument("fees: invalid token payload")
}
return payload, nil
}

View File

@@ -0,0 +1,476 @@
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
}