quotation bff
This commit is contained in:
@@ -44,8 +44,8 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
|
||||
@@ -176,15 +176,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
|
||||
@@ -2,448 +2,16 @@ package fees
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||
"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.
|
||||
// Implementation lives under internal/service/fees/internal/calculator.
|
||||
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
|
||||
}
|
||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fxOracle captures the oracle dependency for FX conversions.
|
||||
type fxOracle interface {
|
||||
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||
}
|
||||
|
||||
// New constructs the default calculator implementation.
|
||||
func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return "eCalculator{
|
||||
logger: logger.Named("calculator"),
|
||||
oracle: oracle,
|
||||
}
|
||||
}
|
||||
|
||||
type quoteCalculator struct {
|
||||
logger mlogger.Logger
|
||||
oracle fxOracle
|
||||
}
|
||||
|
||||
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.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 &types.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 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package resolver
|
||||
|
||||
import "github.com/tech/sendico/pkg/merrors"
|
||||
|
||||
var (
|
||||
// ErrNoFeeRuleFound indicates that no applicable rule exists for the given context.
|
||||
ErrNoFeeRuleFound = merrors.ErrNoData
|
||||
// ErrConflictingFeeRules indicates multiple rules share the same highest priority.
|
||||
ErrConflictingFeeRules = merrors.ErrDataConflict
|
||||
)
|
||||
148
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
148
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type planFinder interface {
|
||||
FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||
FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error)
|
||||
}
|
||||
|
||||
type feeResolver struct {
|
||||
plans storage.PlansStore
|
||||
finder planFinder
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
||||
var finder planFinder
|
||||
if pf, ok := plans.(planFinder); ok {
|
||||
finder = pf
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &feeResolver{
|
||||
plans: plans,
|
||||
finder: finder,
|
||||
logger: logger.Named("resolver"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||
if r.plans == nil {
|
||||
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
||||
}
|
||||
|
||||
// Try org-specific first if provided.
|
||||
if orgID != nil && !orgID.IsZero() {
|
||||
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
|
||||
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
|
||||
return plan, rule, nil
|
||||
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex()))
|
||||
return nil, nil, selErr
|
||||
}
|
||||
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex()))
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
plan, err := r.getGlobalPlan(ctx, at)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
|
||||
}
|
||||
r.logger.Warn("failed resolving global fee plan", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rule, err := selectRule(plan, trigger, at, attrs)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("failed selecting rule in global plan", zap.Error(err))
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return plan, rule, nil
|
||||
}
|
||||
|
||||
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if r.finder != nil {
|
||||
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
|
||||
}
|
||||
return r.plans.GetActivePlan(ctx, orgRef, at)
|
||||
}
|
||||
|
||||
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
if r.finder != nil {
|
||||
return r.finder.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
// Treat zero ObjectID as global in legacy path.
|
||||
return r.plans.GetActivePlan(ctx, primitive.NilObjectID, at)
|
||||
}
|
||||
|
||||
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
|
||||
if plan == nil {
|
||||
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||
}
|
||||
|
||||
var selected *model.FeeRule
|
||||
var highestPriority int
|
||||
for _, rule := range plan.Rules {
|
||||
if rule.Trigger != trigger {
|
||||
continue
|
||||
}
|
||||
if rule.EffectiveFrom.After(at) {
|
||||
continue
|
||||
}
|
||||
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
|
||||
continue
|
||||
}
|
||||
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
||||
continue
|
||||
}
|
||||
|
||||
if selected == nil || rule.Priority > highestPriority {
|
||||
copy := rule
|
||||
selected = ©
|
||||
highestPriority = rule.Priority
|
||||
continue
|
||||
}
|
||||
if rule.Priority == highestPriority {
|
||||
return nil, merrors.DataConflict("fees: conflicting fee rules")
|
||||
}
|
||||
}
|
||||
|
||||
if selected == nil {
|
||||
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
|
||||
if len(appliesTo) == 0 {
|
||||
return true
|
||||
}
|
||||
for key, value := range appliesTo {
|
||||
if attrs == nil {
|
||||
return false
|
||||
}
|
||||
if attrs[key] != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
globalPlan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
orgA := primitive.NewObjectID()
|
||||
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||
}
|
||||
if !plan.GetOrganizationRef().IsZero() {
|
||||
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex())
|
||||
}
|
||||
if rule.RuleID != "global_capture" {
|
||||
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := primitive.NewObjectID()
|
||||
globalPlan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
orgPlan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
orgPlan.SetOrganizationRef(org)
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected org plan rule, got error: %v", err)
|
||||
}
|
||||
if rule.RuleID != "org_capture" {
|
||||
t.Fatalf("expected org rule, got %s", rule.RuleID)
|
||||
}
|
||||
|
||||
otherOrg := primitive.NewObjectID()
|
||||
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected global fallback for other org, got error: %v", err)
|
||||
}
|
||||
if rule.RuleID != "global_capture" {
|
||||
t.Fatalf("expected global rule, got %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := primitive.NewObjectID()
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "low", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
plan.SetOrganizationRef(org)
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected rule resolution, got error: %v", err)
|
||||
}
|
||||
if rule.RuleID != "high" {
|
||||
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
|
||||
}
|
||||
|
||||
plan.Rules = append(plan.Rules, model.FeeRule{
|
||||
RuleID: "conflict",
|
||||
Trigger: model.TriggerCapture,
|
||||
Priority: 200,
|
||||
Percentage: "0.02",
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
})
|
||||
|
||||
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, ErrConflictingFeeRules) {
|
||||
t.Fatalf("expected conflicting fee rules error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := primitive.NewObjectID()
|
||||
past := now.Add(-24 * time.Hour)
|
||||
future := now.Add(24 * time.Hour)
|
||||
|
||||
orgPlan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: past,
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
|
||||
},
|
||||
}
|
||||
orgPlan.SetOrganizationRef(org)
|
||||
|
||||
globalPlan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: past,
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "current", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &future},
|
||||
},
|
||||
}
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{orgPlan, globalPlan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||
}
|
||||
if rule.RuleID != "current" {
|
||||
t.Fatalf("expected current global rule, got %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "card", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", AppliesTo: map[string]string{"paymentMethod": "card"}, EffectiveFrom: now.Add(-time.Hour)},
|
||||
{RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
_, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "card"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected card rule, got error: %v", err)
|
||||
}
|
||||
if rule.RuleID != "card" {
|
||||
t.Fatalf("expected card rule, got %s", rule.RuleID)
|
||||
}
|
||||
|
||||
_, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "bank"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected default rule, got error: %v", err)
|
||||
}
|
||||
if rule.RuleID != "default" {
|
||||
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
if _, _, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerRefund, now, nil); !errors.Is(err, ErrNoFeeRuleFound) {
|
||||
t.Fatalf("expected ErrNoFeeRuleFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := primitive.NewObjectID()
|
||||
p1 := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
p1.SetOrganizationRef(org)
|
||||
p2 := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-30 * time.Minute),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
p2.SetOrganizationRef(org)
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
|
||||
t.Fatalf("expected conflicting plans error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type memoryPlansStore struct {
|
||||
plans []*model.FeePlan
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) Create(context.Context, *model.FeePlan) error { return nil }
|
||||
func (m *memoryPlansStore) Update(context.Context, *model.FeePlan) error { return nil }
|
||||
func (m *memoryPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if !orgRef.IsZero() {
|
||||
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
|
||||
return plan, nil
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return m.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || plan.GetOrganizationRef() != orgRef {
|
||||
continue
|
||||
}
|
||||
if !plan.Active {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveFrom.After(at) {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, plan)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || !plan.GetOrganizationRef().IsZero() {
|
||||
continue
|
||||
}
|
||||
if !plan.Active {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveFrom.After(at) {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, plan)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
var _ storage.PlansStore = (*memoryPlansStore)(nil)
|
||||
@@ -1,6 +1,7 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
)
|
||||
@@ -30,8 +31,18 @@ func WithCalculator(calculator Calculator) Option {
|
||||
func WithOracleClient(oracle oracleclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.oracle = oracle
|
||||
if qc, ok := s.calculator.(*quoteCalculator); ok {
|
||||
qc.oracle = oracle
|
||||
// Rebuild default calculator if none was injected.
|
||||
if s.calculator == nil {
|
||||
s.calculator = internalcalculator.New(s.logger, oracle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFeeResolver injects a custom fee resolver (useful for tests).
|
||||
func WithFeeResolver(r FeeResolver) Option {
|
||||
return func(s *Service) {
|
||||
if r != nil {
|
||||
s.resolver = r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// FeeResolver centralises plan/rule resolution with org override and global fallback.
|
||||
// Implementations live under the internal/resolver package.
|
||||
type FeeResolver interface {
|
||||
ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error)
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
@@ -32,6 +35,7 @@ type Service struct {
|
||||
clock clockpkg.Clock
|
||||
calculator Calculator
|
||||
oracle oracleclient.Client
|
||||
resolver FeeResolver
|
||||
feesv1.UnimplementedFeeEngineServer
|
||||
}
|
||||
|
||||
@@ -52,7 +56,10 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
svc.clock = clockpkg.NewSystem()
|
||||
}
|
||||
if svc.calculator == nil {
|
||||
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
|
||||
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
|
||||
}
|
||||
if svc.resolver == nil {
|
||||
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||
}
|
||||
|
||||
return svc
|
||||
@@ -273,15 +280,34 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
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")
|
||||
var orgPtr *primitive.ObjectID
|
||||
if !orgRef.IsZero() {
|
||||
orgPtr = &orgRef
|
||||
}
|
||||
|
||||
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
|
||||
case errors.Is(err, storage.ErrConflictingFeePlans):
|
||||
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee plans")
|
||||
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||
default:
|
||||
s.logger.Warn("failed to resolve fee rule", zap.Error(err))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
|
||||
}
|
||||
}
|
||||
|
||||
originalRules := plan.Rules
|
||||
plan.Rules = []model.FeeRule{*rule}
|
||||
defer func() {
|
||||
plan.Rules = originalRules
|
||||
}()
|
||||
|
||||
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
||||
if calcErr != nil {
|
||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
|
||||
@@ -2,9 +2,11 @@ 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"
|
||||
@@ -263,11 +265,21 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
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.SetOrganizationRef(orgRef)
|
||||
|
||||
result := &CalculationResult{
|
||||
result := &types.CalculationResult{
|
||||
Lines: []*feesv1.DerivedPostingLine{
|
||||
{
|
||||
LedgerAccountRef: "acct:stub",
|
||||
@@ -409,7 +421,8 @@ func (s *stubRepository) Plans() storage.PlansStore {
|
||||
}
|
||||
|
||||
type stubPlansStore struct {
|
||||
plan *model.FeePlan
|
||||
plan *model.FeePlan
|
||||
globalPlan *model.FeePlan
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
||||
@@ -425,6 +438,17 @@ func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePla
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -434,15 +458,31 @@ func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.Objec
|
||||
if !s.plan.Active {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
|
||||
if s.plan.EffectiveFrom.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
|
||||
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 {
|
||||
@@ -458,14 +498,14 @@ func (f fixedClock) Now() time.Time {
|
||||
}
|
||||
|
||||
type stubCalculator struct {
|
||||
result *CalculationResult
|
||||
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) (*CalculationResult, error) {
|
||||
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
|
||||
|
||||
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
)
|
||||
|
||||
// CalculationResult contains derived fee lines and audit metadata.
|
||||
type CalculationResult struct {
|
||||
Lines []*feesv1.DerivedPostingLine
|
||||
Applied []*feesv1.AppliedRule
|
||||
FxUsed *feesv1.FXUsed
|
||||
}
|
||||
@@ -42,21 +42,21 @@ func (*FeePlan) Collection() string {
|
||||
|
||||
// FeeRule represents a single pricing rule within a plan.
|
||||
type FeeRule struct {
|
||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||
Priority int `bson:"priority" json:"priority"`
|
||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||
Priority int `bson:"priority" json:"priority"`
|
||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
dmath "github.com/tech/sendico/pkg/decimal"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
@@ -53,6 +57,19 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Recommended index to speed up active-plan lookups (org/global + active + dates).
|
||||
activeIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: m.OrganizationRefField, Sort: ri.Asc},
|
||||
{Field: "active", Sort: ri.Asc},
|
||||
{Field: "effectiveFrom", Sort: ri.Asc},
|
||||
{Field: "effectiveTo", Sort: ri.Asc},
|
||||
},
|
||||
}
|
||||
if err := repo.CreateIndex(activeIndex); err != nil {
|
||||
logger.Warn("failed to ensure fee plan active index", zap.Error(err))
|
||||
}
|
||||
|
||||
return &plansStore{
|
||||
logger: logger.Named("plans"),
|
||||
repo: repo,
|
||||
@@ -60,9 +77,13 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
||||
}
|
||||
|
||||
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||
if plan == nil {
|
||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||
if err := validatePlan(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicateFeePlan
|
||||
@@ -77,6 +98,13 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
|
||||
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
||||
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
||||
}
|
||||
if err := validatePlan(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.repo.Update(ctx, plan); err != nil {
|
||||
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
||||
return err
|
||||
@@ -99,13 +127,42 @@ func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*mode
|
||||
}
|
||||
|
||||
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
|
||||
if orgRef.IsZero() {
|
||||
return p.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
|
||||
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at)
|
||||
if err == nil {
|
||||
return plan, nil
|
||||
}
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return p.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if orgRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
||||
}
|
||||
query := repository.Query().Filter(repository.OrgField(), orgRef)
|
||||
return p.findActivePlan(ctx, query, at)
|
||||
}
|
||||
|
||||
limit := int64(1)
|
||||
query := repository.Query().
|
||||
Filter(repository.OrgField(), orgRef).
|
||||
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
globalQuery := repository.Query().Or(
|
||||
repository.Exists(repository.OrgField(), false),
|
||||
repository.Query().Filter(repository.OrgField(), nil),
|
||||
)
|
||||
return p.findActivePlan(ctx, globalQuery, at)
|
||||
}
|
||||
|
||||
var _ storage.PlansStore = (*plansStore)(nil)
|
||||
|
||||
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
|
||||
limit := int64(2)
|
||||
query := orgQuery.
|
||||
Filter(repository.Field("active"), true).
|
||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
||||
Sort(repository.Field("effectiveFrom"), false).
|
||||
@@ -118,13 +175,13 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
||||
),
|
||||
)
|
||||
|
||||
var plan *model.FeePlan
|
||||
var plans []*model.FeePlan
|
||||
decoder := func(cursor *mongo.Cursor) error {
|
||||
target := &model.FeePlan{}
|
||||
if err := cursor.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
plan = target
|
||||
plans = append(plans, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -135,10 +192,127 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
if len(plans) == 0 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
return plan, nil
|
||||
if len(plans) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
return plans[0], nil
|
||||
}
|
||||
|
||||
var _ storage.PlansStore = (*plansStore)(nil)
|
||||
func validatePlan(plan *model.FeePlan) error {
|
||||
if plan == nil {
|
||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||
}
|
||||
if len(plan.Rules) == 0 {
|
||||
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
|
||||
}
|
||||
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
|
||||
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
|
||||
}
|
||||
|
||||
// Ensure unique priority per (trigger, appliesTo) combination.
|
||||
seen := make(map[string]struct{})
|
||||
for _, rule := range plan.Rules {
|
||||
if strings.TrimSpace(rule.Percentage) != "" {
|
||||
if _, err := dmath.RatFromString(rule.Percentage); err != nil {
|
||||
return merrors.InvalidArgument("plansStore: invalid rule percentage")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rule.FixedAmount) != "" {
|
||||
if _, err := dmath.RatFromString(rule.FixedAmount); err != nil {
|
||||
return merrors.InvalidArgument("plansStore: invalid rule fixed amount")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rule.MinimumAmount) != "" {
|
||||
if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil {
|
||||
return merrors.InvalidArgument("plansStore: invalid rule minimum amount")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rule.MaximumAmount) != "" {
|
||||
if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil {
|
||||
return merrors.InvalidArgument("plansStore: invalid rule maximum amount")
|
||||
}
|
||||
}
|
||||
|
||||
appliesKey := normalizeAppliesTo(rule.AppliesTo)
|
||||
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
|
||||
if _, ok := seen[priorityKey]; ok {
|
||||
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
|
||||
}
|
||||
seen[priorityKey] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAppliesTo(applies map[string]string) string {
|
||||
if len(applies) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(applies))
|
||||
for k := range applies {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+applies[k])
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error {
|
||||
if plan == nil || !plan.Active {
|
||||
return nil
|
||||
}
|
||||
|
||||
orgQuery := repository.Query()
|
||||
if plan.OrganizationRef.IsZero() {
|
||||
orgQuery = repository.Query().Or(
|
||||
repository.Exists(repository.OrgField(), false),
|
||||
repository.Query().Filter(repository.OrgField(), nil),
|
||||
)
|
||||
} else {
|
||||
orgQuery = repository.Query().Filter(repository.OrgField(), plan.OrganizationRef)
|
||||
}
|
||||
|
||||
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
newFrom := plan.EffectiveFrom
|
||||
newTo := maxTime
|
||||
if plan.EffectiveTo != nil {
|
||||
newTo = *plan.EffectiveTo
|
||||
}
|
||||
|
||||
query := orgQuery.
|
||||
Filter(repository.Field("active"), true).
|
||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, newTo).
|
||||
And(repository.Query().Or(
|
||||
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
||||
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, newFrom),
|
||||
))
|
||||
|
||||
if id := plan.GetID(); id != nil && !id.IsZero() {
|
||||
query = query.And(repository.Query().Comparison(repository.IDField(), builder.Ne, *id))
|
||||
}
|
||||
|
||||
limit := int64(1)
|
||||
query = query.Limit(&limit)
|
||||
|
||||
var overlapFound bool
|
||||
decoder := func(cursor *mongo.Cursor) error {
|
||||
overlapFound = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if overlapFound {
|
||||
return storage.ErrConflictingFeePlans
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ var (
|
||||
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
||||
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
||||
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
||||
// ErrConflictingFeePlans indicates multiple active plans matched a query.
|
||||
ErrConflictingFeePlans = storageError("billing.fees.storage: conflicting fee plans")
|
||||
)
|
||||
|
||||
// Repository defines the root storage contract for the fees service.
|
||||
@@ -32,5 +34,6 @@ type PlansStore interface {
|
||||
Create(ctx context.Context, plan *model.FeePlan) error
|
||||
Update(ctx context.Context, plan *model.FeePlan) error
|
||||
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
||||
// Legacy helper that now prefers an org plan and falls back to a global plan.
|
||||
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user