wallets listing dedupe #541

Merged
tech merged 1 commits from wallet-540 into main 2026-02-20 12:52:46 +00:00
40 changed files with 3166 additions and 423 deletions
Showing only changes of commit 20cb057618 - Show all commits

View File

@@ -163,6 +163,7 @@ linters:
# Exclude some linters from running on tests files. # Exclude some linters from running on tests files.
- path: _test\.go - path: _test\.go
linters: linters:
- funlen
- gocyclo - gocyclo
- errcheck - errcheck
- dupl - dupl

View File

@@ -0,0 +1,198 @@
# See the dedicated "version" documentation section.
version: "2"
linters:
# Default set of linters.
# The value can be:
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
# - `all`: enables all linters by default.
# - `none`: disables all linters by default.
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
# Default: standard
default: all
# Enable specific linter.
enable:
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- containedctx
- contextcheck
- copyloopvar
- cyclop
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- embeddedstructfieldcheck
- err113
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- forbidigo
- forcetypeassert
- funcorder
- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoglobals
- gochecknoinits
- gochecksumtype
- gocognit
- goconst
- gocritic
- gocyclo
- godoclint
- godot
- godox
- goheader
- gomodguard
- goprintffuncname
- gosec
- gosmopolitan
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- interfacebloat
- intrange
- iotamixing
- ireturn
- lll
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- mnd
- modernize
- musttag
- nakedret
- nestif
- nilerr
- nilnesserr
- nilnil
- nlreturn
- noctx
- noinlineerr
- nolintlint
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- staticcheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- unused
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
# Disable specific linters.
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gomoddirectives
- noinlineerr
- wsl
- wrapcheck
# All available settings of specific linters.
# See the dedicated "linters.settings" documentation section.
settings:
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
# Defines a set of rules to ignore issues.
# It does not skip the analysis, and so does not ignore "typecheck" errors.
exclusions:
# Mode of the generated files analysis.
#
# - `strict`: sources are excluded by strictly following the Go generated file convention.
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
# This line must appear before the first non-comment, non-blank text in the file.
# https://go.dev/s/generatedcode
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
# - `disable`: disable the generated files exclusion.
#
# Default: strict
generated: lax
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- comments
- std-error-handling
- common-false-positives
- legacy
# Excluding configuration per-path, per-linter, per-text and per-source.
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- cyclop
- funlen
- gocyclo
- errcheck
- dupl
- gosec
# Run some linter only for test files by excluding its issues for everything else.
- path-except: _test\.go
linters:
- forbidigo
# Exclude known linters from partially hard-vendored code,
# which is impossible to exclude via `nolint` comments.
# `/` will be replaced by the current OS file path separator to properly work on Windows.
- path: internal/hmac/
text: "weak cryptographic primitive"
linters:
- gosec
# Exclude some `staticcheck` messages.
- linters:
- staticcheck
text: "SA9003:"
# Exclude `lll` issues for long lines with `go:generate`.
- linters:
- lll
source: "^//go:generate "
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
# "/" will be replaced by the current OS file path separator to properly work on Windows.
# Default: []
paths: []
# Which file paths to not exclude.
# Default: []
paths-except: []

View File

@@ -24,5 +24,6 @@ func Create() version.Printer {
BuildDate: BuildDate, BuildDate: BuildDate,
Version: Version, Version: Version,
} }
return vf.Create(&info) return vf.Create(&info)
} }

View File

@@ -31,7 +31,8 @@ type Imp struct {
type config struct { type config struct {
*grpcapp.Config `yaml:",inline"` *grpcapp.Config `yaml:",inline"`
Oracle OracleConfig `yaml:"oracle"`
Oracle OracleConfig `yaml:"oracle"`
} }
type OracleConfig struct { type OracleConfig struct {
@@ -45,6 +46,7 @@ func (c OracleConfig) dialTimeout() time.Duration {
if c.DialTimeoutSecs <= 0 { if c.DialTimeoutSecs <= 0 {
return 5 * time.Second return 5 * time.Second
} }
return time.Duration(c.DialTimeoutSecs) * time.Second return time.Duration(c.DialTimeoutSecs) * time.Second
} }
@@ -52,6 +54,7 @@ func (c OracleConfig) callTimeout() time.Duration {
if c.CallTimeoutSecs <= 0 { if c.CallTimeoutSecs <= 0 {
return 3 * time.Second return 3 * time.Second
} }
return time.Duration(c.CallTimeoutSecs) * time.Second return time.Duration(c.CallTimeoutSecs) * time.Second
} }
@@ -69,9 +72,11 @@ func (i *Imp) Shutdown() {
if i.service != nil { if i.service != nil {
i.service.Shutdown() i.service.Shutdown()
} }
if i.oracleClient != nil { if i.oracleClient != nil {
_ = i.oracleClient.Close() _ = i.oracleClient.Close()
} }
return return
} }
@@ -98,6 +103,7 @@ func (i *Imp) Start() error {
if err != nil { if err != nil {
return err return err
} }
i.config = cfg i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
@@ -105,11 +111,12 @@ func (i *Imp) Start() error {
} }
var oracleClient oracleclient.Client var oracleClient oracleclient.Client
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" { if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout()) dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
defer cancel() defer cancel()
oc, err := oracleclient.New(dialCtx, oracleclient.Config{ oracleConn, err := oracleclient.New(dialCtx, oracleclient.Config{
Address: addr, Address: addr,
DialTimeout: cfg.Oracle.dialTimeout(), DialTimeout: cfg.Oracle.dialTimeout(),
CallTimeout: cfg.Oracle.callTimeout(), CallTimeout: cfg.Oracle.callTimeout(),
@@ -118,8 +125,8 @@ func (i *Imp) Start() error {
if err != nil { if err != nil {
i.logger.Warn("Failed to initialise oracle client", zap.String("address", addr), zap.Error(err)) i.logger.Warn("Failed to initialise oracle client", zap.String("address", addr), zap.Error(err))
} else { } else {
oracleClient = oc oracleClient = oracleConn
i.oracleClient = oc i.oracleClient = oracleConn
i.logger.Info("Connected to oracle service", zap.String("address", addr)) i.logger.Info("Connected to oracle service", zap.String("address", addr))
} }
} }
@@ -129,13 +136,16 @@ func (i *Imp) Start() error {
if oracleClient != nil { if oracleClient != nil {
opts = append(opts, fees.WithOracleClient(oracleClient)) opts = append(opts, fees.WithOracleClient(oracleClient))
} }
if cfg.GRPC != nil { if cfg.GRPC != nil {
if invokeURI := cfg.GRPC.DiscoveryInvokeURI(); invokeURI != "" { if invokeURI := cfg.GRPC.DiscoveryInvokeURI(); invokeURI != "" {
opts = append(opts, fees.WithDiscoveryInvokeURI(invokeURI)) opts = append(opts, fees.WithDiscoveryInvokeURI(invokeURI))
} }
} }
svc := fees.NewService(logger, repo, producer, opts...) svc := fees.NewService(logger, repo, producer, opts...)
i.service = svc i.service = svc
return svc, nil return svc, nil
} }
@@ -143,6 +153,7 @@ func (i *Imp) Start() error {
if err != nil { if err != nil {
return err return err
} }
i.app = app i.app = app
return i.app.Start() return i.app.Start()
@@ -152,12 +163,14 @@ func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file) data, err := os.ReadFile(i.file)
if err != nil { if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err return nil, err
} }
cfg := &config{Config: &grpcapp.Config{}} cfg := &config{Config: &grpcapp.Config{}}
if err := yaml.Unmarshal(data, cfg); err != nil { if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err)) i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err return nil, err
} }

View File

@@ -3,6 +3,7 @@ package calculator
import ( import (
"context" "context"
"errors" "errors"
"maps"
"math/big" "math/big"
"sort" "sort"
"strconv" "strconv"
@@ -33,6 +34,7 @@ func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
if logger == nil { if logger == nil {
logger = zap.NewNop() logger = zap.NewNop()
} }
return &quoteCalculator{ return &quoteCalculator{
logger: logger.Named("calculator"), logger: logger.Named("calculator"),
oracle: oracle, 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) { func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
if plan == nil { baseAmount, baseScale, trigger, err := validateComputeInputs(plan, intent)
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 { 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)) rules := make([]model.FeeRule, len(plan.Rules))
copy(rules, 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 { if rules[i].Priority == rules[j].Priority {
return rules[i].RuleID < rules[j].RuleID return rules[i].RuleID < rules[j].RuleID
} }
return rules[i].Priority < rules[j].Priority return rules[i].Priority < rules[j].Priority
}) })
planID := planIDFrom(plan)
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules)) lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
applied := make([]*feesv1.AppliedRule, 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 { for _, rule := range rules {
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) { if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
continue continue
} }
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule) amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil { if calcErr != nil {
if !errors.Is(calcErr, merrors.ErrInvalidArg) { if !errors.Is(calcErr, merrors.ErrInvalidArg) {
c.logger.Warn("Failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr)) c.logger.Warn("Failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
} }
continue continue
} }
if amount.Sign() == 0 { if amount.Sign() == 0 {
continue continue
} }
currency := intent.GetBaseAmount().GetCurrency() currency := resolvedCurrency(intent.GetBaseAmount().GetCurrency(), rule.Currency)
if override := strings.TrimSpace(rule.Currency); override != "" { entrySide := resolvedEntrySide(rule)
currency = override
}
entrySide := mapEntrySide(rule.EntrySide) lines = append(lines, buildPostingLine(rule, amount, scale, currency, entrySide, planID))
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED { applied = append(applied, buildAppliedRule(rule, planID))
// 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),
})
} }
var fxUsed *feesv1.FXUsed var fxUsed *feesv1.FXUsed
@@ -170,40 +111,24 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
result := new(big.Rat) result := new(big.Rat)
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" { result, err = applyPercentage(result, baseAmount, rule.Percentage)
percentageRat, perr := dmath.RatFromString(percentage) if err != nil {
if perr != nil { return nil, 0, err
return nil, 0, merrors.InvalidArgument("invalid percentage")
}
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
} }
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" { result, err = applyFixed(result, rule.FixedAmount)
fixedRat, ferr := dmath.RatFromString(fixed) if err != nil {
if ferr != nil { return nil, 0, err
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
}
result = dmath.AddRat(result, fixedRat)
} }
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" { result, err = applyMin(result, rule.MinimumAmount)
minRat, merr := dmath.RatFromString(minStr) if err != nil {
if merr != nil { return nil, 0, err
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 != "" { result, err = applyMax(result, rule.MaximumAmount)
maxRat, merr := dmath.RatFromString(maxStr) if err != nil {
if merr != nil { return nil, 0, err
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
}
if dmath.CmpRat(result, maxRat) > 0 {
result = new(big.Rat).Set(maxRat)
}
} }
if result.Sign() < 0 { if result.Sign() < 0 {
@@ -218,6 +143,66 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
return rounded, scale, nil 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 ( const (
attrFxBaseCurrency = "fx_base_currency" attrFxBaseCurrency = "fx_base_currency"
attrFxQuoteCurrency = "fx_quote_currency" attrFxQuoteCurrency = "fx_quote_currency"
@@ -232,7 +217,9 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
} }
attrs := intent.GetAttributes() attrs := intent.GetAttributes()
base := strings.TrimSpace(attrs[attrFxBaseCurrency]) base := strings.TrimSpace(attrs[attrFxBaseCurrency])
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency]) quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
if base == "" || quote == "" { if base == "" || quote == "" {
return nil return nil
@@ -248,19 +235,25 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
}) })
if err != nil { if err != nil {
c.logger.Warn("Fees: failed to fetch FX context", zap.Error(err)) c.logger.Warn("Fees: failed to fetch FX context", zap.Error(err))
return nil return nil
} }
if snapshot == nil { if snapshot == nil {
return nil return nil
} }
rateValue := strings.TrimSpace(attrs[attrFxRateOverride]) rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
if rateValue == "" { if rateValue == "" {
rateValue = snapshot.Mid rateValue = snapshot.Mid
} }
if rateValue == "" { if rateValue == "" {
rateValue = snapshot.Ask rateValue = snapshot.Ask
} }
if rateValue == "" { if rateValue == "" {
rateValue = snapshot.Bid rateValue = snapshot.Bid
} }
@@ -292,15 +285,19 @@ func inferScale(amount string) uint32 {
if value == "" { if value == "" {
return 0 return 0
} }
if idx := strings.IndexAny(value, "eE"); idx >= 0 { if idx := strings.IndexAny(value, "eE"); idx >= 0 {
value = value[:idx] value = value[:idx]
} }
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") { if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
value = value[1:] 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 return 0
} }
@@ -308,12 +305,15 @@ func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[s
if rule.Trigger != trigger { if rule.Trigger != trigger {
return false return false
} }
if rule.EffectiveFrom.After(bookedAt) { if rule.EffectiveFrom.After(bookedAt) {
return false return false
} }
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) { if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
return false return false
} }
return ruleMatchesAttributes(rule, attributes) return ruleMatchesAttributes(rule, attributes)
} }
@@ -325,6 +325,7 @@ func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
} }
} }
} }
return fallback, nil return fallback, nil
} }
@@ -333,17 +334,115 @@ func parseScale(field, value string) (uint32, error) {
if clean == "" { if clean == "" {
return 0, merrors.InvalidArgument(field + " is empty") return 0, merrors.InvalidArgument(field + " is empty")
} }
parsed, err := strconv.ParseUint(clean, 10, 32) parsed, err := strconv.ParseUint(clean, 10, 32)
if err != nil { if err != nil {
return 0, merrors.InvalidArgument("invalid " + field + " value") return 0, merrors.InvalidArgument("invalid " + field + " value")
} }
return uint32(parsed), nil 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 { func metadataValue(meta map[string]string, key string) string {
if meta == nil { if meta == nil {
return "" return ""
} }
return strings.TrimSpace(meta[key]) return strings.TrimSpace(meta[key])
} }
@@ -351,10 +450,10 @@ func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 { if len(src) == 0 {
return nil return nil
} }
cloned := make(map[string]string, len(src)) cloned := make(map[string]string, len(src))
for k, v := range src { maps.Copy(cloned, src)
cloned[k] = v
}
return cloned return cloned
} }
@@ -362,18 +461,22 @@ func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) boo
if len(rule.AppliesTo) == 0 { if len(rule.AppliesTo) == 0 {
return true return true
} }
for key, value := range rule.AppliesTo { for key, value := range rule.AppliesTo {
if attributes == nil { if attributes == nil {
return false return false
} }
attrValue, ok := attributes[key] attrValue, ok := attributes[key]
if !ok { if !ok {
return false return false
} }
if !matchesAttributeValue(value, attrValue) { if !matchesAttributeValue(value, attrValue) {
return false return false
} }
} }
return true return true
} }
@@ -383,16 +486,17 @@ func matchesAttributeValue(expected, actual string) bool {
return actual == "" return actual == ""
} }
values := strings.Split(trimmed, ",") for value := range strings.SplitSeq(trimmed, ",") {
for _, value := range values {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {
continue continue
} }
if value == "*" || value == actual { if value == "*" || value == actual {
return true return true
} }
} }
return false return false
} }
@@ -446,6 +550,8 @@ func mapRoundingMode(mode string) moneyv1.RoundingMode {
func convertTrigger(trigger feesv1.Trigger) model.Trigger { func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger { switch trigger {
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
return model.TriggerUnspecified
case feesv1.Trigger_TRIGGER_CAPTURE: case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND: case feesv1.Trigger_TRIGGER_REFUND:

View File

@@ -30,9 +30,11 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
if pf, ok := plans.(planFinder); ok { if pf, ok := plans.(planFinder); ok {
finder = pf finder = pf
} }
if logger == nil { if logger == nil {
logger = zap.NewNop() logger = zap.NewNop()
} }
return &feeResolver{ return &feeResolver{
plans: plans, plans: plans,
finder: finder, 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 { if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required") return nil, nil, merrors.InvalidArgument("fees: plans store is required")
} }
// Try org-specific first if provided. // Try org-specific first if provided.
if orgRef != nil && !orgRef.IsZero() { if isOrgRef(orgRef) {
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil { plan, rule, err := r.tryOrgRule(ctx, *orgRef, trigger, asOf, attrs)
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { if err != 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))
return nil, nil, err 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 err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) { if errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)), 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") return nil, nil, merrors.NoData("fees: no applicable fee rule found")
} }
r.logger.Warn("Failed resolving global fee plan", zap.Error(err)) r.logger.Warn("Failed resolving global fee plan", zap.Error(err))
return nil, nil, err return nil, nil, err
} }
rule, err := selectRule(plan, trigger, at, attrs) rule, err := selectRule(plan, trigger, asOf, attrs)
if err != nil { if err != nil {
if !errors.Is(err, ErrNoFeeRuleFound) { if !errors.Is(err, ErrNoFeeRuleFound) {
r.logger.Warn("Failed selecting rule in global plan", zap.Error(err)) r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
} else { } else {
globalFields := []zap.Field{ globalFields := zapFieldsForPlan(plan)
globalFields = append([]zap.Field{
zap.String("trigger", string(trigger)), zap.String("trigger", string(trigger)),
zap.Time("booked_at", at), zap.Time("booked_at", asOf),
zap.Any("attributes", attrs), zap.Any("attributes", attrs),
} }, globalFields...)
globalFields = append(globalFields, zapFieldsForPlan(plan)...)
r.logger.Debug("No matching rule in global plan", globalFields...) r.logger.Debug("No matching rule in global plan", globalFields...)
} }
return nil, nil, err 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.String("trigger", string(trigger)),
zap.Time("booked_at", at), zap.Time("booked_at", at),
zap.Any("attributes", attrs), 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), zap.Time("rule_effective_from", rule.EffectiveFrom),
} }
if rule.EffectiveTo != nil { 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) { func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
if r.finder != nil { if r.finder != nil {
return r.finder.FindActiveOrgPlan(ctx, orgRef, at) return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
} }
return r.plans.GetActivePlan(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 { if r.finder != nil {
return r.finder.FindActiveGlobalPlan(ctx, at) return r.finder.FindActiveGlobalPlan(ctx, asOf)
} }
// Treat zero ObjectID as global in legacy path. // 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 { if plan == nil {
return nil, merrors.NoData("fees: no applicable fee rule found") return nil, merrors.NoData("fees: no applicable fee rule found")
} }
var selected *model.FeeRule var (
var highestPriority int selected *model.FeeRule
highestPriority int
)
for _, rule := range plan.Rules { for _, rule := range plan.Rules {
if rule.Trigger != trigger { if !ruleIsActive(rule, trigger, asOf) {
continue
}
if rule.EffectiveFrom.After(at) {
continue
}
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
continue continue
} }
if !matchesAppliesTo(rule.AppliesTo, attrs) { if !matchesAppliesTo(rule.AppliesTo, attrs) {
continue continue
} }
if selected == nil || rule.Priority > highestPriority { if selected == nil || rule.Priority > highestPriority {
copy := rule matched := rule
selected = &copy selected = &matched
highestPriority = rule.Priority highestPriority = rule.Priority
continue continue
} }
if rule.Priority == highestPriority { if rule.Priority == highestPriority {
return nil, merrors.DataConflict("fees: conflicting fee rules") 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 { if selected == nil {
return nil, merrors.NoData("fees: no applicable fee rule found") return nil, merrors.NoData("fees: no applicable fee rule found")
} }
return selected, nil 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 { func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
if len(appliesTo) == 0 { if len(appliesTo) == 0 {
return true return true
} }
for key, value := range appliesTo { for key, value := range appliesTo {
if attrs == nil { if attrs == nil {
return false return false
} }
attrValue, ok := attrs[key] attrValue, ok := attrs[key]
if !ok { if !ok {
return false return false
} }
if !matchesAppliesValue(value, attrValue) { if !matchesAppliesValue(value, attrValue) {
return false return false
} }
} }
return true return true
} }
@@ -195,16 +261,17 @@ func matchesAppliesValue(expected, actual string) bool {
return actual == "" return actual == ""
} }
values := strings.Split(trimmed, ",") for value := range strings.SplitSeq(trimmed, ",") {
for _, value := range values {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {
continue continue
} }
if value == "*" || value == actual { if value == "*" || value == actual {
return true return true
} }
} }
return false return false
} }
@@ -212,6 +279,7 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
if plan == nil { if plan == nil {
return []zap.Field{zap.Bool("plan_present", false)} return []zap.Field{zap.Bool("plan_present", false)}
} }
fields := []zap.Field{ fields := []zap.Field{
zap.Bool("plan_present", true), zap.Bool("plan_present", true),
zap.Bool("plan_active", plan.Active), zap.Bool("plan_active", plan.Active),
@@ -223,13 +291,16 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
} else { } else {
fields = append(fields, zap.Bool("plan_effective_to_set", false)) fields = append(fields, zap.Bool("plan_effective_to_set", false))
} }
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() { if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef)) fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef))
} else { } else {
fields = append(fields, zap.Bool("plan_org_ref_set", false)) fields = append(fields, zap.Bool("plan_org_ref_set", false))
} }
if plan.GetID() != nil && !plan.GetID().IsZero() { if plan.GetID() != nil && !plan.GetID().IsZero() {
fields = append(fields, mzap.StorableRef(plan)) fields = append(fields, mzap.StorableRef(plan))
} }
return fields return fields
} }

View File

@@ -13,7 +13,7 @@ import (
) )
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) { func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
t.Helper() t.Parallel()
now := time.Now() now := time.Now()
globalPlan := &model.FeePlan{ globalPlan := &model.FeePlan{
@@ -28,6 +28,7 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
resolver := New(store, zap.NewNop()) resolver := New(store, zap.NewNop())
orgA := bson.NewObjectID() orgA := bson.NewObjectID()
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil) plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
if err != nil { if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err) 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() { if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex()) t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
} }
if rule.RuleID != "global_capture" { if rule.RuleID != "global_capture" {
t.Fatalf("unexpected rule selected: %s", rule.RuleID) t.Fatalf("unexpected rule selected: %s", rule.RuleID)
} }
} }
func TestResolver_OrgOverridesGlobal(t *testing.T) { func TestResolver_OrgOverridesGlobal(t *testing.T) {
t.Helper() t.Parallel()
now := time.Now() now := time.Now()
org := bson.NewObjectID() org := bson.NewObjectID()
@@ -67,22 +69,25 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("expected org plan rule, got error: %v", err) t.Fatalf("expected org plan rule, got error: %v", err)
} }
if rule.RuleID != "org_capture" { if rule.RuleID != "org_capture" {
t.Fatalf("expected org rule, got %s", rule.RuleID) t.Fatalf("expected org rule, got %s", rule.RuleID)
} }
otherOrg := bson.NewObjectID() otherOrg := bson.NewObjectID()
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil) _, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
if err != nil { if err != nil {
t.Fatalf("expected global fallback for other org, got error: %v", err) t.Fatalf("expected global fallback for other org, got error: %v", err)
} }
if rule.RuleID != "global_capture" { if rule.RuleID != "global_capture" {
t.Fatalf("expected global rule, got %s", rule.RuleID) t.Fatalf("expected global rule, got %s", rule.RuleID)
} }
} }
func TestResolver_SelectsHighestPriority(t *testing.T) { func TestResolver_SelectsHighestPriority(t *testing.T) {
t.Helper() t.Parallel()
now := time.Now() now := time.Now()
org := bson.NewObjectID() org := bson.NewObjectID()
@@ -103,6 +108,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("expected rule resolution, got error: %v", err) t.Fatalf("expected rule resolution, got error: %v", err)
} }
if rule.RuleID != "high" { if rule.RuleID != "high" {
t.Fatalf("expected highest priority rule, got %s", rule.RuleID) 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) { func TestResolver_EffectiveDateFiltering(t *testing.T) {
t.Helper() t.Parallel()
now := time.Now() now := time.Now()
org := bson.NewObjectID() org := bson.NewObjectID()
@@ -158,7 +164,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
} }
func TestResolver_AppliesToFiltering(t *testing.T) { func TestResolver_AppliesToFiltering(t *testing.T) {
t.Helper() t.Parallel()
now := time.Now() now := time.Now()
plan := &model.FeePlan{ plan := &model.FeePlan{
@@ -184,13 +190,14 @@ func TestResolver_AppliesToFiltering(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("expected default rule, got error: %v", err) t.Fatalf("expected default rule, got error: %v", err)
} }
if rule.RuleID != "default" { if rule.RuleID != "default" {
t.Fatalf("expected default rule, got %s", rule.RuleID) t.Fatalf("expected default rule, got %s", rule.RuleID)
} }
} }
func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) { func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
t.Helper() t.Parallel()
now := time.Now() now := time.Now()
plan := &model.FeePlan{ plan := &model.FeePlan{
@@ -231,7 +238,7 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
} }
func TestResolver_MissingTriggerReturnsErr(t *testing.T) { func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
t.Helper() t.Parallel()
now := time.Now() now := time.Now()
plan := &model.FeePlan{ plan := &model.FeePlan{
@@ -250,28 +257,28 @@ func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
} }
func TestResolver_MultipleActivePlansConflict(t *testing.T) { func TestResolver_MultipleActivePlansConflict(t *testing.T) {
t.Helper() t.Parallel()
now := time.Now() now := time.Now()
org := bson.NewObjectID() org := bson.NewObjectID()
p1 := &model.FeePlan{ plan1 := &model.FeePlan{
Active: true, Active: true,
EffectiveFrom: now.Add(-time.Hour), EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{ Rules: []model.FeeRule{
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
p1.OrganizationRef = &org plan1.OrganizationRef = &org
p2 := &model.FeePlan{ plan2 := &model.FeePlan{
Active: true, Active: true,
EffectiveFrom: now.Add(-30 * time.Minute), EffectiveFrom: now.Add(-30 * time.Minute),
Rules: []model.FeeRule{ Rules: []model.FeeRule{
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, {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()) resolver := New(store, zap.NewNop())
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) { 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 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 !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 return plan, nil
} else if !errors.Is(err, storage.ErrFeePlanNotFound) { } else if !errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, err 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 var matches []*model.FeePlan
for _, plan := range m.plans { for _, plan := range m.plans {
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) { if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
continue continue
@@ -309,12 +318,14 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.Obje
if !plan.Active { if !plan.Active {
continue continue
} }
if plan.EffectiveFrom.After(at) { if plan.EffectiveFrom.After(asOf) {
continue continue
} }
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
continue continue
} }
matches = append(matches, plan) matches = append(matches, plan)
} }
if len(matches) == 0 { if len(matches) == 0 {
@@ -323,24 +334,28 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.Obje
if len(matches) > 1 { if len(matches) > 1 {
return nil, storage.ErrConflictingFeePlans return nil, storage.ErrConflictingFeePlans
} }
return matches[0], nil 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 var matches []*model.FeePlan
for _, plan := range m.plans { for _, plan := range m.plans {
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) { if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
continue continue
} }
if !plan.Active { if !plan.Active {
continue continue
} }
if plan.EffectiveFrom.After(at) { if plan.EffectiveFrom.After(asOf) {
continue continue
} }
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) { if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
continue continue
} }
matches = append(matches, plan) matches = append(matches, plan)
} }
if len(matches) == 0 { if len(matches) == 0 {
@@ -349,6 +364,7 @@ func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time)
if len(matches) > 1 { if len(matches) > 1 {
return nil, storage.ErrConflictingFeePlans return nil, storage.ErrConflictingFeePlans
} }
return matches[0], nil return matches[0], nil
} }

View File

@@ -12,6 +12,7 @@ import (
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field { func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
fields := logFieldsFromRequestMeta(meta) fields := logFieldsFromRequestMeta(meta)
fields = append(fields, logFieldsFromIntent(intent)...) fields = append(fields, logFieldsFromIntent(intent)...)
return fields return fields
} }
@@ -19,11 +20,14 @@ func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
if meta == nil { if meta == nil {
return nil return nil
} }
fields := make([]zap.Field, 0, 4) fields := make([]zap.Field, 0, 4)
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" { if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
fields = append(fields, zap.String("organization_ref", org)) fields = append(fields, zap.String("organization_ref", org))
} }
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...) fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
return fields return fields
} }
@@ -31,24 +35,30 @@ func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
if intent == nil { if intent == nil {
return nil return nil
} }
fields := make([]zap.Field, 0, 5) fields := make([]zap.Field, 0, 5)
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED { if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
fields = append(fields, zap.String("trigger", trigger.String())) fields = append(fields, zap.String("trigger", trigger.String()))
} }
if base := intent.GetBaseAmount(); base != nil { if base := intent.GetBaseAmount(); base != nil {
if amount := strings.TrimSpace(base.GetAmount()); amount != "" { if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
fields = append(fields, zap.String("base_amount", amount)) fields = append(fields, zap.String("base_amount", amount))
} }
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" { if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
fields = append(fields, zap.String("base_currency", currency)) fields = append(fields, zap.String("base_currency", currency))
} }
} }
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() { if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
fields = append(fields, zap.Time("booked_at", booked.AsTime())) fields = append(fields, zap.Time("booked_at", booked.AsTime()))
} }
if attrs := intent.GetAttributes(); len(attrs) > 0 { if attrs := intent.GetAttributes(); len(attrs) > 0 {
fields = append(fields, zap.Int("attributes_count", len(attrs))) fields = append(fields, zap.Int("attributes_count", len(attrs)))
} }
return fields return fields
} }
@@ -56,16 +66,20 @@ func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
if trace == nil { if trace == nil {
return nil return nil
} }
fields := make([]zap.Field, 0, 3) fields := make([]zap.Field, 0, 3)
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" { if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
fields = append(fields, zap.String("request_ref", reqRef)) fields = append(fields, zap.String("request_ref", reqRef))
} }
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" { if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
fields = append(fields, zap.String("idempotency_key", idem)) fields = append(fields, zap.String("idempotency_key", idem))
} }
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" { if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
fields = append(fields, zap.String("trace_ref", traceRef)) fields = append(fields, zap.String("trace_ref", traceRef))
} }
return fields return fields
} }
@@ -73,16 +87,20 @@ func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
if payload == nil { if payload == nil {
return nil return nil
} }
fields := make([]zap.Field, 0, 6) fields := make([]zap.Field, 0, 6)
if org := strings.TrimSpace(payload.OrganizationRef); org != "" { if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
fields = append(fields, zap.String("organization_ref", org)) fields = append(fields, zap.String("organization_ref", org))
} }
if payload.ExpiresAtUnixMs > 0 { if payload.ExpiresAtUnixMs > 0 {
fields = append(fields, fields = append(fields,
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs), zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs))) zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
} }
fields = append(fields, logFieldsFromIntent(payload.Intent)...) fields = append(fields, logFieldsFromIntent(payload.Intent)...)
fields = append(fields, logFieldsFromTrace(payload.Trace)...) fields = append(fields, logFieldsFromTrace(payload.Trace)...)
return fields return fields
} }

View File

@@ -50,6 +50,7 @@ func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxU
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED { if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
triggerLabel = "TRIGGER_UNSPECIFIED" triggerLabel = "TRIGGER_UNSPECIFIED"
} }
fxLabel := strconv.FormatBool(fxUsed) fxLabel := strconv.FormatBool(fxUsed)
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc() quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds()) quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
@@ -59,13 +60,16 @@ func statusFromError(err error) string {
if err == nil { if err == nil {
return "success" return "success"
} }
st, ok := status.FromError(err) st, ok := status.FromError(err)
if !ok { if !ok {
return "error" return "error"
} }
code := st.Code() code := st.Code()
if code == codes.OK { if code == codes.OK {
return "success" return "success"
} }
return strings.ToLower(code.String()) return strings.ToLower(code.String())
} }

View File

@@ -5,7 +5,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -33,6 +32,8 @@ import (
) )
type Service struct { type Service struct {
feesv1.UnimplementedFeeEngineServer
logger mlogger.Logger logger mlogger.Logger
storage storage.Repository storage storage.Repository
producer msg.Producer producer msg.Producer
@@ -42,7 +43,6 @@ type Service struct {
resolver FeeResolver resolver FeeResolver
announcer *discovery.Announcer announcer *discovery.Announcer
invokeURI string invokeURI string
feesv1.UnimplementedFeeEngineServer
} }
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service { 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, producer: producer,
clock: clockpkg.NewSystem(), clock: clockpkg.NewSystem(),
} }
initMetrics() initMetrics()
for _, opt := range opts { for _, opt := range opts {
@@ -61,9 +62,11 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
if svc.clock == nil { if svc.clock == nil {
svc.clock = clockpkg.NewSystem() svc.clock = clockpkg.NewSystem()
} }
if svc.calculator == nil { if svc.calculator == nil {
svc.calculator = internalcalculator.New(svc.logger, svc.oracle) svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
} }
if svc.resolver == nil { if svc.resolver == nil {
svc.resolver = resolver.New(repo.Plans(), svc.logger) svc.resolver = resolver.New(repo.Plans(), svc.logger)
} }
@@ -83,25 +86,12 @@ func (s *Service) Shutdown() {
if s == nil { if s == nil {
return return
} }
if s.announcer != nil { if s.announcer != nil {
s.announcer.Stop() 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) { func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
var ( var (
meta *feesv1.RequestMeta meta *feesv1.RequestMeta
@@ -111,23 +101,29 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
meta = req.GetMeta() meta = req.GetMeta()
intent = req.GetIntent() intent = req.GetIntent()
} }
logger := s.logger.With(requestLogFields(meta, intent)...) logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if intent != nil { if intent != nil {
trigger = intent.GetTrigger() trigger = intent.GetTrigger()
} }
var fxUsed bool var fxUsed bool
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
linesCount := 0 linesCount := 0
appliedCount := 0 appliedCount := 0
if err == nil && resp != nil { if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines()) linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied()) appliedCount = len(resp.GetApplied())
} }
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start)) observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{ logFields := []zap.Field{
@@ -140,8 +136,10 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
} }
if err != nil { if err != nil {
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...) logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
return return
} }
logger.Info("QuoteFees finished", logFields...) logger.Info("QuoteFees finished", logFields...)
}() }()
@@ -155,12 +153,14 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
if parseErr != nil { if parseErr != nil {
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr)) logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref") err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err 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 { if computeErr != nil {
err = computeErr err = computeErr
return nil, err return nil, err
} }
@@ -168,8 +168,9 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()}, Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
Lines: lines, Lines: lines,
Applied: applied, Applied: applied,
FxUsed: fx, FxUsed: fxResult,
} }
return resp, nil return resp, nil
} }
@@ -182,48 +183,17 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
meta = req.GetMeta() meta = req.GetMeta()
intent = req.GetIntent() intent = req.GetIntent()
} }
logger := s.logger.With(requestLogFields(meta, intent)...) logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if intent != nil { if intent != nil {
trigger = intent.GetTrigger() 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{ defer func() { s.observePrecomputeFees(logger, err, resp, trigger, start) }()
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...)
}()
logger.Debug("PrecomputeFees request received") logger.Debug("PrecomputeFees request received")
@@ -237,12 +207,14 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if parseErr != nil { if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr)) logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref") err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err 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 { if computeErr != nil {
err = computeErr err = computeErr
return nil, err return nil, err
} }
@@ -250,7 +222,8 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if ttl <= 0 { if ttl <= 0 {
ttl = 60000 ttl = 60000
} }
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{ payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(), 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 { if token, err = encodeTokenPayload(payload); err != nil {
logger.Warn("Failed to encode fee quote token", zap.Error(err)) logger.Warn("Failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token") err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err return nil, err
} }
@@ -272,8 +246,9 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
ExpiresAt: timestamppb.New(expiresAt), ExpiresAt: timestamppb.New(expiresAt),
Lines: lines, Lines: lines,
Applied: applied, Applied: applied,
FxUsed: fx, FxUsed: fxResult,
} }
return resp, nil return resp, nil
} }
@@ -282,49 +257,23 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
if req != nil { if req != nil {
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken())) tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
} }
logger := s.logger.With(zap.Int("token_length", tokenLen)) logger := s.logger.With(zap.Int("token_length", tokenLen))
start := s.clock.Now() 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{ trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)), var resultReason string
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()), defer func() { s.observeValidateFeeToken(logger, err, resp, trigger, resultReason, start) }()
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...)
}()
logger.Debug("ValidateFeeToken request received") logger.Debug("ValidateFeeToken request received")
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" { if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token" resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required") err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err return nil, err
} }
@@ -333,8 +282,11 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken()) payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil { if decodeErr != nil {
resultReason = "invalid_token" resultReason = "invalid_token"
logger.Warn("Failed to decode fee quote token", zap.Error(decodeErr)) logger.Warn("Failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil return resp, nil
} }
@@ -346,22 +298,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
if now.UnixMilli() > payload.ExpiresAtUnixMs { if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired" resultReason = "expired"
logger.Info("Fee quote token expired") logger.Info("Fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil return resp, nil
} }
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef) orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil { if parseErr != nil {
resultReason = "invalid_token" resultReason = "invalid_token"
logger.Warn("Token contained invalid organization reference", zap.Error(parseErr)) logger.Warn("Token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil 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 { if computeErr != nil {
err = computeErr err = computeErr
return nil, err return nil, err
} }
@@ -371,8 +330,9 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
Intent: payload.Intent, Intent: payload.Intent,
Lines: lines, Lines: lines,
Applied: applied, Applied: applied,
FxUsed: fx, FxUsed: fxResult,
} }
return resp, nil return resp, nil
} }
@@ -380,24 +340,31 @@ func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
if req == nil { if req == nil {
return status.Error(codes.InvalidArgument, "request is required") return status.Error(codes.InvalidArgument, "request is required")
} }
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" { if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
return status.Error(codes.InvalidArgument, "meta.organization_ref is required") return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
} }
if req.GetIntent() == nil { if req.GetIntent() == nil {
return status.Error(codes.InvalidArgument, "intent is required") return status.Error(codes.InvalidArgument, "intent is required")
} }
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED { if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
return status.Error(codes.InvalidArgument, "intent.trigger is required") return status.Error(codes.InvalidArgument, "intent.trigger is required")
} }
if req.GetIntent().GetBaseAmount() == nil { if req.GetIntent().GetBaseAmount() == nil {
return status.Error(codes.InvalidArgument, "intent.base_amount is required") return status.Error(codes.InvalidArgument, "intent.base_amount is required")
} }
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" { if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required") return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
} }
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" { if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required") return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
} }
return nil return nil
} }
@@ -405,6 +372,7 @@ func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) e
if req == nil { if req == nil {
return status.Error(codes.InvalidArgument, "request is required") return status.Error(codes.InvalidArgument, "request is required")
} }
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()}) 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) { 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 bookedAt := resolvedBookedAt(intent, now)
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
bookedAt = intent.GetBookedAt().AsTime()
}
logFields := []zap.Field{ logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)}
zap.Time("booked_at_used", bookedAt),
}
if !orgRef.IsZero() { if !orgRef.IsZero() {
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex())) logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
} }
logFields = append(logFields, logFieldsFromIntent(intent)...) logFields = append(logFields, logFieldsFromIntent(intent)...)
logFields = append(logFields, logFieldsFromTrace(trace)...) logFields = append(logFields, logFieldsFromTrace(trace)...)
logger := s.logger.With(logFields...) 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()) plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil { if err != nil {
s.logger.Warn("Failed to resolve fee rule", zap.Error(err)) s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
switch {
case errors.Is(err, merrors.ErrNoData): return nil, nil, nil, mapResolveError(err)
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()))
}
} }
originalRules := plan.Rules originalRules := plan.Rules
plan.Rules = []model.FeeRule{*rule} plan.Rules = []model.FeeRule{*rule}
defer func() { defer func() {
plan.Rules = originalRules plan.Rules = originalRules
}() }()
@@ -461,13 +416,115 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
if errors.Is(calcErr, merrors.ErrInvalidArg) { if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error()) return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
} }
logger.Warn("Failed to compute fee quote", zap.Error(calcErr)) logger.Warn("Failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote") return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
} }
return result.Lines, result.Applied, result.FxUsed, nil 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 { type feeQuoteTokenPayload struct {
OrganizationRef string `json:"organization_ref"` OrganizationRef string `json:"organization_ref"`
Intent *feesv1.Intent `json:"intent"` Intent *feesv1.Intent `json:"intent"`
@@ -480,17 +537,36 @@ func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
if err != nil { if err != nil {
return "", merrors.Internal("fees: failed to serialize token payload") return "", merrors.Internal("fees: failed to serialize token payload")
} }
return base64.StdEncoding.EncodeToString(data), nil return base64.StdEncoding.EncodeToString(data), nil
} }
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) { func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
var payload feeQuoteTokenPayload var payload feeQuoteTokenPayload
data, err := base64.StdEncoding.DecodeString(token) data, err := base64.StdEncoding.DecodeString(token)
if err != nil { if err != nil {
return payload, merrors.InvalidArgument("fees: invalid token encoding") return payload, merrors.InvalidArgument("fees: invalid token encoding")
} }
if err := json.Unmarshal(data, &payload); err != nil { if err := json.Unmarshal(data, &payload); err != nil {
return payload, merrors.InvalidArgument("fees: invalid token payload") return payload, merrors.InvalidArgument("fees: invalid token payload")
} }
return payload, nil 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()
}

View File

@@ -20,7 +20,7 @@ import (
) )
func TestQuoteFees_ComputesDerivedLines(t *testing.T) { func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
t.Helper() t.Parallel()
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC) now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
orgRef := bson.NewObjectID() orgRef := bson.NewObjectID()
@@ -120,7 +120,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
} }
func TestQuoteFees_FiltersByAttributesAndDates(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) now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
orgRef := bson.NewObjectID() orgRef := bson.NewObjectID()
@@ -192,6 +192,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
if len(resp.GetLines()) != 1 { if len(resp.GetLines()) != 1 {
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines())) t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
} }
line := resp.GetLines()[0] line := resp.GetLines()[0]
if line.GetLedgerAccountRef() != "acct:base" { if line.GetLedgerAccountRef() != "acct:base" {
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef()) 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) { func TestQuoteFees_RoundingDown(t *testing.T) {
t.Helper() t.Parallel()
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC) now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
orgRef := bson.NewObjectID() orgRef := bson.NewObjectID()
@@ -258,7 +259,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
} }
func TestQuoteFees_UsesInjectedCalculator(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) now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
orgRef := bson.NewObjectID() orgRef := bson.NewObjectID()
@@ -331,7 +332,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
} }
func TestQuoteFees_PopulatesFxUsed(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) now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
orgRef := bson.NewObjectID() orgRef := bson.NewObjectID()
@@ -356,7 +357,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
plan.OrganizationRef = &orgRef plan.OrganizationRef = &orgRef
fakeOracle := &oracleclient.Fake{ 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{ return &oracleclient.RateSnapshot{
Pair: req.Pair, Pair: req.Pair,
Mid: "1.2300", Mid: "1.2300",
@@ -399,6 +400,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
if resp.GetFxUsed() == nil { if resp.GetFxUsed() == nil {
t.Fatalf("expected FxUsed to be populated") t.Fatalf("expected FxUsed to be populated")
} }
fx := resp.GetFxUsed() fx := resp.GetFxUsed()
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" { if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
t.Fatalf("unexpected FxUsed payload: %+v", fx) 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 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 !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 return plan, nil
} else if !errors.Is(err, storage.ErrFeePlanNotFound) { } else if !errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, err 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 { if s.plan == nil {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
@@ -458,28 +460,30 @@ func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.Object
if !s.plan.Active { if !s.plan.Active {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if s.plan.EffectiveFrom.After(at) { if s.plan.EffectiveFrom.After(asOf) {
return nil, storage.ErrFeePlanNotFound 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 nil, storage.ErrFeePlanNotFound
} }
return s.plan, nil 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 { if s.globalPlan == nil {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if !s.globalPlan.Active { if !s.globalPlan.Active {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if s.globalPlan.EffectiveFrom.After(at) { if s.globalPlan.EffectiveFrom.After(asOf) {
return nil, storage.ErrFeePlanNotFound 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 nil, storage.ErrFeePlanNotFound
} }
return s.globalPlan, nil return s.globalPlan, nil
} }
@@ -509,8 +513,10 @@ func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *fees
s.called = true s.called = true
s.gotPlan = plan s.gotPlan = plan
s.bookedAt = bookedAt s.bookedAt = bookedAt
if s.err != nil { if s.err != nil {
return nil, s.err return nil, s.err
} }
return s.result, nil return s.result, nil
} }

View File

@@ -7,6 +7,8 @@ import (
func convertTrigger(trigger feesv1.Trigger) model.Trigger { func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger { switch trigger {
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
return model.TriggerUnspecified
case feesv1.Trigger_TRIGGER_CAPTURE: case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND: case feesv1.Trigger_TRIGGER_REFUND:

View File

@@ -28,12 +28,13 @@ const (
type FeePlan struct { type FeePlan struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"` model.Describable `bson:",inline" json:",inline"`
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
Active bool `bson:"active" json:"active"` OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` Active bool `bson:"active" json:"active"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"` EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
} }
// Collection implements storable.Storable. // Collection implements storable.Storable.
@@ -43,21 +44,21 @@ func (*FeePlan) Collection() string {
// FeeRule represents a single pricing rule within a plan. // FeeRule represents a single pricing rule within a plan.
type FeeRule struct { type FeeRule struct {
RuleID string `bson:"ruleId" json:"ruleId"` RuleID string `bson:"ruleId" json:"ruleId"`
Trigger Trigger `bson:"trigger" json:"trigger"` Trigger Trigger `bson:"trigger" json:"trigger"`
Priority int `bson:"priority" json:"priority"` Priority int `bson:"priority" json:"priority"`
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"` Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"` FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"` Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"` MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"` MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"` AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"` Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"` LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"` LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"` EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"` Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
} }

View File

@@ -44,17 +44,21 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
if err := result.Ping(ctx); err != nil { if err := result.Ping(ctx); err != nil {
result.logger.Error("Mongo ping failed during store init", zap.Error(err)) result.logger.Error("Mongo ping failed during store init", zap.Error(err))
return nil, err return nil, err
} }
plansStore, err := store.NewPlans(result.logger, database) plansStore, err := store.NewPlans(result.logger, database)
if err != nil { if err != nil {
result.logger.Error("Failed to initialise plans store", zap.Error(err)) result.logger.Error("Failed to initialise plans store", zap.Error(err))
return nil, err return nil, err
} }
result.plans = plansStore result.plans = plansStore
result.logger.Info("Billing fees MongoDB storage initialised") result.logger.Info("Billing fees MongoDB storage initialised")
return result, nil return result, nil
} }

View File

@@ -28,6 +28,8 @@ type plansStore struct {
repo repository.Repository repo repository.Repository
} }
const maxActivePlanResults = 2
// NewPlans constructs a Mongo-backed PlansStore. // NewPlans constructs a Mongo-backed PlansStore.
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) { func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
repo := repository.CreateMongoRepository(db, mservice.FeePlans) repo := repository.CreateMongoRepository(db, mservice.FeePlans)
@@ -41,6 +43,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
} }
if err := repo.CreateIndex(orgIndex); err != nil { if err := repo.CreateIndex(orgIndex); err != nil {
logger.Error("Failed to ensure fee plan organization index", zap.Error(err)) logger.Error("Failed to ensure fee plan organization index", zap.Error(err))
return nil, err return nil, err
} }
@@ -54,6 +57,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
} }
if err := repo.CreateIndex(uniqueIndex); err != nil { if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("Failed to ensure fee plan uniqueness index", zap.Error(err)) logger.Error("Failed to ensure fee plan uniqueness index", zap.Error(err))
return nil, err return nil, err
} }
@@ -80,6 +84,7 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
if err := validatePlan(plan); err != nil { if err := validatePlan(plan); err != nil {
return err return err
} }
if err := p.ensureNoOverlap(ctx, plan); err != nil { if err := p.ensureNoOverlap(ctx, plan); err != nil {
return err return err
} }
@@ -88,9 +93,12 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateFeePlan return storage.ErrDuplicateFeePlan
} }
p.logger.Warn("Failed to create fee plan", zap.Error(err)) p.logger.Warn("Failed to create fee plan", zap.Error(err))
return err return err
} }
return nil return nil
} }
@@ -98,17 +106,21 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() { if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
return merrors.InvalidArgument("plansStore: invalid fee plan reference") return merrors.InvalidArgument("plansStore: invalid fee plan reference")
} }
if err := validatePlan(plan); err != nil { if err := validatePlan(plan); err != nil {
return err return err
} }
if err := p.ensureNoOverlap(ctx, plan); err != nil { if err := p.ensureNoOverlap(ctx, plan); err != nil {
return err return err
} }
if err := p.repo.Update(ctx, plan); err != nil { if err := p.repo.Update(ctx, plan); err != nil {
p.logger.Warn("Failed to update fee plan", zap.Error(err)) p.logger.Warn("Failed to update fee plan", zap.Error(err))
return err return err
} }
return nil return nil
} }
@@ -116,72 +128,83 @@ func (p *plansStore) Get(ctx context.Context, planRef bson.ObjectID) (*model.Fee
if planRef.IsZero() { if planRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero plan reference") return nil, merrors.InvalidArgument("plansStore: zero plan reference")
} }
result := &model.FeePlan{} result := &model.FeePlan{}
if err := p.repo.Get(ctx, planRef, result); err != nil { if err := p.repo.Get(ctx, planRef, result); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
return nil, err return nil, err
} }
return result, nil return result, nil
} }
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) { func (p *plansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global. // Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
if orgRef.IsZero() { if orgRef.IsZero() {
return p.FindActiveGlobalPlan(ctx, at) return p.FindActiveGlobalPlan(ctx, asOf)
} }
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at) plan, err := p.FindActiveOrgPlan(ctx, orgRef, asOf)
if err == nil { if err == nil {
return plan, nil return plan, nil
} }
if errors.Is(err, storage.ErrFeePlanNotFound) { if errors.Is(err, storage.ErrFeePlanNotFound) {
return p.FindActiveGlobalPlan(ctx, at) return p.FindActiveGlobalPlan(ctx, asOf)
} }
return nil, err return nil, err
} }
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) { func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
if orgRef.IsZero() { if orgRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero organization reference") return nil, merrors.InvalidArgument("plansStore: zero organization reference")
} }
query := repository.Query().Filter(repository.OrgField(), orgRef) query := repository.Query().Filter(repository.OrgField(), orgRef)
return p.findActivePlan(ctx, query, at)
return p.findActivePlan(ctx, query, asOf)
} }
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) { func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) {
globalQuery := repository.Query().Or( globalQuery := repository.Query().Or(
repository.Exists(repository.OrgField(), false), repository.Exists(repository.OrgField(), false),
repository.Query().Filter(repository.OrgField(), nil), repository.Query().Filter(repository.OrgField(), nil),
) )
return p.findActivePlan(ctx, globalQuery, at)
return p.findActivePlan(ctx, globalQuery, asOf)
} }
var _ storage.PlansStore = (*plansStore)(nil) var _ storage.PlansStore = (*plansStore)(nil)
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) { func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, asOf time.Time) (*model.FeePlan, error) {
limit := int64(2) limit := int64(maxActivePlanResults)
query := orgQuery. query := orgQuery.
Filter(repository.Field("active"), true). Filter(repository.Field("active"), true).
Comparison(repository.Field("effectiveFrom"), builder.Lte, at). Comparison(repository.Field("effectiveFrom"), builder.Lte, asOf).
Sort(repository.Field("effectiveFrom"), false). Sort(repository.Field("effectiveFrom"), false).
Limit(&limit) Limit(&limit)
query = query.And( query = query.And(
repository.Query().Or( repository.Query().Or(
repository.Query().Filter(repository.Field("effectiveTo"), nil), repository.Query().Filter(repository.Field("effectiveTo"), nil),
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, at), repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, asOf),
), ),
) )
var plans []*model.FeePlan var plans []*model.FeePlan
decoder := func(cursor *mongo.Cursor) error { decoder := func(cursor *mongo.Cursor) error {
target := &model.FeePlan{} target := &model.FeePlan{}
if err := cursor.Decode(target); err != nil { if err := cursor.Decode(target); err != nil {
return err return err
} }
plans = append(plans, target) plans = append(plans, target)
return nil return nil
} }
@@ -189,15 +212,18 @@ func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query,
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
return nil, err return nil, err
} }
if len(plans) == 0 { if len(plans) == 0 {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if len(plans) > 1 { if len(plans) > 1 {
return nil, storage.ErrConflictingFeePlans return nil, storage.ErrConflictingFeePlans
} }
return plans[0], nil return plans[0], nil
} }
@@ -205,44 +231,61 @@ func validatePlan(plan *model.FeePlan) error {
if plan == nil { if plan == nil {
return merrors.InvalidArgument("plansStore: nil fee plan") return merrors.InvalidArgument("plansStore: nil fee plan")
} }
if len(plan.Rules) == 0 { if len(plan.Rules) == 0 {
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule") return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
} }
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) { if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom") return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
} }
// Ensure unique priority per (trigger, appliesTo) combination. // Ensure unique priority per (trigger, appliesTo) combination.
seen := make(map[string]struct{}) seen := make(map[string]struct{})
for _, rule := range plan.Rules { for _, rule := range plan.Rules {
if strings.TrimSpace(rule.Percentage) != "" { if err := validateRule(rule); err != nil {
if _, err := dmath.RatFromString(rule.Percentage); err != nil { return err
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) appliesKey := normalizeAppliesTo(rule.AppliesTo)
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey) priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
if _, ok := seen[priorityKey]; ok { if _, ok := seen[priorityKey]; ok {
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo") return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
} }
seen[priorityKey] = struct{}{} seen[priorityKey] = struct{}{}
} }
return nil
}
func validateRule(rule model.FeeRule) error {
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")
}
}
return nil return nil
} }
@@ -250,15 +293,19 @@ func normalizeAppliesTo(applies map[string]string) string {
if len(applies) == 0 { if len(applies) == 0 {
return "" return ""
} }
keys := make([]string, 0, len(applies)) keys := make([]string, 0, len(applies))
for k := range applies { for k := range applies {
keys = append(keys, k) keys = append(keys, k)
} }
sort.Strings(keys) sort.Strings(keys)
parts := make([]string, 0, len(keys)) parts := make([]string, 0, len(keys))
for _, k := range keys { for _, k := range keys {
parts = append(parts, k+"="+normalizeAppliesToValue(applies[k])) parts = append(parts, k+"="+normalizeAppliesToValue(applies[k]))
} }
return strings.Join(parts, ",") return strings.Join(parts, ",")
} }
@@ -272,28 +319,37 @@ func normalizeAppliesToValue(value string) string {
seen := make(map[string]struct{}, len(values)) seen := make(map[string]struct{}, len(values))
normalized := make([]string, 0, len(values)) normalized := make([]string, 0, len(values))
hasWildcard := false hasWildcard := false
for _, value := range values { for _, value := range values {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {
continue continue
} }
if value == "*" { if value == "*" {
hasWildcard = true hasWildcard = true
continue continue
} }
if _, ok := seen[value]; ok { if _, ok := seen[value]; ok {
continue continue
} }
seen[value] = struct{}{} seen[value] = struct{}{}
normalized = append(normalized, value) normalized = append(normalized, value)
} }
if hasWildcard { if hasWildcard {
return "*" return "*"
} }
if len(normalized) == 0 { if len(normalized) == 0 {
return "" return ""
} }
sort.Strings(normalized) sort.Strings(normalized)
return strings.Join(normalized, ",") return strings.Join(normalized, ",")
} }
@@ -302,7 +358,7 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
return nil return nil
} }
orgQuery := repository.Query() var orgQuery builder.Query
if plan.OrganizationRef.IsZero() { if plan.OrganizationRef.IsZero() {
orgQuery = repository.Query().Or( orgQuery = repository.Query().Or(
repository.Exists(repository.OrgField(), false), repository.Exists(repository.OrgField(), false),
@@ -314,6 +370,7 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
newFrom := plan.EffectiveFrom newFrom := plan.EffectiveFrom
newTo := maxTime newTo := maxTime
if plan.EffectiveTo != nil { if plan.EffectiveTo != nil {
newTo = *plan.EffectiveTo newTo = *plan.EffectiveTo
@@ -335,8 +392,10 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
query = query.Limit(&limit) query = query.Limit(&limit)
var overlapFound bool var overlapFound bool
decoder := func(cursor *mongo.Cursor) error {
decoder := func(_ *mongo.Cursor) error {
overlapFound = true overlapFound = true
return nil return nil
} }
@@ -344,10 +403,13 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
return nil return nil
} }
return err return err
} }
if overlapFound { if overlapFound {
return storage.ErrConflictingFeePlans return storage.ErrConflictingFeePlans
} }
return nil return nil
} }

View File

@@ -163,6 +163,7 @@ linters:
# Exclude some linters from running on tests files. # Exclude some linters from running on tests files.
- path: _test\.go - path: _test\.go
linters: linters:
- funlen
- gocyclo - gocyclo
- errcheck - errcheck
- dupl - dupl

View File

@@ -163,6 +163,7 @@ linters:
# Exclude some linters from running on tests files. # Exclude some linters from running on tests files.
- path: _test\.go - path: _test\.go
linters: linters:
- funlen
- gocyclo - gocyclo
- errcheck - errcheck
- dupl - dupl

View File

@@ -19,11 +19,13 @@ func (s *svc) Fingerprint(in FPInput) (string, error) {
if quotationRef == "" { if quotationRef == "" {
return "", merrors.InvalidArgument("quotation_ref is required") return "", merrors.InvalidArgument("quotation_ref is required")
} }
intentRef := strings.TrimSpace(in.IntentRef)
clientPaymentRef := strings.TrimSpace(in.ClientPaymentRef) clientPaymentRef := strings.TrimSpace(in.ClientPaymentRef)
payload := strings.Join([]string{ payload := strings.Join([]string{
"org=" + orgRef, "org=" + orgRef,
"quote=" + quotationRef, "quote=" + quotationRef,
"intent=" + intentRef,
"client=" + clientPaymentRef, "client=" + clientPaymentRef,
}, hashSep) }, hashSep)

View File

@@ -24,6 +24,7 @@ type Service interface {
type FPInput struct { type FPInput struct {
OrganizationRef string OrganizationRef string
QuotationRef string QuotationRef string
IntentRef string
ClientPaymentRef string ClientPaymentRef string
} }

View File

@@ -16,6 +16,7 @@ func TestFingerprint_StableAndTrimmed(t *testing.T) {
a, err := svc.Fingerprint(FPInput{ a, err := svc.Fingerprint(FPInput{
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ", OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
QuotationRef: " quote-1 ", QuotationRef: " quote-1 ",
IntentRef: " intent-1 ",
ClientPaymentRef: " client-1 ", ClientPaymentRef: " client-1 ",
}) })
if err != nil { if err != nil {
@@ -24,6 +25,7 @@ func TestFingerprint_StableAndTrimmed(t *testing.T) {
b, err := svc.Fingerprint(FPInput{ b, err := svc.Fingerprint(FPInput{
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4", OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
QuotationRef: "quote-1", QuotationRef: "quote-1",
IntentRef: "intent-1",
ClientPaymentRef: "client-1", ClientPaymentRef: "client-1",
}) })
if err != nil { if err != nil {
@@ -40,6 +42,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
base, err := svc.Fingerprint(FPInput{ base, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-1", QuotationRef: "quote-1",
IntentRef: "intent-1",
ClientPaymentRef: "client-1", ClientPaymentRef: "client-1",
}) })
if err != nil { if err != nil {
@@ -49,6 +52,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
diffQuote, err := svc.Fingerprint(FPInput{ diffQuote, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-2", QuotationRef: "quote-2",
IntentRef: "intent-1",
ClientPaymentRef: "client-1", ClientPaymentRef: "client-1",
}) })
if err != nil { if err != nil {
@@ -61,6 +65,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
diffClient, err := svc.Fingerprint(FPInput{ diffClient, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-1", QuotationRef: "quote-1",
IntentRef: "intent-1",
ClientPaymentRef: "client-2", ClientPaymentRef: "client-2",
}) })
if err != nil { if err != nil {
@@ -69,6 +74,19 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
if base == diffClient { if base == diffClient {
t.Fatalf("expected different fingerprint for different client_payment_ref") t.Fatalf("expected different fingerprint for different client_payment_ref")
} }
diffIntent, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-1",
IntentRef: "intent-2",
ClientPaymentRef: "client-1",
})
if err != nil {
t.Fatalf("Fingerprint returned error: %v", err)
}
if base == diffIntent {
t.Fatalf("expected different fingerprint for different intent_ref")
}
} }
func TestFingerprint_RequiresBusinessFields(t *testing.T) { func TestFingerprint_RequiresBusinessFields(t *testing.T) {

View File

@@ -7,4 +7,6 @@ var (
ErrQuoteExpired = errors.New("quotation_ref expired") ErrQuoteExpired = errors.New("quotation_ref expired")
ErrQuoteNotExecutable = errors.New("quotation_ref is not executable") ErrQuoteNotExecutable = errors.New("quotation_ref is not executable")
ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch") ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch")
ErrIntentRefRequired = errors.New("intent_ref is required for batch quotation")
ErrIntentRefNotFound = errors.New("intent_ref not found in quotation")
) )

View File

@@ -22,11 +22,13 @@ type Resolver interface {
type Input struct { type Input struct {
OrganizationID bson.ObjectID OrganizationID bson.ObjectID
QuotationRef string QuotationRef string
IntentRef string
} }
// Output contains extracted canonical snapshots for execution. // Output contains extracted canonical snapshots for execution.
type Output struct { type Output struct {
QuotationRef string QuotationRef string
IntentRef string
IntentSnapshot model.PaymentIntent IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot QuoteSnapshot *model.PaymentQuoteSnapshot
} }

View File

@@ -17,6 +17,12 @@ type svc struct {
now func() time.Time now func() time.Time
} }
type resolvedQuoteItem struct {
Intent model.PaymentIntent
Quote *model.PaymentQuoteSnapshot
Status *model.QuoteStatusV2
}
func (s *svc) Resolve( func (s *svc) Resolve(
ctx context.Context, ctx context.Context,
store Store, store Store,
@@ -33,6 +39,7 @@ func (s *svc) Resolve(
if quoteRef == "" { if quoteRef == "" {
return nil, merrors.InvalidArgument("quotation_ref is required") return nil, merrors.InvalidArgument("quotation_ref is required")
} }
intentRef := strings.TrimSpace(in.IntentRef)
record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef) record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef)
if err != nil { if err != nil {
@@ -45,16 +52,12 @@ func (s *svc) Resolve(
return nil, ErrQuoteNotFound return nil, ErrQuoteNotFound
} }
if err := ensureExecutable(record, s.now().UTC()); err != nil { item, err := resolveRecordItem(record, intentRef)
if err != nil {
return nil, err return nil, err
} }
intentSnapshot, err := extractIntentSnapshot(record) if err := ensureExecutable(record, item.Status, s.now().UTC()); err != nil {
if err != nil {
return nil, err
}
quoteSnapshot, err := extractQuoteSnapshot(record)
if err != nil {
return nil, err return nil, err
} }
@@ -62,18 +65,23 @@ func (s *svc) Resolve(
if outputRef == "" { if outputRef == "" {
outputRef = quoteRef outputRef = quoteRef
} }
if quoteSnapshot != nil && strings.TrimSpace(quoteSnapshot.QuoteRef) == "" { if item.Quote != nil && strings.TrimSpace(item.Quote.QuoteRef) == "" {
quoteSnapshot.QuoteRef = outputRef item.Quote.QuoteRef = outputRef
} }
return &Output{ return &Output{
QuotationRef: outputRef, QuotationRef: outputRef,
IntentSnapshot: intentSnapshot, IntentRef: firstNonEmpty(strings.TrimSpace(item.Intent.Ref), intentRef),
QuoteSnapshot: quoteSnapshot, IntentSnapshot: item.Intent,
QuoteSnapshot: item.Quote,
}, nil }, nil
} }
func ensureExecutable(record *model.PaymentQuoteRecord, now time.Time) error { func ensureExecutable(
record *model.PaymentQuoteRecord,
status *model.QuoteStatusV2,
now time.Time,
) error {
if record == nil { if record == nil {
return ErrQuoteNotFound return ErrQuoteNotFound
} }
@@ -85,10 +93,6 @@ func ensureExecutable(record *model.PaymentQuoteRecord, now time.Time) error {
return fmt.Errorf("%w: %s", ErrQuoteNotExecutable, note) return fmt.Errorf("%w: %s", ErrQuoteNotExecutable, note)
} }
status, err := extractSingleStatus(record)
if err != nil {
return err
}
if status == nil { if status == nil {
// Legacy records may not have status metadata. // Legacy records may not have status metadata.
return nil return nil
@@ -116,58 +120,131 @@ func ensureExecutable(record *model.PaymentQuoteRecord, now time.Time) error {
} }
} }
func extractSingleStatus(record *model.PaymentQuoteRecord) (*model.QuoteStatusV2, error) { func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
if record == nil { if record == nil {
return nil, ErrQuoteShapeMismatch return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch)
} }
if len(record.StatusesV2) > 0 {
if len(record.StatusesV2) != 1 { hasArrayShape := len(record.Intents) > 0 || len(record.Quotes) > 0 || len(record.StatusesV2) > 0
return nil, fmt.Errorf("%w: expected single status", ErrQuoteShapeMismatch) if hasArrayShape {
return resolveArrayShapeItem(record, intentRef)
}
return resolveSingleShapeItem(record, intentRef)
}
func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
if record == nil {
return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch)
}
if record.Quote == nil {
return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch)
}
if isEmptyIntentSnapshot(record.Intent) {
return nil, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch)
}
if intentRef != "" {
recordIntentRef := strings.TrimSpace(record.Intent.Ref)
if recordIntentRef == "" || recordIntentRef != intentRef {
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
} }
if record.StatusesV2[0] == nil { }
intentSnapshot, err := cloneIntentSnapshot(record.Intent)
if err != nil {
return nil, err
}
quoteSnapshot, err := cloneQuoteSnapshot(record.Quote)
if err != nil {
return nil, err
}
return &resolvedQuoteItem{
Intent: intentSnapshot,
Quote: quoteSnapshot,
Status: record.StatusV2,
}, nil
}
func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
if len(record.Intents) == 0 {
return nil, fmt.Errorf("%w: intents are empty", ErrQuoteShapeMismatch)
}
if len(record.Quotes) == 0 {
return nil, fmt.Errorf("%w: quotes are empty", ErrQuoteShapeMismatch)
}
if len(record.Intents) != len(record.Quotes) {
return nil, fmt.Errorf("%w: intents and quotes count mismatch", ErrQuoteShapeMismatch)
}
if len(record.StatusesV2) > 0 && len(record.StatusesV2) != len(record.Quotes) {
return nil, fmt.Errorf("%w: statuses and quotes count mismatch", ErrQuoteShapeMismatch)
}
index := 0
if len(record.Intents) > 1 {
if intentRef == "" {
return nil, ErrIntentRefRequired
}
selected, found := findIntentIndex(record.Intents, intentRef)
if !found {
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
}
index = selected
} else if intentRef != "" {
if strings.TrimSpace(record.Intents[0].Ref) != intentRef {
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
}
}
quoteSnapshot := record.Quotes[index]
if quoteSnapshot == nil {
return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch)
}
intentSnapshot, err := cloneIntentSnapshot(record.Intents[index])
if err != nil {
return nil, err
}
clonedQuote, err := cloneQuoteSnapshot(quoteSnapshot)
if err != nil {
return nil, err
}
var statusSnapshot *model.QuoteStatusV2
if len(record.StatusesV2) > 0 {
if record.StatusesV2[index] == nil {
return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch) return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch)
} }
return record.StatusesV2[0], nil statusSnapshot = record.StatusesV2[index]
} }
return record.StatusV2, nil
return &resolvedQuoteItem{
Intent: intentSnapshot,
Quote: clonedQuote,
Status: statusSnapshot,
}, nil
} }
func extractIntentSnapshot(record *model.PaymentQuoteRecord) (model.PaymentIntent, error) { func findIntentIndex(intents []model.PaymentIntent, targetRef string) (int, bool) {
if record == nil { target := strings.TrimSpace(targetRef)
return model.PaymentIntent{}, ErrQuoteShapeMismatch if target == "" {
return -1, false
} }
for idx := range intents {
switch { if strings.TrimSpace(intents[idx].Ref) == target {
case len(record.Intents) > 1: return idx, true
return model.PaymentIntent{}, fmt.Errorf("%w: expected single intent", ErrQuoteShapeMismatch)
case len(record.Intents) == 1:
return cloneIntentSnapshot(record.Intents[0])
}
if isEmptyIntentSnapshot(record.Intent) {
return model.PaymentIntent{}, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch)
}
return cloneIntentSnapshot(record.Intent)
}
func extractQuoteSnapshot(record *model.PaymentQuoteRecord) (*model.PaymentQuoteSnapshot, error) {
if record == nil {
return nil, ErrQuoteShapeMismatch
}
if record.Quote != nil {
return cloneQuoteSnapshot(record.Quote)
}
if len(record.Quotes) > 1 {
return nil, fmt.Errorf("%w: expected single quote", ErrQuoteShapeMismatch)
}
if len(record.Quotes) == 1 {
if record.Quotes[0] == nil {
return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch)
} }
return cloneQuoteSnapshot(record.Quotes[0])
} }
return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch) return -1, false
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
} }
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) { func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote" quotestorage "github.com/tech/sendico/payments/storage/quote"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -41,6 +42,7 @@ func TestResolve_SingleShapeOK(t *testing.T) {
}, Input{ }, Input{
OrganizationID: orgID, OrganizationID: orgID,
QuotationRef: "stored-quote-ref", QuotationRef: "stored-quote-ref",
IntentRef: "intent-1",
}) })
if err != nil { if err != nil {
t.Fatalf("Resolve returned error: %v", err) t.Fatalf("Resolve returned error: %v", err)
@@ -54,6 +56,9 @@ func TestResolve_SingleShapeOK(t *testing.T) {
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want { if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want) t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
} }
if got, want := out.IntentRef, "intent-1"; got != want {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
if out.QuoteSnapshot == nil { if out.QuoteSnapshot == nil {
t.Fatal("expected quote snapshot") t.Fatal("expected quote snapshot")
} }
@@ -103,6 +108,9 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
if out == nil { if out == nil {
t.Fatal("expected output") t.Fatal("expected output")
} }
if got, want := out.IntentRef, "intent-1"; got != want {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want { if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want) t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
} }
@@ -114,6 +122,129 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
} }
} }
func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
orgID := bson.NewObjectID()
record := &model.PaymentQuoteRecord{
QuoteRef: "batch-quote-ref",
Intents: []model.PaymentIntent{
{Ref: "intent-a", Kind: model.PaymentKindPayout},
{Ref: "intent-b", Kind: model.PaymentKindPayout},
},
Quotes: []*model.PaymentQuoteSnapshot{
{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}},
{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "15", Currency: "USDT"}},
},
StatusesV2: []*model.QuoteStatusV2{
{State: model.QuoteStateExecutable},
{State: model.QuoteStateExecutable},
},
ExpiresAt: now.Add(time.Minute),
}
resolver := &svc{
now: func() time.Time { return now },
}
out, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return record, nil
},
}, Input{
OrganizationID: orgID,
QuotationRef: "batch-quote-ref",
IntentRef: "intent-b",
})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if out == nil {
t.Fatal("expected output")
}
if got, want := out.IntentSnapshot.Ref, "intent-b"; got != want {
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
}
if got, want := out.IntentRef, "intent-b"; got != want {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
if out.QuoteSnapshot == nil || out.QuoteSnapshot.DebitAmount == nil {
t.Fatal("expected quote snapshot with debit amount")
}
if got, want := out.QuoteSnapshot.DebitAmount.Amount, "15"; got != want {
t.Fatalf("selected quote mismatch: got=%q want=%q", got, want)
}
}
func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intents: []model.PaymentIntent{
{Ref: "intent-1", Kind: model.PaymentKindPayout},
{Ref: "intent-2", Kind: model.PaymentKindPayout},
},
Quotes: []*model.PaymentQuoteSnapshot{
{},
{},
},
StatusesV2: []*model.QuoteStatusV2{
{State: model.QuoteStateExecutable},
{State: model.QuoteStateExecutable},
},
ExpiresAt: now.Add(time.Minute),
}, nil
},
}, Input{
OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref",
})
if !errors.Is(err, ErrIntentRefRequired) {
t.Fatalf("expected ErrIntentRefRequired, got %v", err)
}
}
func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return &model.PaymentQuoteRecord{
QuoteRef: "quote-ref",
Intents: []model.PaymentIntent{
{Ref: "intent-1", Kind: model.PaymentKindPayout},
{Ref: "intent-2", Kind: model.PaymentKindPayout},
},
Quotes: []*model.PaymentQuoteSnapshot{
{},
{},
},
StatusesV2: []*model.QuoteStatusV2{
{State: model.QuoteStateExecutable},
{State: model.QuoteStateExecutable},
},
ExpiresAt: now.Add(time.Minute),
}, nil
},
}, Input{
OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref",
IntentRef: "intent-3",
})
if !errors.Is(err, ErrIntentRefNotFound) {
t.Fatalf("expected ErrIntentRefNotFound, got %v", err)
}
}
func TestResolve_NotFound(t *testing.T) { func TestResolve_NotFound(t *testing.T) {
resolver := New() resolver := New()
@@ -232,7 +363,6 @@ func TestResolve_ShapeMismatch(t *testing.T) {
}, },
Quotes: []*model.PaymentQuoteSnapshot{ Quotes: []*model.PaymentQuoteSnapshot{
{}, {},
{},
}, },
ExpiresAt: now.Add(time.Minute), ExpiresAt: now.Add(time.Minute),
}, nil }, nil
@@ -240,6 +370,7 @@ func TestResolve_ShapeMismatch(t *testing.T) {
}, Input{ }, Input{
OrganizationID: bson.NewObjectID(), OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref", QuotationRef: "quote-ref",
IntentRef: "intent-1",
}) })
if !errors.Is(err, ErrQuoteShapeMismatch) { if !errors.Is(err, ErrQuoteShapeMismatch) {
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err) t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)

View File

@@ -12,6 +12,7 @@ type Validator interface {
type Req struct { type Req struct {
Meta *Meta Meta *Meta
QuotationRef string QuotationRef string
IntentRef string
ClientPaymentRef string ClientPaymentRef string
} }
@@ -32,6 +33,7 @@ type Ctx struct {
OrganizationID bson.ObjectID OrganizationID bson.ObjectID
IdempotencyKey string IdempotencyKey string
QuotationRef string QuotationRef string
IntentRef string
ClientPaymentRef string ClientPaymentRef string
} }

View File

@@ -11,6 +11,7 @@ import (
const ( const (
maxIdempotencyKeyLen = 256 maxIdempotencyKeyLen = 256
maxQuotationRefLen = 128 maxQuotationRefLen = 128
maxIntentRefLen = 128
maxClientRefLen = 128 maxClientRefLen = 128
) )
@@ -49,6 +50,11 @@ func (s *svc) Validate(req *Req) (*Ctx, error) {
return nil, err return nil, err
} }
intentRef, err := validateRefToken("intent_ref", req.IntentRef, maxIntentRefLen, false)
if err != nil {
return nil, err
}
clientPaymentRef, err := validateRefToken("client_payment_ref", req.ClientPaymentRef, maxClientRefLen, false) clientPaymentRef, err := validateRefToken("client_payment_ref", req.ClientPaymentRef, maxClientRefLen, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -59,6 +65,7 @@ func (s *svc) Validate(req *Req) (*Ctx, error) {
OrganizationID: orgID, OrganizationID: orgID,
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
QuotationRef: quotationRef, QuotationRef: quotationRef,
IntentRef: intentRef,
ClientPaymentRef: clientPaymentRef, ClientPaymentRef: clientPaymentRef,
}, nil }, nil
} }

View File

@@ -19,6 +19,7 @@ func TestValidate_OK(t *testing.T) {
}, },
}, },
QuotationRef: " quote-ref-1 ", QuotationRef: " quote-ref-1 ",
IntentRef: " intent-ref-1 ",
ClientPaymentRef: " client.ref-1 ", ClientPaymentRef: " client.ref-1 ",
}) })
if err != nil { if err != nil {
@@ -39,6 +40,9 @@ func TestValidate_OK(t *testing.T) {
if got, want := ctx.QuotationRef, "quote-ref-1"; got != want { if got, want := ctx.QuotationRef, "quote-ref-1"; got != want {
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want) t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
} }
if got, want := ctx.IntentRef, "intent-ref-1"; got != want {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
if got, want := ctx.ClientPaymentRef, "client.ref-1"; got != want { if got, want := ctx.ClientPaymentRef, "client.ref-1"; got != want {
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want) t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
} }
@@ -66,12 +70,16 @@ func TestValidate_ClientPaymentRefOptional(t *testing.T) {
if ctx.ClientPaymentRef != "" { if ctx.ClientPaymentRef != "" {
t.Fatalf("expected empty client_payment_ref, got %q", ctx.ClientPaymentRef) t.Fatalf("expected empty client_payment_ref, got %q", ctx.ClientPaymentRef)
} }
if ctx.IntentRef != "" {
t.Fatalf("expected empty intent_ref, got %q", ctx.IntentRef)
}
} }
func TestValidate_Errors(t *testing.T) { func TestValidate_Errors(t *testing.T) {
orgID := bson.NewObjectID().Hex() orgID := bson.NewObjectID().Hex()
tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen) tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen)
tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen) tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen)
tooLongIntent := "i" + strings.Repeat("a", maxIntentRefLen)
tooLongClient := "c" + strings.Repeat("a", maxClientRefLen) tooLongClient := "c" + strings.Repeat("a", maxClientRefLen)
tests := []struct { tests := []struct {
@@ -185,6 +193,28 @@ func TestValidate_Errors(t *testing.T) {
ClientPaymentRef: tooLongClient, ClientPaymentRef: tooLongClient,
}, },
}, },
{
name: "too long intent ref",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: "quote-1",
IntentRef: tooLongIntent,
},
},
{
name: "bad intent ref shape",
req: &Req{
Meta: &Meta{
OrganizationRef: orgID,
Trace: &Trace{IdempotencyKey: "idem-1"},
},
QuotationRef: "quote-1",
IntentRef: "intent ref",
},
},
{ {
name: "bad client payment ref shape", name: "bad client payment ref shape",
req: &Req{ req: &Req{

View File

@@ -0,0 +1,7 @@
package xplan
import "errors"
var (
ErrNotExecutable = errors.New("quote is not executable for runtime compilation")
)

View File

@@ -0,0 +1,110 @@
package xplan
import (
"github.com/tech/sendico/payments/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
// Compiler builds execution runtime step graph from resolved quote snapshots.
type Compiler interface {
Compile(in Input) (*Graph, error)
}
// StepKind classifies graph step intent.
type StepKind string
const (
StepKindUnspecified StepKind = "unspecified"
StepKindLiquidityCheck StepKind = "liquidity_check"
StepKindPrefunding StepKind = "prefunding"
StepKindRailSend StepKind = "rail_send"
StepKindRailObserve StepKind = "rail_observe"
StepKindFundsCredit StepKind = "funds_credit"
StepKindFundsDebit StepKind = "funds_debit"
StepKindFundsMove StepKind = "funds_move"
StepKindFundsBlock StepKind = "funds_block"
StepKindFundsRelease StepKind = "funds_release"
)
// Input is the compiler payload.
type Input struct {
IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot
Policies []Policy
}
// Graph is a compiled runtime execution graph.
type Graph struct {
RouteRef string
Readiness paymenttypes.QuoteExecutionReadiness
Steps []Step
}
// Step is one compiled graph node.
type Step struct {
StepRef string
StepCode string
Kind StepKind
Action model.RailOperation
DependsOn []string
Rail model.Rail
Gateway string
InstanceID string
HopIndex uint32
HopRole paymenttypes.QuoteRouteHopRole
Visibility model.ReportVisibility
UserLabel string
CommitPolicy model.CommitPolicy
CommitAfter []string
Metadata map[string]string
}
// Custody classifies whether a rail is internal to orchestrator or external.
type Custody string
const (
CustodyUnspecified Custody = "unspecified"
CustodyInternal Custody = "internal"
CustodyExternal Custody = "external"
)
// EndpointMatch defines optional selectors for one boundary endpoint.
type EndpointMatch struct {
Rail *model.Rail `json:"rail,omitempty" bson:"rail,omitempty"`
Custody *Custody `json:"custody,omitempty" bson:"custody,omitempty"`
Gateway string `json:"gateway,omitempty" bson:"gateway,omitempty"`
Network string `json:"network,omitempty" bson:"network,omitempty"`
Method string `json:"method,omitempty" bson:"method,omitempty"`
}
// EdgeMatch defines source/target selectors for a boundary policy.
type EdgeMatch struct {
Source EndpointMatch `json:"source" bson:"source"`
Target EndpointMatch `json:"target" bson:"target"`
}
// PolicyStep defines one operation step emitted by a matching policy.
type PolicyStep struct {
Code string `json:"code" bson:"code"`
Action model.RailOperation `json:"action" bson:"action"`
Rail *model.Rail `json:"rail,omitempty" bson:"rail,omitempty"`
DependsOn []string `json:"depends_on,omitempty" bson:"depends_on,omitempty"`
Visibility model.ReportVisibility `json:"visibility,omitempty" bson:"visibility,omitempty"`
UserLabel string `json:"user_label,omitempty" bson:"user_label,omitempty"`
Metadata map[string]string `json:"metadata,omitempty" bson:"metadata,omitempty"`
}
// Policy defines optional edge-specific expansion override.
type Policy struct {
ID string `json:"id" bson:"id"`
Enabled *bool `json:"enabled,omitempty" bson:"enabled,omitempty"`
Priority int `json:"priority,omitempty" bson:"priority,omitempty"`
Match EdgeMatch `json:"match" bson:"match"`
Steps []PolicyStep `json:"steps" bson:"steps"`
Success []PolicyStep `json:"success,omitempty" bson:"success,omitempty"`
Failure []PolicyStep `json:"failure,omitempty" bson:"failure,omitempty"`
}
func New() Compiler {
return &svc{}
}

View File

@@ -0,0 +1,989 @@
package xplan
import (
"fmt"
"slices"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
type svc struct{}
type normalizedHop struct {
index uint32
rail model.Rail
gateway string
instanceID string
network string
role paymenttypes.QuoteRouteHopRole
pos int
}
type expansion struct {
steps []Step
lastMainRef string
refSeq map[string]int
externalObserved map[string]string
}
func newExpansion() *expansion {
return &expansion{
refSeq: map[string]int{},
externalObserved: map[string]string{},
}
}
func (s *svc) Compile(in Input) (*Graph, error) {
if isEmptyIntentSnapshot(in.IntentSnapshot) {
return nil, merrors.InvalidArgument("intent_snapshot is required")
}
if in.QuoteSnapshot == nil {
return nil, merrors.InvalidArgument("quote_snapshot is required")
}
if in.QuoteSnapshot.Route == nil {
return nil, merrors.InvalidArgument("quote_snapshot.route is required")
}
conditions := in.QuoteSnapshot.ExecutionConditions
readiness := paymenttypes.QuoteExecutionReadinessUnspecified
if conditions != nil {
readiness = conditions.Readiness
}
if readiness == paymenttypes.QuoteExecutionReadinessIndicative {
return nil, ErrNotExecutable
}
hops, err := normalizeRouteHops(in.QuoteSnapshot.Route, in.IntentSnapshot)
if err != nil {
return nil, err
}
ex := newExpansion()
appendGuards(ex, conditions)
if len(hops) == 1 {
if err := s.expandSingleHop(ex, hops[0], in.IntentSnapshot); err != nil {
return nil, err
}
} else {
for i := 0; i < len(hops)-1; i++ {
from := hops[i]
to := hops[i+1]
policy := selectPolicy(from, to, in.Policies)
if policy != nil {
if err := s.applyPolicy(ex, *policy, from, to, in.IntentSnapshot); err != nil {
return nil, err
}
continue
}
if err := s.applyDefaultBoundary(ex, from, to, in.IntentSnapshot); err != nil {
return nil, err
}
}
}
if len(ex.steps) == 0 {
return nil, merrors.InvalidArgument("compiled graph is empty")
}
return &Graph{
RouteRef: strings.TrimSpace(in.QuoteSnapshot.Route.RouteRef),
Readiness: readiness,
Steps: ex.steps,
}, nil
}
func (s *svc) expandSingleHop(ex *expansion, hop normalizedHop, intent model.PaymentIntent) error {
if isExternalRail(hop.rail) {
_, err := s.ensureExternalObserved(ex, hop, intent)
return err
}
switch hop.role {
case paymenttypes.QuoteRouteHopRoleSource:
ex.appendMain(Step{
StepCode: singleHopCode(hop, "debit"),
Kind: StepKindFundsDebit,
Action: model.RailOperationDebit,
Rail: hop.rail,
HopIndex: hop.index,
HopRole: hop.role,
Visibility: model.ReportVisibilityHidden,
})
case paymenttypes.QuoteRouteHopRoleDestination:
ex.appendMain(Step{
StepCode: singleHopCode(hop, "credit"),
Kind: StepKindFundsCredit,
Action: model.RailOperationCredit,
Rail: hop.rail,
HopIndex: hop.index,
HopRole: hop.role,
Visibility: model.ReportVisibilityHidden,
})
default:
ex.appendMain(Step{
StepCode: singleHopCode(hop, "move"),
Kind: StepKindFundsMove,
Action: model.RailOperationMove,
Rail: hop.rail,
HopIndex: hop.index,
HopRole: hop.role,
Visibility: model.ReportVisibilityHidden,
})
}
return nil
}
func (s *svc) applyDefaultBoundary(
ex *expansion,
from normalizedHop,
to normalizedHop,
intent model.PaymentIntent,
) error {
switch {
case isExternalRail(from.rail) && isInternalRail(to.rail):
if _, err := s.ensureExternalObserved(ex, from, intent); err != nil {
return err
}
ex.appendMain(makeFundsCreditStep(from, to, internalRailForBoundary(from, to)))
return nil
case isInternalRail(from.rail) && isExternalRail(to.rail):
internalRail := internalRailForBoundary(from, to)
ex.appendMain(makeFundsBlockStep(from, to, internalRail))
observeRef, err := s.ensureExternalObserved(ex, to, intent)
if err != nil {
return err
}
appendSettlementBranches(ex, from, to, internalRail, observeRef)
return nil
case isExternalRail(from.rail) && isExternalRail(to.rail):
if _, err := s.ensureExternalObserved(ex, from, intent); err != nil {
return err
}
internalRail := internalRailForBoundary(from, to)
ex.appendMain(makeFundsCreditStep(from, to, internalRail))
ex.appendMain(makeFundsBlockStep(from, to, internalRail))
observeRef, err := s.ensureExternalObserved(ex, to, intent)
if err != nil {
return err
}
appendSettlementBranches(ex, from, to, internalRail, observeRef)
return nil
case isInternalRail(from.rail) && isInternalRail(to.rail):
ex.appendMain(makeFundsMoveStep(from, to, internalRailForBoundary(from, to)))
return nil
default:
return merrors.InvalidArgument("unsupported rail boundary")
}
}
func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) {
key := observedKey(hop)
if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" {
return ref, nil
}
sendStep := makeRailSendStep(hop, intent)
sendRef := ex.appendMain(sendStep)
observeStep := makeRailObserveStep(hop, intent)
if sendRef != "" {
observeStep.DependsOn = []string{sendRef}
}
observeRef := ex.appendMain(observeStep)
ex.externalObserved[key] = observeRef
return observeRef, nil
}
func (s *svc) applyPolicy(
ex *expansion,
policy Policy,
from normalizedHop,
to normalizedHop,
intent model.PaymentIntent,
) error {
if len(policy.Steps) == 0 {
return merrors.InvalidArgument("policy.steps are required")
}
anchorRef := ""
for i := range policy.Steps {
step, err := policyStepToStep(policy.Steps[i], from, to, intent)
if err != nil {
return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " step[" + itoa(i) + "]: " + err.Error())
}
anchorRef = ex.appendMain(step)
}
if strings.TrimSpace(anchorRef) == "" {
return merrors.InvalidArgument("policy produced no anchor step")
}
for i := range policy.Success {
step, err := policyStepToStep(policy.Success[i], from, to, intent)
if err != nil {
return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " success[" + itoa(i) + "]: " + err.Error())
}
if len(step.DependsOn) == 0 {
step.DependsOn = []string{anchorRef}
}
if len(step.CommitAfter) == 0 {
step.CommitAfter = cloneStringSlice(step.DependsOn)
}
step.CommitPolicy = model.CommitPolicyAfterSuccess
ex.appendBranch(step)
}
for i := range policy.Failure {
step, err := policyStepToStep(policy.Failure[i], from, to, intent)
if err != nil {
return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " failure[" + itoa(i) + "]: " + err.Error())
}
if len(step.DependsOn) == 0 {
step.DependsOn = []string{anchorRef}
}
if len(step.CommitAfter) == 0 {
step.CommitAfter = cloneStringSlice(step.DependsOn)
}
step.CommitPolicy = model.CommitPolicyAfterFailure
ex.appendBranch(step)
}
return nil
}
func selectPolicy(from normalizedHop, to normalizedHop, policies []Policy) *Policy {
best := -1
bestPriority := 0
for i := range policies {
policy := &policies[i]
if !policyEnabled(*policy) {
continue
}
if !policyMatches(policy.Match, from, to) {
continue
}
if best == -1 || policy.Priority > bestPriority {
best = i
bestPriority = policy.Priority
}
}
if best == -1 {
return nil
}
return &policies[best]
}
func policyEnabled(policy Policy) bool {
if policy.Enabled == nil {
return true
}
return *policy.Enabled
}
func policyMatches(match EdgeMatch, from normalizedHop, to normalizedHop) bool {
return endpointMatches(match.Source, from) && endpointMatches(match.Target, to)
}
func endpointMatches(match EndpointMatch, hop normalizedHop) bool {
if match.Rail != nil && normalizeRail(string(*match.Rail)) != hop.rail {
return false
}
if match.Custody != nil && *match.Custody != custodyForRail(hop.rail) {
return false
}
if gateway := strings.TrimSpace(match.Gateway); gateway != "" && !strings.EqualFold(gateway, hop.gateway) {
return false
}
if network := strings.TrimSpace(match.Network); network != "" && !strings.EqualFold(network, hop.network) {
return false
}
if strings.TrimSpace(match.Method) != "" {
// Method-matching is reserved for the next phase once method is passed in intent/route context.
return false
}
return true
}
func policyStepToStep(spec PolicyStep, from normalizedHop, to normalizedHop, intent model.PaymentIntent) (Step, error) {
code := strings.TrimSpace(spec.Code)
if code == "" {
return Step{}, merrors.InvalidArgument("code is required")
}
action := normalizeAction(spec.Action)
if action == model.RailOperationUnspecified {
return Step{}, merrors.InvalidArgument("action is required")
}
rail := inferPolicyRail(spec, action, from, to)
if rail == model.RailUnspecified {
return Step{}, merrors.InvalidArgument("rail could not be inferred")
}
hopIndex, hopRole, gateway, instanceID := resolveStepContext(rail, action, from, to)
visibility := model.NormalizeReportVisibility(spec.Visibility)
if visibility == model.ReportVisibilityUnspecified {
visibility = defaultVisibilityForAction(action, hopRole)
}
userLabel := strings.TrimSpace(spec.UserLabel)
if userLabel == "" && visibility == model.ReportVisibilityUser {
userLabel = defaultUserLabel(action, rail, hopRole, intent.Kind)
}
return Step{
StepCode: code,
Kind: kindForAction(action),
Action: action,
DependsOn: cloneStringSlice(spec.DependsOn),
Rail: rail,
Gateway: gateway,
InstanceID: instanceID,
HopIndex: hopIndex,
HopRole: hopRole,
Visibility: visibility,
UserLabel: userLabel,
Metadata: cloneMetadata(spec.Metadata),
}, nil
}
func normalizeAction(action model.RailOperation) model.RailOperation {
switch strings.ToUpper(strings.TrimSpace(string(action))) {
case string(model.RailOperationDebit):
return model.RailOperationDebit
case string(model.RailOperationCredit):
return model.RailOperationCredit
case string(model.RailOperationExternalDebit):
return model.RailOperationExternalDebit
case string(model.RailOperationExternalCredit):
return model.RailOperationExternalCredit
case string(model.RailOperationMove):
return model.RailOperationMove
case string(model.RailOperationSend):
return model.RailOperationSend
case string(model.RailOperationFee):
return model.RailOperationFee
case string(model.RailOperationObserveConfirm):
return model.RailOperationObserveConfirm
case string(model.RailOperationFXConvert):
return model.RailOperationFXConvert
case string(model.RailOperationBlock):
return model.RailOperationBlock
case string(model.RailOperationRelease):
return model.RailOperationRelease
default:
return model.RailOperationUnspecified
}
}
func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalizedHop, to normalizedHop) model.Rail {
if spec.Rail != nil {
return normalizeRail(string(*spec.Rail))
}
switch action {
case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee:
return to.rail
case model.RailOperationBlock,
model.RailOperationRelease,
model.RailOperationDebit,
model.RailOperationCredit,
model.RailOperationExternalDebit,
model.RailOperationExternalCredit,
model.RailOperationMove:
return internalRailForBoundary(from, to)
default:
return model.RailUnspecified
}
}
func resolveStepContext(
rail model.Rail,
action model.RailOperation,
from normalizedHop,
to normalizedHop,
) (uint32, paymenttypes.QuoteRouteHopRole, string, string) {
if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) {
return to.index, to.role, to.gateway, to.instanceID
}
if rail == from.rail {
return from.index, from.role, from.gateway, from.instanceID
}
if rail == to.rail {
return to.index, to.role, to.gateway, to.instanceID
}
return to.index, paymenttypes.QuoteRouteHopRoleTransit, "", ""
}
func kindForAction(action model.RailOperation) StepKind {
switch action {
case model.RailOperationSend:
return StepKindRailSend
case model.RailOperationObserveConfirm:
return StepKindRailObserve
case model.RailOperationCredit, model.RailOperationExternalCredit:
return StepKindFundsCredit
case model.RailOperationDebit, model.RailOperationExternalDebit:
return StepKindFundsDebit
case model.RailOperationMove:
return StepKindFundsMove
case model.RailOperationBlock:
return StepKindFundsBlock
case model.RailOperationRelease:
return StepKindFundsRelease
default:
return StepKindUnspecified
}
}
func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditions) {
if conditions == nil {
return
}
if conditions.LiquidityCheckRequiredAtExecution {
ex.appendMain(Step{
StepCode: "liquidity.check",
Kind: StepKindLiquidityCheck,
Action: model.RailOperationUnspecified,
Rail: model.RailUnspecified,
Visibility: model.ReportVisibilityHidden,
})
}
if conditions.PrefundingRequired {
ex.appendMain(Step{
StepCode: "prefunding.ensure",
Kind: StepKindPrefunding,
Action: model.RailOperationUnspecified,
Rail: model.RailUnspecified,
Visibility: model.ReportVisibilityHidden,
})
}
}
func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step {
visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role)
userLabel := ""
if visibility == model.ReportVisibilityUser {
userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind)
}
return Step{
StepCode: singleHopCode(hop, "send"),
Kind: StepKindRailSend,
Action: model.RailOperationSend,
Rail: hop.rail,
Gateway: hop.gateway,
InstanceID: hop.instanceID,
HopIndex: hop.index,
HopRole: hop.role,
Visibility: visibility,
UserLabel: userLabel,
}
}
func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step {
visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role)
userLabel := ""
if visibility == model.ReportVisibilityUser {
userLabel = defaultUserLabel(model.RailOperationObserveConfirm, hop.rail, hop.role, intent.Kind)
}
return Step{
StepCode: singleHopCode(hop, "observe"),
Kind: StepKindRailObserve,
Action: model.RailOperationObserveConfirm,
Rail: hop.rail,
Gateway: hop.gateway,
InstanceID: hop.instanceID,
HopIndex: hop.index,
HopRole: hop.role,
Visibility: visibility,
UserLabel: userLabel,
}
}
func makeFundsCreditStep(from normalizedHop, to normalizedHop, rail model.Rail) Step {
return Step{
StepCode: edgeCode(from, to, rail, "credit"),
Kind: StepKindFundsCredit,
Action: model.RailOperationCredit,
Rail: rail,
HopIndex: to.index,
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
Visibility: model.ReportVisibilityHidden,
}
}
func makeFundsBlockStep(from normalizedHop, to normalizedHop, rail model.Rail) Step {
return Step{
StepCode: edgeCode(from, to, rail, "block"),
Kind: StepKindFundsBlock,
Action: model.RailOperationBlock,
Rail: rail,
HopIndex: to.index,
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
Visibility: model.ReportVisibilityHidden,
}
}
func makeFundsMoveStep(from normalizedHop, to normalizedHop, rail model.Rail) Step {
return Step{
StepCode: edgeCode(from, to, rail, "move"),
Kind: StepKindFundsMove,
Action: model.RailOperationMove,
Rail: rail,
HopIndex: to.index,
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
Visibility: model.ReportVisibilityHidden,
}
}
func appendSettlementBranches(
ex *expansion,
from normalizedHop,
to normalizedHop,
rail model.Rail,
anchorObserveRef string,
) {
if strings.TrimSpace(anchorObserveRef) == "" {
return
}
successStep := Step{
StepCode: edgeCode(from, to, rail, "debit"),
Kind: StepKindFundsDebit,
Action: model.RailOperationDebit,
DependsOn: []string{anchorObserveRef},
Rail: rail,
HopIndex: to.index,
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
Visibility: model.ReportVisibilityHidden,
CommitPolicy: model.CommitPolicyAfterSuccess,
CommitAfter: []string{anchorObserveRef},
Metadata: map[string]string{"mode": "finalize_debit"},
}
ex.appendBranch(successStep)
failureStep := Step{
StepCode: edgeCode(from, to, rail, "release"),
Kind: StepKindFundsRelease,
Action: model.RailOperationRelease,
DependsOn: []string{anchorObserveRef},
Rail: rail,
HopIndex: to.index,
HopRole: paymenttypes.QuoteRouteHopRoleTransit,
Visibility: model.ReportVisibilityHidden,
CommitPolicy: model.CommitPolicyAfterFailure,
CommitAfter: []string{anchorObserveRef},
Metadata: map[string]string{"mode": "unlock_hold"},
}
ex.appendBranch(failureStep)
}
func (e *expansion) appendMain(step Step) string {
step = normalizeStep(step)
if len(step.DependsOn) == 0 && strings.TrimSpace(e.lastMainRef) != "" {
step.DependsOn = []string{e.lastMainRef}
}
if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified {
step.CommitAfter = cloneStringSlice(step.DependsOn)
}
step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode))
if strings.TrimSpace(step.StepCode) == "" {
step.StepCode = step.StepRef
}
e.steps = append(e.steps, step)
e.lastMainRef = step.StepRef
return step.StepRef
}
func (e *expansion) appendBranch(step Step) string {
step = normalizeStep(step)
if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified {
step.CommitAfter = cloneStringSlice(step.DependsOn)
}
step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode))
if strings.TrimSpace(step.StepCode) == "" {
step.StepCode = step.StepRef
}
e.steps = append(e.steps, step)
return step.StepRef
}
func (e *expansion) nextRef(base string) string {
token := sanitizeToken(base)
if token == "" {
token = "step"
}
count := e.refSeq[token]
e.refSeq[token] = count + 1
if count == 0 {
return token
}
return token + "_" + itoa(count+1)
}
func normalizeStep(step Step) Step {
step.StepRef = strings.TrimSpace(step.StepRef)
step.StepCode = strings.TrimSpace(step.StepCode)
step.Gateway = strings.TrimSpace(step.Gateway)
step.InstanceID = strings.TrimSpace(step.InstanceID)
step.UserLabel = strings.TrimSpace(step.UserLabel)
step.Visibility = model.NormalizeReportVisibility(step.Visibility)
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
step.DependsOn = normalizeStringList(step.DependsOn)
step.CommitAfter = normalizeStringList(step.CommitAfter)
step.Metadata = normalizeMetadata(step.Metadata)
return step
}
func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy {
switch strings.ToUpper(strings.TrimSpace(string(policy))) {
case string(model.CommitPolicyImmediate):
return model.CommitPolicyImmediate
case string(model.CommitPolicyAfterSuccess):
return model.CommitPolicyAfterSuccess
case string(model.CommitPolicyAfterFailure):
return model.CommitPolicyAfterFailure
case string(model.CommitPolicyAfterCanceled):
return model.CommitPolicyAfterCanceled
default:
return model.CommitPolicyUnspecified
}
}
func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility {
switch action {
case model.RailOperationSend, model.RailOperationObserveConfirm:
if role == paymenttypes.QuoteRouteHopRoleDestination {
return model.ReportVisibilityUser
}
return model.ReportVisibilityBackoffice
default:
return model.ReportVisibilityHidden
}
}
func defaultUserLabel(
action model.RailOperation,
rail model.Rail,
role paymenttypes.QuoteRouteHopRole,
kind model.PaymentKind,
) string {
if role != paymenttypes.QuoteRouteHopRoleDestination {
return ""
}
switch action {
case model.RailOperationSend:
if kind == model.PaymentKindPayout && rail == model.RailCardPayout {
return "Card payout submitted"
}
return "Transfer submitted"
case model.RailOperationObserveConfirm:
if kind == model.PaymentKindPayout && rail == model.RailCardPayout {
return "Card payout confirmed"
}
return "Transfer confirmed"
default:
return ""
}
}
func internalRailForBoundary(from normalizedHop, to normalizedHop) model.Rail {
if isInternalRail(from.rail) {
return from.rail
}
if isInternalRail(to.rail) {
return to.rail
}
return model.RailLedger
}
func isInternalRail(rail model.Rail) bool {
return rail == model.RailLedger
}
func isExternalRail(rail model.Rail) bool {
switch rail {
case model.RailCrypto, model.RailProviderSettlement, model.RailCardPayout, model.RailFiatOnRamp:
return true
default:
return false
}
}
func custodyForRail(rail model.Rail) Custody {
if isInternalRail(rail) {
return CustodyInternal
}
if isExternalRail(rail) {
return CustodyExternal
}
return CustodyUnspecified
}
func singleHopCode(hop normalizedHop, op string) string {
return fmt.Sprintf("hop.%d.%s.%s", hop.index, railToken(hop.rail), strings.TrimSpace(op))
}
func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string) string {
return fmt.Sprintf(
"edge.%d_%d.%s.%s",
from.index,
to.index,
railToken(rail),
strings.TrimSpace(op),
)
}
func railToken(rail model.Rail) string {
return strings.ToLower(strings.TrimSpace(string(rail)))
}
func observedKey(hop normalizedHop) string {
return fmt.Sprintf("%d:%d:%s:%s", hop.pos, hop.index, strings.TrimSpace(string(hop.rail)), hop.instanceID)
}
func normalizeRouteHops(route *paymenttypes.QuoteRouteSpecification, intent model.PaymentIntent) ([]normalizedHop, error) {
if route == nil {
return nil, merrors.InvalidArgument("quote_snapshot.route is required")
}
if len(route.Hops) == 0 {
rail := normalizeRail(route.Rail)
if rail == model.RailUnspecified {
return nil, merrors.InvalidArgument("quote_snapshot.route.rail is required")
}
return []normalizedHop{
{
index: 0,
rail: rail,
gateway: strings.TrimSpace(route.Provider),
instanceID: "",
network: strings.TrimSpace(route.Network),
role: paymenttypes.QuoteRouteHopRoleDestination,
pos: 0,
},
}, nil
}
hops := make([]normalizedHop, 0, len(route.Hops))
for i, hop := range route.Hops {
if hop == nil {
continue
}
rail := normalizeRail(firstNonEmpty(hop.Rail, route.Rail))
if rail == model.RailUnspecified {
return nil, merrors.InvalidArgument("quote_snapshot.route.hops[" + itoa(i) + "].rail is required")
}
hops = append(hops, normalizedHop{
index: hop.Index,
rail: rail,
gateway: strings.TrimSpace(firstNonEmpty(hop.Gateway, route.Provider)),
instanceID: strings.TrimSpace(hop.InstanceID),
network: strings.TrimSpace(firstNonEmpty(hop.Network, route.Network)),
role: normalizeHopRole(hop.Role, i, len(route.Hops), intent),
pos: i,
})
}
if len(hops) == 0 {
return nil, merrors.InvalidArgument("quote_snapshot.route.hops are empty")
}
slices.SortFunc(hops, func(a, b normalizedHop) int {
switch {
case a.index < b.index:
return -1
case a.index > b.index:
return 1
case a.pos < b.pos:
return -1
case a.pos > b.pos:
return 1
default:
return 0
}
})
return hops, nil
}
func normalizeHopRole(
role paymenttypes.QuoteRouteHopRole,
position int,
total int,
_ model.PaymentIntent,
) paymenttypes.QuoteRouteHopRole {
switch role {
case paymenttypes.QuoteRouteHopRoleSource,
paymenttypes.QuoteRouteHopRoleTransit,
paymenttypes.QuoteRouteHopRoleDestination:
return role
}
if total <= 1 {
return paymenttypes.QuoteRouteHopRoleDestination
}
if position == 0 {
return paymenttypes.QuoteRouteHopRoleSource
}
if position == total-1 {
return paymenttypes.QuoteRouteHopRoleDestination
}
return paymenttypes.QuoteRouteHopRoleTransit
}
func normalizeRail(raw string) model.Rail {
token := strings.ToUpper(strings.TrimSpace(raw))
token = strings.ReplaceAll(token, "-", "_")
token = strings.ReplaceAll(token, " ", "_")
for strings.Contains(token, "__") {
token = strings.ReplaceAll(token, "__", "_")
}
switch token {
case "CRYPTO":
return model.RailCrypto
case "PROVIDER_SETTLEMENT", "PROVIDER":
return model.RailProviderSettlement
case "LEDGER":
return model.RailLedger
case "CARD_PAYOUT", "CARD":
return model.RailCardPayout
case "FIAT_ONRAMP", "FIAT_ON_RAMP":
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func sanitizeToken(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
var b strings.Builder
prevUnderscore := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
prevUnderscore = false
continue
}
if !prevUnderscore {
b.WriteByte('_')
prevUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}
func normalizeStringList(items []string) []string {
if len(items) == 0 {
return nil
}
seen := make(map[string]struct{}, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
token := strings.TrimSpace(item)
if token == "" {
continue
}
if _, exists := seen[token]; exists {
continue
}
seen[token] = struct{}{}
out = append(out, token)
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
out := make(map[string]string, len(input))
for key, value := range input {
k := strings.TrimSpace(key)
if k == "" {
continue
}
out[k] = strings.TrimSpace(value)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
out := make(map[string]string, len(input))
for key, value := range input {
out[key] = value
}
return out
}
func cloneStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, len(values))
copy(out, values)
return out
}
func itoa(v int) string {
if v == 0 {
return "0"
}
if v < 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}

View File

@@ -0,0 +1,493 @@
package xplan
import (
"errors"
"testing"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
RouteRef: "route-1",
Hops: []*paymenttypes.QuoteRouteHop{
{
Index: 10,
Rail: "CRYPTO",
Gateway: "gw-crypto",
InstanceID: "crypto-1",
Role: paymenttypes.QuoteRouteHopRoleSource,
},
{
Index: 20,
Rail: "CARD_PAYOUT",
Gateway: "gw-card",
InstanceID: "card-1",
Role: paymenttypes.QuoteRouteHopRoleDestination,
},
},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if graph == nil {
t.Fatal("expected graph")
}
if got, want := graph.RouteRef, "route-1"; got != want {
t.Fatalf("route_ref mismatch: got=%q want=%q", got, want)
}
if len(graph.Steps) != 8 {
t.Fatalf("expected 8 steps, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "hop.10.crypto.send", model.RailOperationSend, model.RailCrypto, model.ReportVisibilityBackoffice)
assertStep(t, graph.Steps[1], "hop.10.crypto.observe", model.RailOperationObserveConfirm, model.RailCrypto, model.ReportVisibilityBackoffice)
assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[3], "edge.10_20.ledger.block", model.RailOperationBlock, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[4], "hop.20.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[5], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[6], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[7], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("step[1] deps mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[2].DependsOn, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("step[2] deps mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[3].DependsOn, []string{graph.Steps[2].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("step[3] deps mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[4].DependsOn, []string{graph.Steps[3].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("step[4] deps mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[5].DependsOn, []string{graph.Steps[4].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("step[5] deps mismatch: got=%v want=%v", got, want)
}
if graph.Steps[6].CommitPolicy != model.CommitPolicyAfterSuccess {
t.Fatalf("expected debit commit policy AFTER_SUCCESS, got %q", graph.Steps[6].CommitPolicy)
}
if graph.Steps[7].CommitPolicy != model.CommitPolicyAfterFailure {
t.Fatalf("expected release commit policy AFTER_FAILURE, got %q", graph.Steps[7].CommitPolicy)
}
if got, want := graph.Steps[6].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("debit commit_after mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[7].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("release commit_after mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[6].Metadata["mode"], "finalize_debit"; got != want {
t.Fatalf("expected debit mode %q, got %q", want, got)
}
if got, want := graph.Steps[7].Metadata["mode"], "unlock_hold"; got != want {
t.Fatalf("expected release mode %q, got %q", want, got)
}
}
func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 20, Rail: "CARD_PAYOUT", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 5 {
t.Fatalf("expected 5 steps, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "edge.10_20.ledger.block", model.RailOperationBlock, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[1], "hop.20.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[2], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[3], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[4], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
}
func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindInternalTransfer),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 20, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 3 {
t.Fatalf("expected 3 steps, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "hop.10.crypto.send", model.RailOperationSend, model.RailCrypto, model.ReportVisibilityBackoffice)
assertStep(t, graph.Steps[1], "hop.10.crypto.observe", model.RailOperationObserveConfirm, model.RailCrypto, model.ReportVisibilityBackoffice)
assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden)
}
func TestCompile_InternalToInternal_UsesMove(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindInternalTransfer),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 20, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 1 {
t.Fatalf("expected 1 step, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "edge.10_20.ledger.move", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden)
}
func TestCompile_GuardsArePrepended(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
LiquidityCheckRequiredAtExecution: true,
PrefundingRequired: true,
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 7 {
t.Fatalf("expected 7 steps, got %d", len(graph.Steps))
}
if graph.Steps[0].Kind != StepKindLiquidityCheck {
t.Fatalf("expected first guard liquidity_check, got %q", graph.Steps[0].Kind)
}
if graph.Steps[1].Kind != StepKindPrefunding {
t.Fatalf("expected second guard prefunding, got %q", graph.Steps[1].Kind)
}
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("prefunding dependency mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[2].DependsOn, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("first execution step dependency mismatch: got=%v want=%v", got, want)
}
}
func TestCompile_SingleExternalFallback(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
RouteRef: "route-summary",
Rail: "CARD_PAYOUT",
Provider: "gw-card",
Network: "visa",
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "hop.0.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[1], "hop.0.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser)
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("observe dependency mismatch: got=%v want=%v", got, want)
}
}
func TestCompile_PolicyOverrideByRailPair(t *testing.T) {
compiler := New()
cardRail := model.RailCardPayout
ledgerRail := model.RailLedger
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
Policies: []Policy{
{
ID: "crypto-to-card-override",
Match: EdgeMatch{
Source: EndpointMatch{Rail: railPtr(model.RailCrypto)},
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)},
},
Steps: []PolicyStep{
{Code: "custom.review", Action: model.RailOperationMove, Rail: &ledgerRail},
{Code: "custom.submit", Action: model.RailOperationSend, Rail: &cardRail, Visibility: model.ReportVisibilityUser},
},
Success: []PolicyStep{
{Code: "custom.finalize", Action: model.RailOperationDebit, Rail: &ledgerRail},
},
Failure: []PolicyStep{
{Code: "custom.release", Action: model.RailOperationRelease, Rail: &ledgerRail},
},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 4 {
t.Fatalf("expected 4 steps, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "custom.review", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[1], "custom.submit", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[2], "custom.finalize", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[3], "custom.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
if graph.Steps[2].CommitPolicy != model.CommitPolicyAfterSuccess {
t.Fatalf("expected custom.finalize AFTER_SUCCESS, got %q", graph.Steps[2].CommitPolicy)
}
if graph.Steps[3].CommitPolicy != model.CommitPolicyAfterFailure {
t.Fatalf("expected custom.release AFTER_FAILURE, got %q", graph.Steps[3].CommitPolicy)
}
}
func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) {
compiler := New()
cardRail := model.RailCardPayout
on := true
external := CustodyExternal
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
Policies: []Policy{
{
ID: "generic-external",
Enabled: &on,
Priority: 1,
Match: EdgeMatch{
Source: EndpointMatch{Custody: &external},
Target: EndpointMatch{Custody: &external},
},
Steps: []PolicyStep{{Code: "generic.submit", Action: model.RailOperationSend, Rail: &cardRail}},
},
{
ID: "specific-crypto-card",
Enabled: &on,
Priority: 10,
Match: EdgeMatch{
Source: EndpointMatch{Rail: railPtr(model.RailCrypto), Custody: &external},
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout), Custody: &external},
},
Steps: []PolicyStep{{Code: "specific.submit", Action: model.RailOperationSend, Rail: &cardRail}},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 1 {
t.Fatalf("expected 1 policy step, got %d", len(graph.Steps))
}
if got, want := graph.Steps[0].StepCode, "specific.submit"; got != want {
t.Fatalf("expected high-priority specific policy, got %q", got)
}
}
func TestCompile_IndicativeRejected(t *testing.T) {
compiler := New()
_, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Rail: "CRYPTO",
},
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
Readiness: paymenttypes.QuoteExecutionReadinessIndicative,
},
},
})
if !errors.Is(err, ErrNotExecutable) {
t.Fatalf("expected ErrNotExecutable, got %v", err)
}
}
func TestCompile_ValidationErrors(t *testing.T) {
compiler := New()
enabled := true
tests := []struct {
name string
in Input
}{
{
name: "missing intent",
in: Input{
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{Rail: "CRYPTO"},
},
},
},
{
name: "missing quote",
in: Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
},
},
{
name: "missing route",
in: Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{},
},
},
{
name: "unknown hop rail",
in: Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{{Index: 1, Rail: "UNKNOWN"}},
},
},
},
},
{
name: "invalid policy step action",
in: Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
Policies: []Policy{
{
ID: "bad-policy",
Enabled: &enabled,
Priority: 1,
Match: EdgeMatch{
Source: EndpointMatch{Rail: railPtr(model.RailLedger)},
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)},
},
Steps: []PolicyStep{
{Code: "bad.step", Action: model.RailOperationUnspecified},
},
},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
_, err := compiler.Compile(tt.in)
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument, got %v", err)
}
})
}
}
func assertStep(
t *testing.T,
step Step,
code string,
action model.RailOperation,
rail model.Rail,
visibility model.ReportVisibility,
) {
t.Helper()
if got, want := step.StepCode, code; got != want {
t.Fatalf("step code mismatch: got=%q want=%q", got, want)
}
if got, want := step.Action, action; got != want {
t.Fatalf("step action mismatch: got=%q want=%q", got, want)
}
if got, want := step.Rail, rail; got != want {
t.Fatalf("step rail mismatch: got=%q want=%q", got, want)
}
if got, want := step.Visibility, visibility; got != want {
t.Fatalf("step visibility mismatch: got=%q want=%q", got, want)
}
}
func testIntent(kind model.PaymentKind) model.PaymentIntent {
return model.PaymentIntent{
Kind: kind,
Amount: &paymenttypes.Money{
Amount: "10",
Currency: "USD",
},
}
}
func railPtr(v model.Rail) *model.Rail {
return &v
}
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -30,6 +30,11 @@ func (s *QuotationServiceV2) singleResultFromRecord(record *model.PaymentQuoteRe
if err != nil { if err != nil {
return nil, err return nil, err
} }
intentRef := strings.TrimSpace(record.Intent.Ref)
if len(record.Intents) == 1 {
intentRef = firstNonEmpty(strings.TrimSpace(record.Intents[0].Ref), intentRef)
}
mapped.Quote.IntentRef = intentRef
return &QuotePaymentResult{ return &QuotePaymentResult{
Response: &quotationv2.QuotePaymentResponse{ Response: &quotationv2.QuotePaymentResponse{
Quote: mapped.Quote, Quote: mapped.Quote,
@@ -67,6 +72,9 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec
if err != nil { if err != nil {
return nil, err return nil, err
} }
if idx < len(record.Intents) {
mapped.Quote.IntentRef = strings.TrimSpace(record.Intents[idx].Ref)
}
quotes = append(quotes, mapped.Quote) quotes = append(quotes, mapped.Quote)
} }

View File

@@ -67,6 +67,9 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
if got, want := quote.GetQuoteRef(), "quote-single-usdt-rub"; got != want { if got, want := quote.GetQuoteRef(), "quote-single-usdt-rub"; got != want {
t.Fatalf("unexpected quote_ref: got=%q want=%q", got, want) t.Fatalf("unexpected quote_ref: got=%q want=%q", got, want)
} }
if got, want := quote.GetIntentRef(), "q-intent-single"; got != want {
t.Fatalf("unexpected intent_ref: got=%q want=%q", got, want)
}
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want { if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String()) t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
} }
@@ -175,6 +178,9 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
if got := strings.TrimSpace(reused.Response.GetQuote().GetRoute().GetProvider()); got != "" { if got := strings.TrimSpace(reused.Response.GetQuote().GetRoute().GetProvider()); got != "" {
t.Fatalf("expected idempotent route provider header to be empty, got=%q", got) t.Fatalf("expected idempotent route provider header to be empty, got=%q", got)
} }
if got, want := reused.Response.GetQuote().GetIntentRef(), "q-intent-single"; got != want {
t.Fatalf("unexpected idempotent intent_ref: got=%q want=%q", got, want)
}
t.Logf("single request:\n%s", mustProtoJSON(t, req)) t.Logf("single request:\n%s", mustProtoJSON(t, req))
t.Logf("single response:\n%s", mustProtoJSON(t, result.Response)) t.Logf("single response:\n%s", mustProtoJSON(t, result.Response))
@@ -290,6 +296,9 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
if quote == nil { if quote == nil {
t.Fatalf("quote[%d] is nil", i) t.Fatalf("quote[%d] is nil", i)
} }
if got := strings.TrimSpace(quote.GetIntentRef()); got == "" {
t.Fatalf("expected intent_ref for item %d", i)
}
if quote.GetQuoteRef() != "quote-batch-usdt-rub" { if quote.GetQuoteRef() != "quote-batch-usdt-rub" {
t.Fatalf("unexpected quote_ref for item %d: %q", i, quote.GetQuoteRef()) t.Fatalf("unexpected quote_ref for item %d: %q", i, quote.GetQuoteRef())
} }

View File

@@ -3,6 +3,7 @@ package quotation_service_v2
import ( import (
"context" "context"
"sort" "sort"
"strings"
"time" "time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2" "github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2"
@@ -165,6 +166,10 @@ func (p *singleIntentProcessorV2) Process(
if mapped == nil || mapped.Quote == nil { if mapped == nil || mapped.Quote == nil {
return nil, merrors.InvalidArgument("mapped quote is required") return nil, merrors.InvalidArgument("mapped quote is required")
} }
mapped.Quote.IntentRef = firstNonEmpty(
strings.TrimSpace(planItem.Intent.Ref),
strings.TrimSpace(in.Item.Intent.Ref),
)
expiresAt := result.ExpiresAt expiresAt := result.ExpiresAt
if quoteExpiresAt := mapped.Quote.GetExpiresAt(); quoteExpiresAt != nil { if quoteExpiresAt := mapped.Quote.GetExpiresAt(); quoteExpiresAt != nil {
expiresAt = quoteExpiresAt.AsTime().UTC() expiresAt = quoteExpiresAt.AsTime().UTC()

View File

@@ -140,7 +140,9 @@ func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, er
if dbSettings.URI != "" { if dbSettings.URI != "" {
logger.Info("Connected successfully", zap.Bool("uri_provided", true)) logger.Info("Connected successfully", zap.Bool("uri_provided", true))
} else { } else {
logger.Info("Connected successfully", zap.Strings("hosts", opts.Hosts), zap.String("replica_set", dbSettings.ReplicaSet)) logger.Info("Connected successfully", zap.String("user", dbSettings.User),
zap.Strings("hosts", opts.Hosts), zap.String("port", dbSettings.Port),
zap.String("database", dbSettings.Database), zap.String("replica_set", dbSettings.ReplicaSet))
} }
if err := client.Ping(context.Background(), readpref.Primary()); err != nil { if err := client.Ping(context.Background(), readpref.Primary()); err != nil {

View File

@@ -14,45 +14,75 @@ import "api/proto/common/pagination/v1/cursor.proto";
// ConnectorService exposes capability-driven account and operation primitives. // ConnectorService exposes capability-driven account and operation primitives.
service ConnectorService { service ConnectorService {
// GetCapabilities returns the capabilities advertised by this connector.
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
// OpenAccount provisions a new account on the connector.
rpc OpenAccount(OpenAccountRequest) returns (OpenAccountResponse); rpc OpenAccount(OpenAccountRequest) returns (OpenAccountResponse);
// GetAccount retrieves a single account by reference.
rpc GetAccount(GetAccountRequest) returns (GetAccountResponse); rpc GetAccount(GetAccountRequest) returns (GetAccountResponse);
// ListAccounts returns a paginated list of accounts.
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse); rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
// GetBalance returns the current balance of an account.
rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse); rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse);
// UpdateAccountState transitions an account to a new lifecycle state.
rpc UpdateAccountState(UpdateAccountStateRequest) returns (UpdateAccountStateResponse); rpc UpdateAccountState(UpdateAccountStateRequest) returns (UpdateAccountStateResponse);
// SubmitOperation submits a financial operation for execution.
rpc SubmitOperation(SubmitOperationRequest) returns (SubmitOperationResponse); rpc SubmitOperation(SubmitOperationRequest) returns (SubmitOperationResponse);
// GetOperation retrieves the current state of an operation.
rpc GetOperation(GetOperationRequest) returns (GetOperationResponse); rpc GetOperation(GetOperationRequest) returns (GetOperationResponse);
// ListOperations returns a paginated list of operations.
rpc ListOperations(ListOperationsRequest) returns (ListOperationsResponse); rpc ListOperations(ListOperationsRequest) returns (ListOperationsResponse);
} }
// AccountKind classifies the type of account managed by a connector.
enum AccountKind { enum AccountKind {
// ACCOUNT_KIND_UNSPECIFIED is the default zero value.
ACCOUNT_KIND_UNSPECIFIED = 0; ACCOUNT_KIND_UNSPECIFIED = 0;
// LEDGER_ACCOUNT is an internal ledger-based account.
LEDGER_ACCOUNT = 1; LEDGER_ACCOUNT = 1;
// CHAIN_MANAGED_WALLET is a blockchain managed wallet.
CHAIN_MANAGED_WALLET = 2; CHAIN_MANAGED_WALLET = 2;
// EXTERNAL_REF is a reference to an account outside the system.
EXTERNAL_REF = 3; EXTERNAL_REF = 3;
} }
// AccountState represents the lifecycle state of an account.
enum AccountState { enum AccountState {
// ACCOUNT_STATE_UNSPECIFIED is the default zero value.
ACCOUNT_STATE_UNSPECIFIED = 0; ACCOUNT_STATE_UNSPECIFIED = 0;
// ACCOUNT_ACTIVE means the account is open and operational.
ACCOUNT_ACTIVE = 1; ACCOUNT_ACTIVE = 1;
// ACCOUNT_SUSPENDED means the account is temporarily disabled.
ACCOUNT_SUSPENDED = 2; ACCOUNT_SUSPENDED = 2;
// ACCOUNT_CLOSED means the account is permanently closed.
ACCOUNT_CLOSED = 3; ACCOUNT_CLOSED = 3;
} }
// OperationType classifies the kind of financial operation.
enum OperationType { enum OperationType {
// OPERATION_TYPE_UNSPECIFIED is the default zero value.
OPERATION_TYPE_UNSPECIFIED = 0; OPERATION_TYPE_UNSPECIFIED = 0;
// CREDIT adds funds to an account.
CREDIT = 1; CREDIT = 1;
// DEBIT removes funds from an account.
DEBIT = 2; DEBIT = 2;
// TRANSFER moves funds between two accounts.
TRANSFER = 3; TRANSFER = 3;
// PAYOUT sends funds to an external destination.
PAYOUT = 4; PAYOUT = 4;
// FEE_ESTIMATE computes estimated fees without executing.
FEE_ESTIMATE = 5; FEE_ESTIMATE = 5;
// FX performs a foreign-exchange conversion.
FX = 6; FX = 6;
// GAS_TOPUP tops up native gas for blockchain transactions.
GAS_TOPUP = 7; GAS_TOPUP = 7;
} }
// OperationStatus tracks the lifecycle of an operation.
enum OperationStatus { enum OperationStatus {
// OPERATION_STATUS_UNSPECIFIED is the default zero value.
OPERATION_STATUS_UNSPECIFIED = 0; OPERATION_STATUS_UNSPECIFIED = 0;
OPERATION_CREATED = 1; // record exists, not started OPERATION_CREATED = 1; // record exists, not started
@@ -64,28 +94,45 @@ enum OperationStatus {
OPERATION_CANCELLED = 6; // final cancelled OPERATION_CANCELLED = 6; // final cancelled
} }
// ParamType defines the data type of a connector parameter.
enum ParamType { enum ParamType {
// PARAM_TYPE_UNSPECIFIED is the default zero value.
PARAM_TYPE_UNSPECIFIED = 0; PARAM_TYPE_UNSPECIFIED = 0;
// STRING is a plain string parameter.
STRING = 1; STRING = 1;
// INT is an integer parameter.
INT = 2; INT = 2;
// BOOL is a boolean parameter.
BOOL = 3; BOOL = 3;
// DECIMAL_STRING is a decimal number encoded as a string.
DECIMAL_STRING = 4; DECIMAL_STRING = 4;
// JSON is a free-form JSON parameter.
JSON = 5; JSON = 5;
} }
// ErrorCode enumerates well-known connector error codes.
enum ErrorCode { enum ErrorCode {
// ERROR_CODE_UNSPECIFIED is the default zero value.
ERROR_CODE_UNSPECIFIED = 0; ERROR_CODE_UNSPECIFIED = 0;
// UNSUPPORTED_OPERATION means the connector does not support this operation type.
UNSUPPORTED_OPERATION = 1; UNSUPPORTED_OPERATION = 1;
// UNSUPPORTED_ACCOUNT_KIND means the connector does not support this account kind.
UNSUPPORTED_ACCOUNT_KIND = 2; UNSUPPORTED_ACCOUNT_KIND = 2;
// INVALID_PARAMS means the supplied parameters are invalid.
INVALID_PARAMS = 3; INVALID_PARAMS = 3;
// INSUFFICIENT_FUNDS means the account lacks sufficient balance.
INSUFFICIENT_FUNDS = 4; INSUFFICIENT_FUNDS = 4;
// NOT_FOUND means the referenced resource was not found.
NOT_FOUND = 5; NOT_FOUND = 5;
// TEMPORARY_UNAVAILABLE means the connector is temporarily unavailable.
TEMPORARY_UNAVAILABLE = 6; TEMPORARY_UNAVAILABLE = 6;
// RATE_LIMITED means the caller has exceeded the rate limit.
RATE_LIMITED = 7; RATE_LIMITED = 7;
// PROVIDER_ERROR means an upstream provider returned an error.
PROVIDER_ERROR = 8; PROVIDER_ERROR = 8;
} }
// ParamSpec describes a single parameter accepted by the connector.
message ParamSpec { message ParamSpec {
string key = 1; string key = 1;
ParamType type = 2; ParamType type = 2;
@@ -95,11 +142,13 @@ message ParamSpec {
google.protobuf.Struct example = 6; google.protobuf.Struct example = 6;
} }
// OperationParamSpec groups the parameter specs for a given operation type.
message OperationParamSpec { message OperationParamSpec {
OperationType operation_type = 1; OperationType operation_type = 1;
repeated ParamSpec params = 2; repeated ParamSpec params = 2;
} }
// ConnectorCapabilities describes the features and constraints of a connector.
message ConnectorCapabilities { message ConnectorCapabilities {
string connector_type = 1; string connector_type = 1;
string version = 2; string version = 2;
@@ -112,16 +161,19 @@ message ConnectorCapabilities {
map<string, string> metadata = 9; map<string, string> metadata = 9;
} }
// AccountRef uniquely identifies an account within a connector.
message AccountRef { message AccountRef {
string connector_id = 1; string connector_id = 1;
string account_id = 2; string account_id = 2;
} }
// ExternalRef identifies an account or party outside the platform.
message ExternalRef { message ExternalRef {
string external_ref = 1; string external_ref = 1;
google.protobuf.Struct details = 2; google.protobuf.Struct details = 2;
} }
// OperationParty represents one side (source or destination) of an operation.
message OperationParty { message OperationParty {
oneof ref { oneof ref {
AccountRef account = 1; AccountRef account = 1;
@@ -129,6 +181,7 @@ message OperationParty {
} }
} }
// Account is the canonical representation of a connector-managed account.
message Account { message Account {
AccountRef ref = 1; AccountRef ref = 1;
AccountKind kind = 2; AccountKind kind = 2;
@@ -143,6 +196,7 @@ message Account {
common.account_role.v1.AccountRole role = 11; // functional role within the organization (ledger-only; unset for non-ledger connectors) common.account_role.v1.AccountRole role = 11; // functional role within the organization (ledger-only; unset for non-ledger connectors)
} }
// Balance holds the current balance breakdown for an account.
message Balance { message Balance {
AccountRef account_ref = 1; AccountRef account_ref = 1;
common.money.v1.Money available = 2; common.money.v1.Money available = 2;
@@ -151,6 +205,7 @@ message Balance {
google.protobuf.Timestamp calculated_at = 5; google.protobuf.Timestamp calculated_at = 5;
} }
// ConnectorError conveys a structured error from the connector.
message ConnectorError { message ConnectorError {
ErrorCode code = 1; ErrorCode code = 1;
string message = 2; string message = 2;
@@ -161,6 +216,7 @@ message ConnectorError {
string account_id = 7; string account_id = 7;
} }
// Operation represents a financial operation submitted to a connector.
message Operation { message Operation {
string operation_id = 1; string operation_id = 1;
OperationType type = 2; OperationType type = 2;
@@ -181,6 +237,7 @@ message Operation {
string intent_ref = 17; string intent_ref = 17;
} }
// OperationReceipt is the synchronous result returned after submitting an operation.
message OperationReceipt { message OperationReceipt {
string operation_id = 1; string operation_id = 1;
OperationStatus status = 2; OperationStatus status = 2;
@@ -189,12 +246,15 @@ message OperationReceipt {
google.protobuf.Struct result = 5; // connector-specific output payload google.protobuf.Struct result = 5; // connector-specific output payload
} }
// GetCapabilitiesRequest is the request for GetCapabilities.
message GetCapabilitiesRequest {} message GetCapabilitiesRequest {}
// GetCapabilitiesResponse is the response for GetCapabilities.
message GetCapabilitiesResponse { message GetCapabilitiesResponse {
ConnectorCapabilities capabilities = 1; ConnectorCapabilities capabilities = 1;
} }
// OpenAccountRequest is the request to provision a new account.
message OpenAccountRequest { message OpenAccountRequest {
string idempotency_key = 1; string idempotency_key = 1;
AccountKind kind = 2; AccountKind kind = 2;
@@ -207,19 +267,23 @@ message OpenAccountRequest {
common.account_role.v1.AccountRole role = 9; // functional role (ledger-only; ignored by non-ledger connectors) common.account_role.v1.AccountRole role = 9; // functional role (ledger-only; ignored by non-ledger connectors)
} }
// OpenAccountResponse is the response for OpenAccount.
message OpenAccountResponse { message OpenAccountResponse {
Account account = 1; Account account = 1;
ConnectorError error = 2; ConnectorError error = 2;
} }
// GetAccountRequest is the request to retrieve a single account.
message GetAccountRequest { message GetAccountRequest {
AccountRef account_ref = 1; AccountRef account_ref = 1;
} }
// GetAccountResponse is the response for GetAccount.
message GetAccountResponse { message GetAccountResponse {
Account account = 1; Account account = 1;
} }
// ListAccountsRequest is the request to list accounts with optional filters.
message ListAccountsRequest { message ListAccountsRequest {
reserved 1; reserved 1;
reserved "owner_ref"; reserved "owner_ref";
@@ -234,46 +298,56 @@ message ListAccountsRequest {
google.protobuf.StringValue owner_ref_filter = 6; google.protobuf.StringValue owner_ref_filter = 6;
} }
// ListAccountsResponse is the response for ListAccounts.
message ListAccountsResponse { message ListAccountsResponse {
repeated Account accounts = 1; repeated Account accounts = 1;
common.pagination.v1.CursorPageResponse page = 2; common.pagination.v1.CursorPageResponse page = 2;
} }
// UpdateAccountStateRequest is the request to change an account's lifecycle state.
message UpdateAccountStateRequest { message UpdateAccountStateRequest {
AccountRef account_ref = 1; AccountRef account_ref = 1;
AccountState target_state = 2; AccountState target_state = 2;
common.account_role.v1.AccountRole source_role = 3; // optional: assert account has this role before mutation common.account_role.v1.AccountRole source_role = 3; // optional: assert account has this role before mutation
} }
// UpdateAccountStateResponse is the response for UpdateAccountState.
message UpdateAccountStateResponse { message UpdateAccountStateResponse {
Account account = 1; Account account = 1;
ConnectorError error = 2; ConnectorError error = 2;
} }
// GetBalanceRequest is the request to retrieve an account balance.
message GetBalanceRequest { message GetBalanceRequest {
AccountRef account_ref = 1; AccountRef account_ref = 1;
} }
// GetBalanceResponse is the response for GetBalance.
message GetBalanceResponse { message GetBalanceResponse {
Balance balance = 1; Balance balance = 1;
} }
// SubmitOperationRequest is the request to submit a financial operation.
message SubmitOperationRequest { message SubmitOperationRequest {
Operation operation = 1; Operation operation = 1;
} }
// SubmitOperationResponse is the response for SubmitOperation.
message SubmitOperationResponse { message SubmitOperationResponse {
OperationReceipt receipt = 1; OperationReceipt receipt = 1;
} }
// GetOperationRequest is the request to retrieve an operation by ID.
message GetOperationRequest { message GetOperationRequest {
string operation_id = 1; string operation_id = 1;
} }
// GetOperationResponse is the response for GetOperation.
message GetOperationResponse { message GetOperationResponse {
Operation operation = 1; Operation operation = 1;
} }
// ListOperationsRequest is the request to list operations with optional filters.
message ListOperationsRequest { message ListOperationsRequest {
AccountRef account_ref = 1; AccountRef account_ref = 1;
OperationType type = 2; OperationType type = 2;
@@ -283,6 +357,7 @@ message ListOperationsRequest {
common.pagination.v1.CursorPageRequest page = 6; common.pagination.v1.CursorPageRequest page = 6;
} }
// ListOperationsResponse is the response for ListOperations.
message ListOperationsResponse { message ListOperationsResponse {
repeated Operation operations = 1; repeated Operation operations = 1;
common.pagination.v1.CursorPageResponse page = 2; common.pagination.v1.CursorPageResponse page = 2;

View File

@@ -40,6 +40,10 @@ message ExecutePaymentRequest {
// Optional caller-side correlation key. // Optional caller-side correlation key.
string client_payment_ref = 3; string client_payment_ref = 3;
// Optional intent selector for batch quotations.
// Must be provided when quotation_ref resolves to multiple intents.
string intent_ref = 4;
} }
// ExecutePaymentResponse returns the created or deduplicated payment. // ExecutePaymentResponse returns the created or deduplicated payment.

View File

@@ -125,4 +125,7 @@ message PaymentQuote {
common.money.v1.Money payer_total_debit_amount = 14; common.money.v1.Money payer_total_debit_amount = 14;
common.payment.v1.SettlementMode resolved_settlement_mode = 15; common.payment.v1.SettlementMode resolved_settlement_mode = 15;
FeeTreatment resolved_fee_treatment = 16; FeeTreatment resolved_fee_treatment = 16;
// Correlates this quote item with the originating quote intent.
// Required for disambiguating batch quotations at execution time.
string intent_ref = 17;
} }

View File

@@ -75,20 +75,89 @@ func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token *
// Query all gateways in parallel // Query all gateways in parallel
allAccounts := a.queryAllGateways(ctx, cryptoGateways, req) allAccounts := a.queryAllGateways(ctx, cryptoGateways, req)
dedupedAccounts := dedupeAccountsByWalletRef(allAccounts)
if len(dedupedAccounts) != len(allAccounts) {
a.logger.Debug("Deduplicated duplicate wallets from gateway fan-out",
zap.Int("before", len(allAccounts)),
zap.Int("after", len(dedupedAccounts)))
}
return sresponse.WalletsFromAccounts(a.logger, allAccounts, token) return sresponse.WalletsFromAccounts(a.logger, dedupedAccounts, token)
} }
func filterCryptoGateways(gateways []discovery.GatewaySummary) []discovery.GatewaySummary { func filterCryptoGateways(gateways []discovery.GatewaySummary) []discovery.GatewaySummary {
result := make([]discovery.GatewaySummary, 0) result := make([]discovery.GatewaySummary, 0)
indexByInvokeURI := map[string]int{}
for _, gw := range gateways { for _, gw := range gateways {
if strings.EqualFold(gw.Rail, cryptoRail) && gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" { if strings.EqualFold(gw.Rail, cryptoRail) && gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" {
invokeURI := strings.ToLower(strings.TrimSpace(gw.InvokeURI))
if idx, ok := indexByInvokeURI[invokeURI]; ok {
// Keep the entry with higher priority if the same backend was announced multiple times.
if gw.RoutingPriority > result[idx].RoutingPriority {
result[idx] = gw
}
continue
}
indexByInvokeURI[invokeURI] = len(result)
result = append(result, gw) result = append(result, gw)
} }
} }
return result return result
} }
func dedupeAccountsByWalletRef(accounts []*connectorv1.Account) []*connectorv1.Account {
if len(accounts) == 0 {
return nil
}
result := make([]*connectorv1.Account, 0, len(accounts))
seen := make(map[string]struct{}, len(accounts))
for _, account := range accounts {
if account == nil {
continue
}
walletRef := accountWalletRef(account)
if walletRef == "" {
// If ref is missing, keep item to avoid dropping potentially valid records.
result = append(result, account)
continue
}
if _, ok := seen[walletRef]; ok {
continue
}
seen[walletRef] = struct{}{}
result = append(result, account)
}
return result
}
func accountWalletRef(account *connectorv1.Account) string {
if account == nil {
return ""
}
if ref := account.GetRef(); ref != nil {
accountID := strings.TrimSpace(ref.GetAccountId())
if accountID != "" {
return accountID
}
}
details := account.GetProviderDetails()
if details == nil {
return ""
}
field, ok := details.GetFields()["wallet_ref"]
if !ok || field == nil {
return ""
}
return strings.TrimSpace(field.GetStringValue())
}
func (a *WalletAPI) queryAllGateways(ctx context.Context, gateways []discovery.GatewaySummary, req *connectorv1.ListAccountsRequest) []*connectorv1.Account { func (a *WalletAPI) queryAllGateways(ctx context.Context, gateways []discovery.GatewaySummary, req *connectorv1.ListAccountsRequest) []*connectorv1.Account {
var mu sync.Mutex var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup

View File

@@ -0,0 +1,116 @@
package walletapiimp
import (
"testing"
"github.com/tech/sendico/pkg/discovery"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"google.golang.org/protobuf/types/known/structpb"
)
func TestFilterCryptoGateways_DedupesByInvokeURI(t *testing.T) {
gateways := []discovery.GatewaySummary{
{
ID: "gw-low",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "dev-tron-gateway:50071",
Network: "TRON_MAINNET",
RoutingPriority: 10,
},
{
ID: "gw-high",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "dev-tron-gateway:50071",
Network: "TRON_NILE",
RoutingPriority: 20,
},
{
ID: "gw-chain",
Rail: "CRYPTO",
Healthy: true,
InvokeURI: "dev-chain-gateway:50070",
Network: "ARBITRUM_SEPOLIA",
RoutingPriority: 5,
},
{
ID: "gw-unhealthy",
Rail: "CRYPTO",
Healthy: false,
InvokeURI: "dev-unhealthy:50070",
Network: "TRON_MAINNET",
RoutingPriority: 99,
},
}
filtered := filterCryptoGateways(gateways)
if len(filtered) != 2 {
t.Fatalf("expected 2 filtered gateways, got %d", len(filtered))
}
byInvoke := map[string]discovery.GatewaySummary{}
for _, gw := range filtered {
byInvoke[gw.InvokeURI] = gw
}
tron, ok := byInvoke["dev-tron-gateway:50071"]
if !ok {
t.Fatalf("expected tron gateway entry")
}
if tron.ID != "gw-high" {
t.Fatalf("expected higher-priority duplicate to win, got %q", tron.ID)
}
if _, ok := byInvoke["dev-chain-gateway:50070"]; !ok {
t.Fatalf("expected chain gateway entry")
}
}
func TestDedupeAccountsByWalletRef(t *testing.T) {
detailsA, err := structpb.NewStruct(map[string]interface{}{"wallet_ref": "wallet-3"})
if err != nil {
t.Fatalf("build provider details: %v", err)
}
detailsB, err := structpb.NewStruct(map[string]interface{}{"wallet_ref": "wallet-3"})
if err != nil {
t.Fatalf("build provider details: %v", err)
}
accounts := []*connectorv1.Account{
{Ref: &connectorv1.AccountRef{AccountId: "wallet-1"}},
{Ref: &connectorv1.AccountRef{AccountId: "wallet-1"}}, // duplicate
{Ref: &connectorv1.AccountRef{AccountId: "wallet-2"}},
{ProviderDetails: detailsA},
{ProviderDetails: detailsB}, // duplicate via provider_details.wallet_ref
{Ref: &connectorv1.AccountRef{AccountId: ""}}, // kept: missing ref
nil, // ignored
}
deduped := dedupeAccountsByWalletRef(accounts)
if len(deduped) != 4 {
t.Fatalf("expected 4 accounts after dedupe, got %d", len(deduped))
}
seen := map[string]int{}
for _, acc := range deduped {
if acc == nil {
t.Fatalf("deduped account should never be nil")
}
ref := accountWalletRef(acc)
seen[ref]++
}
if seen["wallet-1"] != 1 {
t.Fatalf("expected wallet-1 once, got %d", seen["wallet-1"])
}
if seen["wallet-2"] != 1 {
t.Fatalf("expected wallet-2 once, got %d", seen["wallet-2"])
}
if seen["wallet-3"] != 1 {
t.Fatalf("expected wallet-3 once, got %d", seen["wallet-3"])
}
if seen[""] != 1 {
t.Fatalf("expected one account with missing wallet ref, got %d", seen[""])
}
}