wallets listing dedupe
This commit is contained in:
@@ -24,5 +24,6 @@ func Create() version.Printer {
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return vf.Create(&info)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ type Imp struct {
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
Oracle OracleConfig `yaml:"oracle"`
|
||||
|
||||
Oracle OracleConfig `yaml:"oracle"`
|
||||
}
|
||||
|
||||
type OracleConfig struct {
|
||||
@@ -45,6 +46,7 @@ func (c OracleConfig) dialTimeout() time.Duration {
|
||||
if c.DialTimeoutSecs <= 0 {
|
||||
return 5 * time.Second
|
||||
}
|
||||
|
||||
return time.Duration(c.DialTimeoutSecs) * time.Second
|
||||
}
|
||||
|
||||
@@ -52,6 +54,7 @@ func (c OracleConfig) callTimeout() time.Duration {
|
||||
if c.CallTimeoutSecs <= 0 {
|
||||
return 3 * time.Second
|
||||
}
|
||||
|
||||
return time.Duration(c.CallTimeoutSecs) * time.Second
|
||||
}
|
||||
|
||||
@@ -69,9 +72,11 @@ func (i *Imp) Shutdown() {
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
|
||||
if i.oracleClient != nil {
|
||||
_ = i.oracleClient.Close()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,6 +103,7 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.config = cfg
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
@@ -105,11 +111,12 @@ func (i *Imp) Start() error {
|
||||
}
|
||||
|
||||
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{
|
||||
oracleConn, err := oracleclient.New(dialCtx, oracleclient.Config{
|
||||
Address: addr,
|
||||
DialTimeout: cfg.Oracle.dialTimeout(),
|
||||
CallTimeout: cfg.Oracle.callTimeout(),
|
||||
@@ -118,8 +125,8 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
i.logger.Warn("Failed to initialise oracle client", zap.String("address", addr), zap.Error(err))
|
||||
} else {
|
||||
oracleClient = oc
|
||||
i.oracleClient = oc
|
||||
oracleClient = oracleConn
|
||||
i.oracleClient = oracleConn
|
||||
i.logger.Info("Connected to oracle service", zap.String("address", addr))
|
||||
}
|
||||
}
|
||||
@@ -129,13 +136,16 @@ func (i *Imp) Start() error {
|
||||
if oracleClient != nil {
|
||||
opts = append(opts, fees.WithOracleClient(oracleClient))
|
||||
}
|
||||
|
||||
if cfg.GRPC != nil {
|
||||
if invokeURI := cfg.GRPC.DiscoveryInvokeURI(); invokeURI != "" {
|
||||
opts = append(opts, fees.WithDiscoveryInvokeURI(invokeURI))
|
||||
}
|
||||
}
|
||||
|
||||
svc := fees.NewService(logger, repo, producer, opts...)
|
||||
i.service = svc
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
@@ -143,6 +153,7 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.app = app
|
||||
|
||||
return i.app.Start()
|
||||
@@ -152,12 +163,14 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package calculator
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -33,6 +34,7 @@ func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return "eCalculator{
|
||||
logger: logger.Named("calculator"),
|
||||
oracle: oracle,
|
||||
@@ -45,27 +47,10 @@ type quoteCalculator struct {
|
||||
}
|
||||
|
||||
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())
|
||||
baseAmount, baseScale, trigger, err := validateComputeInputs(plan, intent)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid base amount")
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
@@ -73,81 +58,37 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
|
||||
if rules[i].Priority == rules[j].Priority {
|
||||
return rules[i].RuleID < rules[j].RuleID
|
||||
}
|
||||
|
||||
return rules[i].Priority < rules[j].Priority
|
||||
})
|
||||
|
||||
planID := planIDFrom(plan)
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
currency := resolvedCurrency(intent.GetBaseAmount().GetCurrency(), rule.Currency)
|
||||
entrySide := resolvedEntrySide(rule)
|
||||
|
||||
entrySide := mapEntrySide(rule.EntrySide)
|
||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
|
||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
lines = append(lines, buildPostingLine(rule, amount, scale, currency, entrySide, planID))
|
||||
applied = append(applied, buildAppliedRule(rule, planID))
|
||||
}
|
||||
|
||||
var fxUsed *feesv1.FXUsed
|
||||
@@ -170,40 +111,24 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
|
||||
|
||||
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))
|
||||
result, err = applyPercentage(result, baseAmount, rule.Percentage)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
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)
|
||||
result, err = applyFixed(result, rule.FixedAmount)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
result, err = applyMin(result, rule.MinimumAmount)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
result, err = applyMax(result, rule.MaximumAmount)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if result.Sign() < 0 {
|
||||
@@ -218,6 +143,66 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
|
||||
return rounded, scale, nil
|
||||
}
|
||||
|
||||
func applyPercentage(result, baseAmount *big.Rat, percentage string) (*big.Rat, error) {
|
||||
if strings.TrimSpace(percentage) == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
percentageRat, err := dmath.RatFromString(percentage)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid percentage")
|
||||
}
|
||||
|
||||
return dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat)), nil
|
||||
}
|
||||
|
||||
func applyFixed(result *big.Rat, fixed string) (*big.Rat, error) {
|
||||
if strings.TrimSpace(fixed) == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
fixedRat, err := dmath.RatFromString(fixed)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid fixed amount")
|
||||
}
|
||||
|
||||
return dmath.AddRat(result, fixedRat), nil
|
||||
}
|
||||
|
||||
func applyMin(result *big.Rat, minStr string) (*big.Rat, error) {
|
||||
if strings.TrimSpace(minStr) == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
minRat, err := dmath.RatFromString(minStr)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid minimum amount")
|
||||
}
|
||||
|
||||
if dmath.CmpRat(result, minRat) < 0 {
|
||||
return new(big.Rat).Set(minRat), nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyMax(result *big.Rat, maxStr string) (*big.Rat, error) {
|
||||
if strings.TrimSpace(maxStr) == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
maxRat, err := dmath.RatFromString(maxStr)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid maximum amount")
|
||||
}
|
||||
|
||||
if dmath.CmpRat(result, maxRat) > 0 {
|
||||
return new(big.Rat).Set(maxRat), nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
const (
|
||||
attrFxBaseCurrency = "fx_base_currency"
|
||||
attrFxQuoteCurrency = "fx_quote_currency"
|
||||
@@ -232,7 +217,9 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
|
||||
}
|
||||
|
||||
attrs := intent.GetAttributes()
|
||||
|
||||
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||
|
||||
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||
if base == "" || quote == "" {
|
||||
return nil
|
||||
@@ -248,19 +235,25 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -292,15 +285,19 @@ func inferScale(amount string) uint32 {
|
||||
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:]))
|
||||
|
||||
if _, after, found := strings.Cut(value, "."); found {
|
||||
return uint32(len(after)) //nolint:gosec // decimal scale; cannot overflow
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -308,12 +305,15 @@ func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[s
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
@@ -333,17 +334,115 @@ func parseScale(field, value string) (uint32, error) {
|
||||
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 validateComputeInputs(plan *model.FeePlan, intent *feesv1.Intent) (*big.Rat, uint32, model.Trigger, error) {
|
||||
if plan == nil {
|
||||
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("plan is required")
|
||||
}
|
||||
|
||||
if intent == nil {
|
||||
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
|
||||
trigger := convertTrigger(intent.GetTrigger())
|
||||
if trigger == model.TriggerUnspecified {
|
||||
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("unsupported trigger")
|
||||
}
|
||||
|
||||
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
||||
if err != nil {
|
||||
return nil, 0, trigger, merrors.InvalidArgument("invalid base amount")
|
||||
}
|
||||
|
||||
if baseAmount.Sign() < 0 {
|
||||
return nil, 0, trigger, merrors.InvalidArgument("base amount cannot be negative")
|
||||
}
|
||||
|
||||
return baseAmount, inferScale(intent.GetBaseAmount().GetAmount()), trigger, nil
|
||||
}
|
||||
|
||||
func planIDFrom(plan *model.FeePlan) string {
|
||||
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
||||
return planRef.Hex()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolvedCurrency(baseCurrency, ruleCurrency string) string {
|
||||
if override := strings.TrimSpace(ruleCurrency); override != "" {
|
||||
return override
|
||||
}
|
||||
|
||||
return baseCurrency
|
||||
}
|
||||
|
||||
func resolvedEntrySide(rule model.FeeRule) accountingv1.EntrySide {
|
||||
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
|
||||
if side := mapEntrySide(rule.EntrySide); side != accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||
return side
|
||||
}
|
||||
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
}
|
||||
|
||||
func buildPostingLine(rule model.FeeRule, amount *big.Rat, scale uint32, currency string, entrySide accountingv1.EntrySide, planID string) *feesv1.DerivedPostingLine {
|
||||
return &feesv1.DerivedPostingLine{
|
||||
LedgerAccountRef: strings.TrimSpace(rule.LedgerAccountRef),
|
||||
Money: &moneyv1.Money{
|
||||
Amount: dmath.FormatRat(amount, scale),
|
||||
Currency: currency,
|
||||
},
|
||||
LineType: mapLineType(rule.LineType),
|
||||
Side: entrySide,
|
||||
Meta: buildRuleMeta(rule, planID),
|
||||
}
|
||||
}
|
||||
|
||||
func buildAppliedRule(rule model.FeeRule, planID string) *feesv1.AppliedRule {
|
||||
return &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),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRuleMeta(rule model.FeeRule, planID string) map[string]string {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func metadataValue(meta map[string]string, key string) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(meta[key])
|
||||
}
|
||||
|
||||
@@ -351,10 +450,10 @@ 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
|
||||
}
|
||||
maps.Copy(cloned, src)
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
@@ -362,18 +461,22 @@ func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) boo
|
||||
if len(rule.AppliesTo) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for key, value := range rule.AppliesTo {
|
||||
if attributes == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
attrValue, ok := attributes[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if !matchesAttributeValue(value, attrValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -383,16 +486,17 @@ func matchesAttributeValue(expected, actual string) bool {
|
||||
return actual == ""
|
||||
}
|
||||
|
||||
values := strings.Split(trimmed, ",")
|
||||
for _, value := range values {
|
||||
for value := range strings.SplitSeq(trimmed, ",") {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if value == "*" || value == actual {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -446,6 +550,8 @@ func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
||||
|
||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||
switch trigger {
|
||||
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
|
||||
return model.TriggerUnspecified
|
||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||
return model.TriggerCapture
|
||||
case feesv1.Trigger_TRIGGER_REFUND:
|
||||
|
||||
@@ -30,9 +30,11 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
||||
if pf, ok := plans.(planFinder); ok {
|
||||
finder = pf
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return &feeResolver{
|
||||
plans: plans,
|
||||
finder: finder,
|
||||
@@ -40,63 +42,100 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, asOf 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 orgRef != nil && !orgRef.IsZero() {
|
||||
if plan, err := r.getOrgPlan(ctx, *orgRef, 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), mzap.ObjRef("org_ref", *orgRef))
|
||||
return nil, nil, selErr
|
||||
}
|
||||
orgFields := []zap.Field{
|
||||
mzap.ObjRef("org_ref", *orgRef),
|
||||
zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", at),
|
||||
zap.Any("attributes", attrs),
|
||||
}
|
||||
orgFields = append(orgFields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...)
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", *orgRef))
|
||||
if isOrgRef(orgRef) {
|
||||
plan, rule, err := r.tryOrgRule(ctx, *orgRef, trigger, asOf, attrs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if rule != nil {
|
||||
return plan, rule, nil
|
||||
}
|
||||
}
|
||||
|
||||
plan, err := r.getGlobalPlan(ctx, at)
|
||||
plan, err := r.getGlobalPlan(ctx, asOf)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", at), zap.Any("attributes", attrs),
|
||||
zap.Time("booked_at", asOf), zap.Any("attributes", attrs),
|
||||
)
|
||||
|
||||
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)
|
||||
rule, err := selectRule(plan, trigger, asOf, attrs)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
|
||||
} else {
|
||||
globalFields := []zap.Field{
|
||||
globalFields := zapFieldsForPlan(plan)
|
||||
globalFields = append([]zap.Field{
|
||||
zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", at),
|
||||
zap.Time("booked_at", asOf),
|
||||
zap.Any("attributes", attrs),
|
||||
}
|
||||
globalFields = append(globalFields, zapFieldsForPlan(plan)...)
|
||||
}, globalFields...)
|
||||
r.logger.Debug("No matching rule in global plan", globalFields...)
|
||||
}
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
selectedFields := []zap.Field{
|
||||
r.logSelectedRule(orgRef, trigger, asOf, attrs, rule, plan)
|
||||
|
||||
return plan, rule, nil
|
||||
}
|
||||
|
||||
// tryOrgRule attempts to find a matching rule in the org-specific plan.
|
||||
// Returns (plan, rule, nil) on success, (nil, nil, nil) to signal fallthrough to global,
|
||||
// or (nil, nil, err) on a hard error.
|
||||
func (r *feeResolver) tryOrgRule(ctx context.Context, orgRef bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||
plan, err := r.getOrgPlan(ctx, orgRef, asOf)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", orgRef))
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rule, selErr := selectRule(plan, trigger, asOf, attrs)
|
||||
if selErr == nil {
|
||||
return plan, rule, nil
|
||||
}
|
||||
|
||||
if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), mzap.ObjRef("org_ref", orgRef))
|
||||
|
||||
return nil, nil, selErr
|
||||
}
|
||||
|
||||
orgFields := zapFieldsForPlan(plan)
|
||||
orgFields = append([]zap.Field{
|
||||
mzap.ObjRef("org_ref", orgRef),
|
||||
zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", asOf),
|
||||
zap.Any("attributes", attrs),
|
||||
}, orgFields...)
|
||||
r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...)
|
||||
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *feeResolver) logSelectedRule(orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string, rule *model.FeeRule, plan *model.FeePlan) {
|
||||
fields := []zap.Field{
|
||||
zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", at),
|
||||
zap.Any("attributes", attrs),
|
||||
@@ -106,59 +145,65 @@ func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID,
|
||||
zap.Time("rule_effective_from", rule.EffectiveFrom),
|
||||
}
|
||||
if rule.EffectiveTo != nil {
|
||||
selectedFields = append(selectedFields, zap.Time("rule_effective_to", *rule.EffectiveTo))
|
||||
fields = append(fields, zap.Time("rule_effective_to", *rule.EffectiveTo))
|
||||
}
|
||||
if orgRef != nil && !orgRef.IsZero() {
|
||||
selectedFields = append(selectedFields, mzap.ObjRef("org_ref", *orgRef))
|
||||
}
|
||||
selectedFields = append(selectedFields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("Selected fee rule", selectedFields...)
|
||||
|
||||
return plan, rule, nil
|
||||
if isOrgRef(orgRef) {
|
||||
fields = append(fields, mzap.ObjRef("org_ref", *orgRef))
|
||||
}
|
||||
|
||||
fields = append(fields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("Selected fee rule", fields...)
|
||||
}
|
||||
|
||||
func isOrgRef(ref *bson.ObjectID) bool {
|
||||
return ref != nil && !ref.IsZero()
|
||||
}
|
||||
|
||||
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.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) {
|
||||
func (r *feeResolver) getGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) {
|
||||
if r.finder != nil {
|
||||
return r.finder.FindActiveGlobalPlan(ctx, at)
|
||||
return r.finder.FindActiveGlobalPlan(ctx, asOf)
|
||||
}
|
||||
|
||||
// Treat zero ObjectID as global in legacy path.
|
||||
return r.plans.GetActivePlan(ctx, bson.NilObjectID, at)
|
||||
return r.plans.GetActivePlan(ctx, bson.NilObjectID, asOf)
|
||||
}
|
||||
|
||||
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
|
||||
func selectRule(plan *model.FeePlan, trigger model.Trigger, asOf 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
|
||||
var (
|
||||
selected *model.FeeRule
|
||||
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) {
|
||||
if !ruleIsActive(rule, trigger, asOf) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
||||
continue
|
||||
}
|
||||
|
||||
if selected == nil || rule.Priority > highestPriority {
|
||||
copy := rule
|
||||
selected = ©
|
||||
matched := rule
|
||||
selected = &matched
|
||||
highestPriority = rule.Priority
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if rule.Priority == highestPriority {
|
||||
return nil, merrors.DataConflict("fees: conflicting fee rules")
|
||||
}
|
||||
@@ -167,25 +212,46 @@ func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs
|
||||
if selected == nil {
|
||||
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func ruleIsActive(rule model.FeeRule, trigger model.Trigger, asOf time.Time) bool {
|
||||
if rule.Trigger != trigger {
|
||||
return false
|
||||
}
|
||||
|
||||
if rule.EffectiveFrom.After(asOf) {
|
||||
return false
|
||||
}
|
||||
|
||||
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(asOf) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
attrValue, ok := attrs[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if !matchesAppliesValue(value, attrValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -195,16 +261,17 @@ func matchesAppliesValue(expected, actual string) bool {
|
||||
return actual == ""
|
||||
}
|
||||
|
||||
values := strings.Split(trimmed, ",")
|
||||
for _, value := range values {
|
||||
for value := range strings.SplitSeq(trimmed, ",") {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if value == "*" || value == actual {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -212,6 +279,7 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
|
||||
if plan == nil {
|
||||
return []zap.Field{zap.Bool("plan_present", false)}
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.Bool("plan_present", true),
|
||||
zap.Bool("plan_active", plan.Active),
|
||||
@@ -223,13 +291,16 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
|
||||
} else {
|
||||
fields = append(fields, zap.Bool("plan_effective_to_set", false))
|
||||
}
|
||||
|
||||
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||
fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef))
|
||||
} else {
|
||||
fields = append(fields, zap.Bool("plan_org_ref_set", false))
|
||||
}
|
||||
|
||||
if plan.GetID() != nil && !plan.GetID().IsZero() {
|
||||
fields = append(fields, mzap.StorableRef(plan))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
globalPlan := &model.FeePlan{
|
||||
@@ -28,6 +28,7 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
orgA := bson.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)
|
||||
@@ -35,13 +36,14 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
|
||||
}
|
||||
|
||||
if rule.RuleID != "global_capture" {
|
||||
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
@@ -67,22 +69,25 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||
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 := bson.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()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
@@ -103,6 +108,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -121,7 +127,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
@@ -158,7 +164,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
plan := &model.FeePlan{
|
||||
@@ -184,13 +190,14 @@ func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||
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_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
plan := &model.FeePlan{
|
||||
@@ -231,7 +238,7 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
plan := &model.FeePlan{
|
||||
@@ -250,28 +257,28 @@ func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
p1 := &model.FeePlan{
|
||||
plan1 := &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.OrganizationRef = &org
|
||||
p2 := &model.FeePlan{
|
||||
plan1.OrganizationRef = &org
|
||||
plan2 := &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.OrganizationRef = &org
|
||||
plan2.OrganizationRef = &org
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{plan1, plan2}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
|
||||
@@ -289,19 +296,21 @@ func (m *memoryPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan,
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
||||
if !orgRef.IsZero() {
|
||||
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
|
||||
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
|
||||
return plan, nil
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return m.FindActiveGlobalPlan(ctx, at)
|
||||
|
||||
return m.FindActiveGlobalPlan(ctx, asOf)
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
||||
continue
|
||||
@@ -309,12 +318,14 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.Obje
|
||||
if !plan.Active {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveFrom.After(at) {
|
||||
if plan.EffectiveFrom.After(asOf) {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
|
||||
continue
|
||||
}
|
||||
|
||||
matches = append(matches, plan)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
@@ -323,24 +334,28 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.Obje
|
||||
if len(matches) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, asOf time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !plan.Active {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveFrom.After(at) {
|
||||
if plan.EffectiveFrom.After(asOf) {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
|
||||
continue
|
||||
}
|
||||
|
||||
matches = append(matches, plan)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
@@ -349,6 +364,7 @@ func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time)
|
||||
if len(matches) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
|
||||
fields := logFieldsFromRequestMeta(meta)
|
||||
fields = append(fields, logFieldsFromIntent(intent)...)
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -19,11 +20,14 @@ func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]zap.Field, 0, 4)
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
|
||||
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -31,24 +35,30 @@ func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]zap.Field, 0, 5)
|
||||
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
fields = append(fields, zap.String("trigger", trigger.String()))
|
||||
}
|
||||
|
||||
if base := intent.GetBaseAmount(); base != nil {
|
||||
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
|
||||
fields = append(fields, zap.String("base_amount", amount))
|
||||
}
|
||||
|
||||
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
|
||||
fields = append(fields, zap.String("base_currency", currency))
|
||||
}
|
||||
}
|
||||
|
||||
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
|
||||
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
|
||||
}
|
||||
|
||||
if attrs := intent.GetAttributes(); len(attrs) > 0 {
|
||||
fields = append(fields, zap.Int("attributes_count", len(attrs)))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -56,16 +66,20 @@ func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
|
||||
if trace == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]zap.Field, 0, 3)
|
||||
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
|
||||
fields = append(fields, zap.String("request_ref", reqRef))
|
||||
}
|
||||
|
||||
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||
fields = append(fields, zap.String("idempotency_key", idem))
|
||||
}
|
||||
|
||||
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
|
||||
fields = append(fields, zap.String("trace_ref", traceRef))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -73,16 +87,20 @@ func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
|
||||
if payload.ExpiresAtUnixMs > 0 {
|
||||
fields = append(fields,
|
||||
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
|
||||
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
|
||||
}
|
||||
|
||||
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
|
||||
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxU
|
||||
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())
|
||||
@@ -59,13 +60,16 @@ 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())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,6 +32,8 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
feesv1.UnimplementedFeeEngineServer
|
||||
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
producer msg.Producer
|
||||
@@ -42,7 +43,6 @@ type Service struct {
|
||||
resolver FeeResolver
|
||||
announcer *discovery.Announcer
|
||||
invokeURI string
|
||||
feesv1.UnimplementedFeeEngineServer
|
||||
}
|
||||
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||
@@ -52,6 +52,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
producer: producer,
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
|
||||
initMetrics()
|
||||
|
||||
for _, opt := range opts {
|
||||
@@ -61,9 +62,11 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
if svc.clock == nil {
|
||||
svc.clock = clockpkg.NewSystem()
|
||||
}
|
||||
|
||||
if svc.calculator == nil {
|
||||
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
|
||||
}
|
||||
|
||||
if svc.resolver == nil {
|
||||
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||
}
|
||||
@@ -83,25 +86,12 @@ func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.announcer != nil {
|
||||
s.announcer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_FEES",
|
||||
Operations: []string{"fee.calc"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||
var (
|
||||
meta *feesv1.RequestMeta
|
||||
@@ -111,23 +101,29 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
meta = req.GetMeta()
|
||||
intent = req.GetIntent()
|
||||
}
|
||||
|
||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||
|
||||
start := s.clock.Now()
|
||||
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
}
|
||||
|
||||
var fxUsed bool
|
||||
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
}
|
||||
|
||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
@@ -140,8 +136,10 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("QuoteFees finished", logFields...)
|
||||
}()
|
||||
|
||||
@@ -155,12 +153,14 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
if parseErr != nil {
|
||||
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
||||
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())
|
||||
lines, applied, fxResult, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
|
||||
if computeErr != nil {
|
||||
err = computeErr
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -168,8 +168,9 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fx,
|
||||
FxUsed: fxResult,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -182,48 +183,17 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
meta = req.GetMeta()
|
||||
intent = req.GetIntent()
|
||||
}
|
||||
|
||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||
|
||||
start := s.clock.Now()
|
||||
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
}
|
||||
var (
|
||||
fxUsed bool
|
||||
expiresAt time.Time
|
||||
)
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
if ts := resp.GetExpiresAt(); ts != nil {
|
||||
expiresAt = ts.AsTime()
|
||||
}
|
||||
}
|
||||
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Int("lines", linesCount),
|
||||
zap.Int("applied_rules", appliedCount),
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
logFields = append(logFields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("PrecomputeFees finished", logFields...)
|
||||
}()
|
||||
defer func() { s.observePrecomputeFees(logger, err, resp, trigger, start) }()
|
||||
|
||||
logger.Debug("PrecomputeFees request received")
|
||||
|
||||
@@ -237,12 +207,14 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
if parseErr != nil {
|
||||
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
||||
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)
|
||||
lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
|
||||
if computeErr != nil {
|
||||
err = computeErr
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -250,7 +222,8 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
if ttl <= 0 {
|
||||
ttl = 60000
|
||||
}
|
||||
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
|
||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
|
||||
payload := feeQuoteTokenPayload{
|
||||
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||
@@ -263,6 +236,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
if token, err = encodeTokenPayload(payload); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -272,8 +246,9 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
ExpiresAt: timestamppb.New(expiresAt),
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fx,
|
||||
FxUsed: fxResult,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -282,49 +257,23 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
if req != nil {
|
||||
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.Int("token_length", tokenLen))
|
||||
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
var (
|
||||
fxUsed bool
|
||||
resultReason string
|
||||
)
|
||||
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))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Bool("valid", resp != nil && resp.GetValid()),
|
||||
}
|
||||
if resultReason != "" {
|
||||
logFields = append(logFields, zap.String("reason", resultReason))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("ValidateFeeToken finished", logFields...)
|
||||
}()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
|
||||
var resultReason string
|
||||
|
||||
defer func() { s.observeValidateFeeToken(logger, err, resp, trigger, resultReason, start) }()
|
||||
|
||||
logger.Debug("ValidateFeeToken request received")
|
||||
|
||||
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||
resultReason = "missing_token"
|
||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -333,8 +282,11 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||
if decodeErr != nil {
|
||||
resultReason = "invalid_token"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -346,22 +298,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
|
||||
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||
resultReason = "expired"
|
||||
|
||||
logger.Info("Fee quote token expired")
|
||||
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
|
||||
if parseErr != nil {
|
||||
resultReason = "invalid_token"
|
||||
|
||||
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)
|
||||
lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
|
||||
if computeErr != nil {
|
||||
err = computeErr
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -371,8 +330,9 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
Intent: payload.Intent,
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fx,
|
||||
FxUsed: fxResult,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -380,24 +340,31 @@ 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
|
||||
}
|
||||
|
||||
@@ -405,6 +372,7 @@ func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) e
|
||||
if req == nil {
|
||||
return status.Error(codes.InvalidArgument, "request is required")
|
||||
}
|
||||
|
||||
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
|
||||
}
|
||||
|
||||
@@ -413,17 +381,13 @@ func (s *Service) computeQuote(ctx context.Context, orgRef bson.ObjectID, intent
|
||||
}
|
||||
|
||||
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, _ *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()
|
||||
}
|
||||
bookedAt := resolvedBookedAt(intent, now)
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.Time("booked_at_used", bookedAt),
|
||||
}
|
||||
logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)}
|
||||
if !orgRef.IsZero() {
|
||||
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
|
||||
}
|
||||
|
||||
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
||||
logFields = append(logFields, logFieldsFromTrace(trace)...)
|
||||
logger := s.logger.With(logFields...)
|
||||
@@ -436,22 +400,13 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
|
||||
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee rule not found: %s", err.Error()))
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee rules: %s", err.Error()))
|
||||
case errors.Is(err, storage.ErrConflictingFeePlans):
|
||||
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee plans: %s", err.Error()))
|
||||
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee plan not found: %s", err.Error()))
|
||||
default:
|
||||
return nil, nil, nil, status.Error(codes.Internal, fmt.Sprintf("failed to resolve fee rule: %s", err.Error()))
|
||||
}
|
||||
|
||||
return nil, nil, nil, mapResolveError(err)
|
||||
}
|
||||
|
||||
originalRules := plan.Rules
|
||||
plan.Rules = []model.FeeRule{*rule}
|
||||
|
||||
defer func() {
|
||||
plan.Rules = originalRules
|
||||
}()
|
||||
@@ -461,13 +416,115 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
|
||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func resolvedBookedAt(intent *feesv1.Intent, now time.Time) time.Time {
|
||||
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
|
||||
return intent.GetBookedAt().AsTime()
|
||||
}
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
func mapResolveError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return status.Error(codes.NotFound, "fee rule not found: "+err.Error())
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return status.Error(codes.FailedPrecondition, "conflicting fee rules: "+err.Error())
|
||||
case errors.Is(err, storage.ErrConflictingFeePlans):
|
||||
return status.Error(codes.FailedPrecondition, "conflicting fee plans: "+err.Error())
|
||||
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||
return status.Error(codes.NotFound, "fee plan not found: "+err.Error())
|
||||
default:
|
||||
return status.Error(codes.Internal, "failed to resolve fee rule: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) observePrecomputeFees(logger mlogger.Logger, err error, resp *feesv1.PrecomputeFeesResponse, trigger feesv1.Trigger, start time.Time) {
|
||||
statusLabel := statusFromError(err)
|
||||
fxUsed := false
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
|
||||
var expiresAt time.Time
|
||||
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
|
||||
if ts := resp.GetExpiresAt(); ts != nil {
|
||||
expiresAt = ts.AsTime()
|
||||
}
|
||||
}
|
||||
|
||||
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Int("lines", linesCount),
|
||||
zap.Int("applied_rules", appliedCount),
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
logFields = append(logFields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("PrecomputeFees finished", logFields...)
|
||||
}
|
||||
|
||||
func (s *Service) observeValidateFeeToken(logger mlogger.Logger, err error, resp *feesv1.ValidateFeeTokenResponse, trigger feesv1.Trigger, resultReason string, start time.Time) {
|
||||
statusLabel := statusFromError(err)
|
||||
fxUsed := false
|
||||
|
||||
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))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Bool("valid", resp != nil && resp.GetValid()),
|
||||
}
|
||||
if resultReason != "" {
|
||||
logFields = append(logFields, zap.String("reason", resultReason))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("ValidateFeeToken finished", logFields...)
|
||||
}
|
||||
|
||||
type feeQuoteTokenPayload struct {
|
||||
OrganizationRef string `json:"organization_ref"`
|
||||
Intent *feesv1.Intent `json:"intent"`
|
||||
@@ -480,17 +537,36 @@ func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_FEES",
|
||||
Operations: []string{"fee.calc"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.FeePlans, announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -120,7 +120,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -192,6 +192,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
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())
|
||||
@@ -202,7 +203,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -258,7 +259,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -331,7 +332,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -356,7 +357,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
fakeOracle := &oracleclient.Fake{
|
||||
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||
LatestRateFn: func(_ context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||
return &oracleclient.RateSnapshot{
|
||||
Pair: req.Pair,
|
||||
Mid: "1.2300",
|
||||
@@ -399,6 +400,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
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)
|
||||
@@ -437,18 +439,18 @@ func (s *stubPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan, er
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
func (s *stubPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
||||
if !orgRef.IsZero() {
|
||||
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
|
||||
if plan, err := s.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
|
||||
return plan, nil
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.FindActiveGlobalPlan(context.Background(), at)
|
||||
return s.FindActiveGlobalPlan(ctx, asOf)
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
||||
if s.plan == nil {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
@@ -458,28 +460,30 @@ func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.Object
|
||||
if !s.plan.Active {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.plan.EffectiveFrom.After(at) {
|
||||
if s.plan.EffectiveFrom.After(asOf) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
|
||||
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(asOf) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
return s.plan, nil
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, asOf 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) {
|
||||
if s.globalPlan.EffectiveFrom.After(asOf) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
|
||||
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(asOf) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
return s.globalPlan, nil
|
||||
}
|
||||
|
||||
@@ -509,8 +513,10 @@ func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *fees
|
||||
s.called = true
|
||||
s.gotPlan = plan
|
||||
s.bookedAt = bookedAt
|
||||
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
return s.result, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||
switch trigger {
|
||||
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
|
||||
return model.TriggerUnspecified
|
||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||
return model.TriggerCapture
|
||||
case feesv1.Trigger_TRIGGER_REFUND:
|
||||
|
||||
Reference in New Issue
Block a user