450 lines
12 KiB
Go
450 lines
12 KiB
Go
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 "eCalculator{
|
|
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
|
|
}
|
|
}
|