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.
- path: _test\.go
linters:
- funlen
- gocyclo
- errcheck
- 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,
Version: Version,
}
return vf.Create(&info)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -33,6 +32,8 @@ import (
)
type Service struct {
feesv1.UnimplementedFeeEngineServer
logger mlogger.Logger
storage storage.Repository
producer msg.Producer
@@ -42,7 +43,6 @@ type Service struct {
resolver FeeResolver
announcer *discovery.Announcer
invokeURI string
feesv1.UnimplementedFeeEngineServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
@@ -52,6 +52,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
producer: producer,
clock: clockpkg.NewSystem(),
}
initMetrics()
for _, opt := range opts {
@@ -61,9 +62,11 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
if svc.clock == nil {
svc.clock = clockpkg.NewSystem()
}
if svc.calculator == nil {
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
}
if svc.resolver == nil {
svc.resolver = resolver.New(repo.Plans(), svc.logger)
}
@@ -83,25 +86,12 @@ func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "BILLING_FEES",
Operations: []string{"fee.calc"},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
s.announcer.Start()
}
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
var (
meta *feesv1.RequestMeta
@@ -111,23 +101,29 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if intent != nil {
trigger = intent.GetTrigger()
}
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
}
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
@@ -140,8 +136,10 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
}
if err != nil {
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("QuoteFees finished", logFields...)
}()
@@ -155,12 +153,14 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
if parseErr != nil {
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
lines, applied, fx, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
lines, applied, fxResult, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
if computeErr != nil {
err = computeErr
return nil, err
}
@@ -168,8 +168,9 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
Lines: lines,
Applied: applied,
FxUsed: fx,
FxUsed: fxResult,
}
return resp, nil
}
@@ -182,29 +183,289 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if intent != nil {
trigger = intent.GetTrigger()
}
var (
fxUsed bool
expiresAt time.Time
)
defer func() { s.observePrecomputeFees(logger, err, resp, trigger, start) }()
logger.Debug("PrecomputeFees request received")
if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err
}
now := s.clock.Now()
orgRef, parseErr := bson.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
if computeErr != nil {
err = computeErr
return nil, err
}
ttl := req.GetTtlMs()
if ttl <= 0 {
ttl = 60000
}
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(),
Intent: req.GetIntent(),
ExpiresAtUnixMs: expiresAt.UnixMilli(),
Trace: req.GetMeta().GetTrace(),
}
var token string
if token, err = encodeTokenPayload(payload); err != nil {
logger.Warn("Failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err
}
resp = &feesv1.PrecomputeFeesResponse{
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
FeeQuoteToken: token,
ExpiresAt: timestamppb.New(expiresAt),
Lines: lines,
Applied: applied,
FxUsed: fxResult,
}
return resp, nil
}
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
tokenLen := 0
if req != nil {
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
}
logger := s.logger.With(zap.Int("token_length", tokenLen))
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var resultReason string
defer func() { s.observeValidateFeeToken(logger, err, resp, trigger, resultReason, start) }()
logger.Debug("ValidateFeeToken request received")
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err
}
now := s.clock.Now()
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil {
resultReason = "invalid_token"
logger.Warn("Failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
if payload.Intent != nil {
trigger = payload.Intent.GetTrigger()
}
if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired"
logger.Info("Fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil
}
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil {
resultReason = "invalid_token"
logger.Warn("Token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
if computeErr != nil {
err = computeErr
return nil, err
}
resp = &feesv1.ValidateFeeTokenResponse{
Meta: &feesv1.ResponseMeta{Trace: payload.Trace},
Valid: true,
Intent: payload.Intent,
Lines: lines,
Applied: applied,
FxUsed: fxResult,
}
return resp, nil
}
func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
}
if req.GetIntent() == nil {
return status.Error(codes.InvalidArgument, "intent is required")
}
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
return status.Error(codes.InvalidArgument, "intent.trigger is required")
}
if req.GetIntent().GetBaseAmount() == nil {
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
}
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
}
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
}
return nil
}
func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
}
func (s *Service) computeQuote(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
return s.computeQuoteWithTime(ctx, orgRef, intent, overrides, trace, s.clock.Now())
}
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, _ *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
bookedAt := resolvedBookedAt(intent, now)
logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)}
if !orgRef.IsZero() {
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
}
logFields = append(logFields, logFieldsFromIntent(intent)...)
logFields = append(logFields, logFieldsFromTrace(trace)...)
logger := s.logger.With(logFields...)
var orgPtr *bson.ObjectID
if !orgRef.IsZero() {
orgPtr = &orgRef
}
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil {
s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
return nil, nil, nil, mapResolveError(err)
}
originalRules := plan.Rules
plan.Rules = []model.FeeRule{*rule}
defer func() {
plan.Rules = originalRules
}()
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
if calcErr != nil {
if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
}
logger.Warn("Failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
}
return result.Lines, result.Applied, result.FxUsed, nil
}
func resolvedBookedAt(intent *feesv1.Intent, now time.Time) time.Time {
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
return intent.GetBookedAt().AsTime()
}
return now
}
func mapResolveError(err error) error {
switch {
case errors.Is(err, merrors.ErrNoData):
return status.Error(codes.NotFound, "fee rule not found: "+err.Error())
case errors.Is(err, merrors.ErrDataConflict):
return status.Error(codes.FailedPrecondition, "conflicting fee rules: "+err.Error())
case errors.Is(err, storage.ErrConflictingFeePlans):
return status.Error(codes.FailedPrecondition, "conflicting fee plans: "+err.Error())
case errors.Is(err, storage.ErrFeePlanNotFound):
return status.Error(codes.NotFound, "fee plan not found: "+err.Error())
default:
return status.Error(codes.Internal, "failed to resolve fee rule: "+err.Error())
}
}
func (s *Service) observePrecomputeFees(logger mlogger.Logger, err error, resp *feesv1.PrecomputeFeesResponse, trigger feesv1.Trigger, start time.Time) {
statusLabel := statusFromError(err)
fxUsed := false
linesCount := 0
appliedCount := 0
var expiresAt time.Time
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
if ts := resp.GetExpiresAt(); ts != nil {
expiresAt = ts.AsTime()
}
}
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
@@ -220,87 +481,28 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
}
if err != nil {
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("PrecomputeFees finished", logFields...)
}()
logger.Debug("PrecomputeFees request received")
if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err
}
now := s.clock.Now()
orgRef, parseErr := bson.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
if computeErr != nil {
err = computeErr
return nil, err
}
ttl := req.GetTtlMs()
if ttl <= 0 {
ttl = 60000
}
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(),
Intent: req.GetIntent(),
ExpiresAtUnixMs: expiresAt.UnixMilli(),
Trace: req.GetMeta().GetTrace(),
}
var token string
if token, err = encodeTokenPayload(payload); err != nil {
logger.Warn("Failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err
}
resp = &feesv1.PrecomputeFeesResponse{
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
FeeQuoteToken: token,
ExpiresAt: timestamppb.New(expiresAt),
Lines: lines,
Applied: applied,
FxUsed: fx,
}
return resp, nil
}
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
tokenLen := 0
if req != nil {
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
}
logger := s.logger.With(zap.Int("token_length", tokenLen))
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var (
fxUsed bool
resultReason string
)
defer func() {
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{
@@ -313,159 +515,14 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
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")
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err
}
now := s.clock.Now()
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil {
resultReason = "invalid_token"
logger.Warn("Failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
if payload.Intent != nil {
trigger = payload.Intent.GetTrigger()
}
if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired"
logger.Info("Fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil
}
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil {
resultReason = "invalid_token"
logger.Warn("Token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
if computeErr != nil {
err = computeErr
return nil, err
}
resp = &feesv1.ValidateFeeTokenResponse{
Meta: &feesv1.ResponseMeta{Trace: payload.Trace},
Valid: true,
Intent: payload.Intent,
Lines: lines,
Applied: applied,
FxUsed: fx,
}
return resp, nil
}
func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
}
if req.GetIntent() == nil {
return status.Error(codes.InvalidArgument, "intent is required")
}
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
return status.Error(codes.InvalidArgument, "intent.trigger is required")
}
if req.GetIntent().GetBaseAmount() == nil {
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
}
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
}
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
}
return nil
}
func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
}
func (s *Service) computeQuote(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
return s.computeQuoteWithTime(ctx, orgRef, intent, overrides, trace, s.clock.Now())
}
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, _ *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
bookedAt := now
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
bookedAt = intent.GetBookedAt().AsTime()
}
logFields := []zap.Field{
zap.Time("booked_at_used", bookedAt),
}
if !orgRef.IsZero() {
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
}
logFields = append(logFields, logFieldsFromIntent(intent)...)
logFields = append(logFields, logFieldsFromTrace(trace)...)
logger := s.logger.With(logFields...)
var orgPtr *bson.ObjectID
if !orgRef.IsZero() {
orgPtr = &orgRef
}
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil {
s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
switch {
case errors.Is(err, merrors.ErrNoData):
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee rule not found: %s", err.Error()))
case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee rules: %s", err.Error()))
case errors.Is(err, storage.ErrConflictingFeePlans):
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee plans: %s", err.Error()))
case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee plan not found: %s", err.Error()))
default:
return nil, nil, nil, status.Error(codes.Internal, fmt.Sprintf("failed to resolve fee rule: %s", err.Error()))
}
}
originalRules := plan.Rules
plan.Rules = []model.FeeRule{*rule}
defer func() {
plan.Rules = originalRules
}()
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
if calcErr != nil {
if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
}
logger.Warn("Failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
}
return result.Lines, result.Applied, result.FxUsed, nil
}
type feeQuoteTokenPayload struct {
@@ -480,17 +537,36 @@ func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
if err != nil {
return "", merrors.Internal("fees: failed to serialize token payload")
}
return base64.StdEncoding.EncodeToString(data), nil
}
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
var payload feeQuoteTokenPayload
data, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return payload, merrors.InvalidArgument("fees: invalid token encoding")
}
if err := json.Unmarshal(data, &payload); err != nil {
return payload, merrors.InvalidArgument("fees: invalid token payload")
}
return payload, nil
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "BILLING_FEES",
Operations: []string{"fee.calc"},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.FeePlans, announce)
s.announcer.Start()
}

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ const (
type FeePlan struct {
storable.Base `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"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ func TestFingerprint_StableAndTrimmed(t *testing.T) {
a, err := svc.Fingerprint(FPInput{
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
QuotationRef: " quote-1 ",
IntentRef: " intent-1 ",
ClientPaymentRef: " client-1 ",
})
if err != nil {
@@ -24,6 +25,7 @@ func TestFingerprint_StableAndTrimmed(t *testing.T) {
b, err := svc.Fingerprint(FPInput{
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
QuotationRef: "quote-1",
IntentRef: "intent-1",
ClientPaymentRef: "client-1",
})
if err != nil {
@@ -40,6 +42,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
base, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-1",
IntentRef: "intent-1",
ClientPaymentRef: "client-1",
})
if err != nil {
@@ -49,6 +52,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
diffQuote, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-2",
IntentRef: "intent-1",
ClientPaymentRef: "client-1",
})
if err != nil {
@@ -61,6 +65,7 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
diffClient, err := svc.Fingerprint(FPInput{
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
QuotationRef: "quote-1",
IntentRef: "intent-1",
ClientPaymentRef: "client-2",
})
if err != nil {
@@ -69,6 +74,19 @@ func TestFingerprint_ChangesOnPayload(t *testing.T) {
if base == diffClient {
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) {

View File

@@ -7,4 +7,6 @@ var (
ErrQuoteExpired = errors.New("quotation_ref expired")
ErrQuoteNotExecutable = errors.New("quotation_ref is not executable")
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 {
OrganizationID bson.ObjectID
QuotationRef string
IntentRef string
}
// Output contains extracted canonical snapshots for execution.
type Output struct {
QuotationRef string
IntentRef string
IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot
}

View File

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

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -41,6 +42,7 @@ func TestResolve_SingleShapeOK(t *testing.T) {
}, Input{
OrganizationID: orgID,
QuotationRef: "stored-quote-ref",
IntentRef: "intent-1",
})
if err != nil {
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 {
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 {
t.Fatal("expected quote snapshot")
}
@@ -103,6 +108,9 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
if out == nil {
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 {
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) {
resolver := New()
@@ -232,7 +363,6 @@ func TestResolve_ShapeMismatch(t *testing.T) {
},
Quotes: []*model.PaymentQuoteSnapshot{
{},
{},
},
ExpiresAt: now.Add(time.Minute),
}, nil
@@ -240,6 +370,7 @@ func TestResolve_ShapeMismatch(t *testing.T) {
}, Input{
OrganizationID: bson.NewObjectID(),
QuotationRef: "quote-ref",
IntentRef: "intent-1",
})
if !errors.Is(err, ErrQuoteShapeMismatch) {
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ func TestValidate_OK(t *testing.T) {
},
},
QuotationRef: " quote-ref-1 ",
IntentRef: " intent-ref-1 ",
ClientPaymentRef: " client.ref-1 ",
})
if err != nil {
@@ -39,6 +40,9 @@ func TestValidate_OK(t *testing.T) {
if got, want := ctx.QuotationRef, "quote-ref-1"; 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 {
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 != "" {
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) {
orgID := bson.NewObjectID().Hex()
tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen)
tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen)
tooLongIntent := "i" + strings.Repeat("a", maxIntentRefLen)
tooLongClient := "c" + strings.Repeat("a", maxClientRefLen)
tests := []struct {
@@ -185,6 +193,28 @@ func TestValidate_Errors(t *testing.T) {
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",
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 {
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{
Response: &quotationv2.QuotePaymentResponse{
Quote: mapped.Quote,
@@ -67,6 +72,9 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec
if err != nil {
return nil, err
}
if idx < len(record.Intents) {
mapped.Quote.IntentRef = strings.TrimSpace(record.Intents[idx].Ref)
}
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 {
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 {
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 != "" {
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 response:\n%s", mustProtoJSON(t, result.Response))
@@ -290,6 +296,9 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
if quote == nil {
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" {
t.Fatalf("unexpected quote_ref for item %d: %q", i, quote.GetQuoteRef())
}

View File

@@ -3,6 +3,7 @@ package quotation_service_v2
import (
"context"
"sort"
"strings"
"time"
"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 {
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
if quoteExpiresAt := mapped.Quote.GetExpiresAt(); quoteExpiresAt != nil {
expiresAt = quoteExpiresAt.AsTime().UTC()

View File

@@ -140,7 +140,9 @@ func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, er
if dbSettings.URI != "" {
logger.Info("Connected successfully", zap.Bool("uri_provided", true))
} 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 {

View File

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

View File

@@ -40,6 +40,10 @@ message ExecutePaymentRequest {
// Optional caller-side correlation key.
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.

View File

@@ -125,4 +125,7 @@ message PaymentQuote {
common.money.v1.Money payer_total_debit_amount = 14;
common.payment.v1.SettlementMode resolved_settlement_mode = 15;
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
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 {
result := make([]discovery.GatewaySummary, 0)
indexByInvokeURI := map[string]int{}
for _, gw := range gateways {
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)
}
}
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 {
var mu sync.Mutex
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[""])
}
}