From 20cb0576186de9a4259c26064488ba2993383b6e Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 20 Feb 2026 13:52:09 +0100 Subject: [PATCH] wallets listing dedupe --- api/billing/documents/.golangci.yml | 1 + api/billing/fees/.golangci.yml | 198 ++++ .../fees/internal/appversion/version.go | 1 + .../internal/server/internal/serverimp.go | 21 +- .../service/fees/internal/calculator/impl.go | 318 ++++-- .../service/fees/internal/resolver/impl.go | 173 ++- .../fees/internal/resolver/resolver_test.go | 60 +- .../fees/internal/service/fees/logging.go | 18 + .../fees/internal/service/fees/metrics.go | 4 + .../fees/internal/service/fees/service.go | 294 ++++-- .../internal/service/fees/service_test.go | 36 +- .../fees/internal/service/fees/trigger.go | 2 + api/billing/fees/storage/model/plan.go | 45 +- api/billing/fees/storage/mongo/repository.go | 4 + api/billing/fees/storage/mongo/store/plans.go | 128 ++- api/discovery/.golangci.yml | 1 + api/fx/ingestor/.golangci.yml | 1 + .../orchestrationv2/idem/fingerprint.go | 2 + .../service/orchestrationv2/idem/module.go | 1 + .../orchestrationv2/idem/service_test.go | 18 + .../service/orchestrationv2/qsnap/errors.go | 2 + .../service/orchestrationv2/qsnap/module.go | 2 + .../service/orchestrationv2/qsnap/service.go | 191 +++- .../orchestrationv2/qsnap/service_test.go | 133 ++- .../service/orchestrationv2/reqval/module.go | 2 + .../orchestrationv2/reqval/validator.go | 7 + .../orchestrationv2/reqval/validator_test.go | 30 + .../service/orchestrationv2/xplan/errors.go | 7 + .../service/orchestrationv2/xplan/module.go | 110 ++ .../service/orchestrationv2/xplan/service.go | 989 ++++++++++++++++++ .../orchestrationv2/xplan/service_test.go | 493 +++++++++ .../quotation/quotation_service_v2/reuse.go | 8 + .../quotation_service_v2/service_e2e_test.go | 9 + .../quotation_service_v2/single_processor.go | 5 + api/pkg/db/internal/mongo/db.go | 4 +- api/proto/connector/v1/connector.proto | 77 +- .../orchestration/v2/orchestration.proto | 4 + .../payments/quotation/v2/interface.proto | 3 + .../internal/server/walletapiimp/list.go | 71 +- .../internal/server/walletapiimp/list_test.go | 116 ++ 40 files changed, 3166 insertions(+), 423 deletions(-) create mode 100644 api/billing/fees/.golangci.yml create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/errors.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go create mode 100644 api/server/internal/server/walletapiimp/list_test.go diff --git a/api/billing/documents/.golangci.yml b/api/billing/documents/.golangci.yml index f489facb..d292eb83 100644 --- a/api/billing/documents/.golangci.yml +++ b/api/billing/documents/.golangci.yml @@ -163,6 +163,7 @@ linters: # Exclude some linters from running on tests files. - path: _test\.go linters: + - funlen - gocyclo - errcheck - dupl diff --git a/api/billing/fees/.golangci.yml b/api/billing/fees/.golangci.yml new file mode 100644 index 00000000..73d4efc3 --- /dev/null +++ b/api/billing/fees/.golangci.yml @@ -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: [] diff --git a/api/billing/fees/internal/appversion/version.go b/api/billing/fees/internal/appversion/version.go index de65b6ab..b4aa2151 100644 --- a/api/billing/fees/internal/appversion/version.go +++ b/api/billing/fees/internal/appversion/version.go @@ -24,5 +24,6 @@ func Create() version.Printer { BuildDate: BuildDate, Version: Version, } + return vf.Create(&info) } diff --git a/api/billing/fees/internal/server/internal/serverimp.go b/api/billing/fees/internal/server/internal/serverimp.go index fc708d7c..cb850d97 100644 --- a/api/billing/fees/internal/server/internal/serverimp.go +++ b/api/billing/fees/internal/server/internal/serverimp.go @@ -31,7 +31,8 @@ type Imp struct { type config struct { *grpcapp.Config `yaml:",inline"` - Oracle OracleConfig `yaml:"oracle"` + + Oracle OracleConfig `yaml:"oracle"` } type OracleConfig struct { @@ -45,6 +46,7 @@ func (c OracleConfig) dialTimeout() time.Duration { if c.DialTimeoutSecs <= 0 { return 5 * time.Second } + return time.Duration(c.DialTimeoutSecs) * time.Second } @@ -52,6 +54,7 @@ func (c OracleConfig) callTimeout() time.Duration { if c.CallTimeoutSecs <= 0 { return 3 * time.Second } + return time.Duration(c.CallTimeoutSecs) * time.Second } @@ -69,9 +72,11 @@ func (i *Imp) Shutdown() { if i.service != nil { i.service.Shutdown() } + if i.oracleClient != nil { _ = i.oracleClient.Close() } + return } @@ -98,6 +103,7 @@ func (i *Imp) Start() error { if err != nil { return err } + i.config = cfg repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { @@ -105,11 +111,12 @@ func (i *Imp) Start() error { } var oracleClient oracleclient.Client + if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" { dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout()) defer cancel() - oc, err := oracleclient.New(dialCtx, oracleclient.Config{ + oracleConn, err := oracleclient.New(dialCtx, oracleclient.Config{ Address: addr, DialTimeout: cfg.Oracle.dialTimeout(), CallTimeout: cfg.Oracle.callTimeout(), @@ -118,8 +125,8 @@ func (i *Imp) Start() error { if err != nil { i.logger.Warn("Failed to initialise oracle client", zap.String("address", addr), zap.Error(err)) } else { - oracleClient = oc - i.oracleClient = oc + oracleClient = oracleConn + i.oracleClient = oracleConn i.logger.Info("Connected to oracle service", zap.String("address", addr)) } } @@ -129,13 +136,16 @@ func (i *Imp) Start() error { if oracleClient != nil { opts = append(opts, fees.WithOracleClient(oracleClient)) } + if cfg.GRPC != nil { if invokeURI := cfg.GRPC.DiscoveryInvokeURI(); invokeURI != "" { opts = append(opts, fees.WithDiscoveryInvokeURI(invokeURI)) } } + svc := fees.NewService(logger, repo, producer, opts...) i.service = svc + return svc, nil } @@ -143,6 +153,7 @@ func (i *Imp) Start() error { if err != nil { return err } + i.app = app return i.app.Start() @@ -152,12 +163,14 @@ func (i *Imp) loadConfig() (*config, error) { data, err := os.ReadFile(i.file) if err != nil { i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err } cfg := &config{Config: &grpcapp.Config{}} if err := yaml.Unmarshal(data, cfg); err != nil { i.logger.Error("Failed to parse configuration", zap.Error(err)) + return nil, err } diff --git a/api/billing/fees/internal/service/fees/internal/calculator/impl.go b/api/billing/fees/internal/service/fees/internal/calculator/impl.go index 27ad8b17..f6c6114d 100644 --- a/api/billing/fees/internal/service/fees/internal/calculator/impl.go +++ b/api/billing/fees/internal/service/fees/internal/calculator/impl.go @@ -3,6 +3,7 @@ package calculator import ( "context" "errors" + "maps" "math/big" "sort" "strconv" @@ -33,6 +34,7 @@ func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator { if logger == nil { logger = zap.NewNop() } + return "eCalculator{ logger: logger.Named("calculator"), oracle: oracle, @@ -45,27 +47,10 @@ type quoteCalculator struct { } func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) { - if plan == nil { - return nil, merrors.InvalidArgument("plan is required") - } - if intent == nil { - return nil, merrors.InvalidArgument("intent is required") - } - - trigger := convertTrigger(intent.GetTrigger()) - if trigger == model.TriggerUnspecified { - return nil, merrors.InvalidArgument("unsupported trigger") - } - - baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount()) + baseAmount, baseScale, trigger, err := validateComputeInputs(plan, intent) if err != nil { - return nil, merrors.InvalidArgument("invalid base amount") + return nil, err } - if baseAmount.Sign() < 0 { - return nil, merrors.InvalidArgument("base amount cannot be negative") - } - - baseScale := inferScale(intent.GetBaseAmount().GetAmount()) rules := make([]model.FeeRule, len(plan.Rules)) copy(rules, plan.Rules) @@ -73,81 +58,37 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte if rules[i].Priority == rules[j].Priority { return rules[i].RuleID < rules[j].RuleID } + return rules[i].Priority < rules[j].Priority }) + planID := planIDFrom(plan) lines := make([]*feesv1.DerivedPostingLine, 0, len(rules)) applied := make([]*feesv1.AppliedRule, 0, len(rules)) - planID := "" - if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() { - planID = planRef.Hex() - } - for _, rule := range rules { if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) { continue } - ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef) - amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule) if calcErr != nil { if !errors.Is(calcErr, merrors.ErrInvalidArg) { c.logger.Warn("Failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr)) } + continue } + if amount.Sign() == 0 { continue } - currency := intent.GetBaseAmount().GetCurrency() - if override := strings.TrimSpace(rule.Currency); override != "" { - currency = override - } + currency := resolvedCurrency(intent.GetBaseAmount().GetCurrency(), rule.Currency) + entrySide := resolvedEntrySide(rule) - entrySide := mapEntrySide(rule.EntrySide) - if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED { - // Default fees to debit (i.e. charge the customer) when entry side is not specified. - entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT - } - - meta := map[string]string{ - "fee_rule_id": rule.RuleID, - } - if planID != "" { - meta["fee_plan_id"] = planID - } - if rule.Metadata != nil { - if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" { - meta["tax_code"] = taxCode - } - if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" { - meta["tax_rate"] = taxRate - } - } - - lines = append(lines, &feesv1.DerivedPostingLine{ - LedgerAccountRef: ledgerAccountRef, - Money: &moneyv1.Money{ - Amount: dmath.FormatRat(amount, scale), - Currency: currency, - }, - LineType: mapLineType(rule.LineType), - Side: entrySide, - Meta: meta, - }) - - applied = append(applied, &feesv1.AppliedRule{ - RuleId: rule.RuleID, - RuleVersion: planID, - Formula: rule.Formula, - Rounding: mapRoundingMode(rule.Rounding), - TaxCode: metadataValue(rule.Metadata, "tax_code"), - TaxRate: metadataValue(rule.Metadata, "tax_rate"), - Parameters: cloneStringMap(rule.Metadata), - }) + lines = append(lines, buildPostingLine(rule, amount, scale, currency, entrySide, planID)) + applied = append(applied, buildAppliedRule(rule, planID)) } var fxUsed *feesv1.FXUsed @@ -170,40 +111,24 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin result := new(big.Rat) - if percentage := strings.TrimSpace(rule.Percentage); percentage != "" { - percentageRat, perr := dmath.RatFromString(percentage) - if perr != nil { - return nil, 0, merrors.InvalidArgument("invalid percentage") - } - result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat)) + result, err = applyPercentage(result, baseAmount, rule.Percentage) + if err != nil { + return nil, 0, err } - if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" { - fixedRat, ferr := dmath.RatFromString(fixed) - if ferr != nil { - return nil, 0, merrors.InvalidArgument("invalid fixed amount") - } - result = dmath.AddRat(result, fixedRat) + result, err = applyFixed(result, rule.FixedAmount) + if err != nil { + return nil, 0, err } - if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" { - minRat, merr := dmath.RatFromString(minStr) - if merr != nil { - return nil, 0, merrors.InvalidArgument("invalid minimum amount") - } - if dmath.CmpRat(result, minRat) < 0 { - result = new(big.Rat).Set(minRat) - } + result, err = applyMin(result, rule.MinimumAmount) + if err != nil { + return nil, 0, err } - if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" { - maxRat, merr := dmath.RatFromString(maxStr) - if merr != nil { - return nil, 0, merrors.InvalidArgument("invalid maximum amount") - } - if dmath.CmpRat(result, maxRat) > 0 { - result = new(big.Rat).Set(maxRat) - } + result, err = applyMax(result, rule.MaximumAmount) + if err != nil { + return nil, 0, err } if result.Sign() < 0 { @@ -218,6 +143,66 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin return rounded, scale, nil } +func applyPercentage(result, baseAmount *big.Rat, percentage string) (*big.Rat, error) { + if strings.TrimSpace(percentage) == "" { + return result, nil + } + + percentageRat, err := dmath.RatFromString(percentage) + if err != nil { + return nil, merrors.InvalidArgument("invalid percentage") + } + + return dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat)), nil +} + +func applyFixed(result *big.Rat, fixed string) (*big.Rat, error) { + if strings.TrimSpace(fixed) == "" { + return result, nil + } + + fixedRat, err := dmath.RatFromString(fixed) + if err != nil { + return nil, merrors.InvalidArgument("invalid fixed amount") + } + + return dmath.AddRat(result, fixedRat), nil +} + +func applyMin(result *big.Rat, minStr string) (*big.Rat, error) { + if strings.TrimSpace(minStr) == "" { + return result, nil + } + + minRat, err := dmath.RatFromString(minStr) + if err != nil { + return nil, merrors.InvalidArgument("invalid minimum amount") + } + + if dmath.CmpRat(result, minRat) < 0 { + return new(big.Rat).Set(minRat), nil + } + + return result, nil +} + +func applyMax(result *big.Rat, maxStr string) (*big.Rat, error) { + if strings.TrimSpace(maxStr) == "" { + return result, nil + } + + maxRat, err := dmath.RatFromString(maxStr) + if err != nil { + return nil, merrors.InvalidArgument("invalid maximum amount") + } + + if dmath.CmpRat(result, maxRat) > 0 { + return new(big.Rat).Set(maxRat), nil + } + + return result, nil +} + const ( attrFxBaseCurrency = "fx_base_currency" attrFxQuoteCurrency = "fx_quote_currency" @@ -232,7 +217,9 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent } attrs := intent.GetAttributes() + base := strings.TrimSpace(attrs[attrFxBaseCurrency]) + quote := strings.TrimSpace(attrs[attrFxQuoteCurrency]) if base == "" || quote == "" { return nil @@ -248,19 +235,25 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent }) if err != nil { c.logger.Warn("Fees: failed to fetch FX context", zap.Error(err)) + return nil } + if snapshot == nil { return nil } + rateValue := strings.TrimSpace(attrs[attrFxRateOverride]) + if rateValue == "" { rateValue = snapshot.Mid } + if rateValue == "" { rateValue = snapshot.Ask } + if rateValue == "" { rateValue = snapshot.Bid } @@ -292,15 +285,19 @@ func inferScale(amount string) uint32 { if value == "" { return 0 } + if idx := strings.IndexAny(value, "eE"); idx >= 0 { value = value[:idx] } + if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") { value = value[1:] } - if dot := strings.IndexByte(value, '.'); dot >= 0 { - return uint32(len(value[dot+1:])) + + if _, after, found := strings.Cut(value, "."); found { + return uint32(len(after)) //nolint:gosec // decimal scale; cannot overflow } + return 0 } @@ -308,12 +305,15 @@ func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[s if rule.Trigger != trigger { return false } + if rule.EffectiveFrom.After(bookedAt) { return false } + if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) { return false } + return ruleMatchesAttributes(rule, attributes) } @@ -325,6 +325,7 @@ func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) { } } } + return fallback, nil } @@ -333,17 +334,115 @@ func parseScale(field, value string) (uint32, error) { if clean == "" { return 0, merrors.InvalidArgument(field + " is empty") } + parsed, err := strconv.ParseUint(clean, 10, 32) if err != nil { return 0, merrors.InvalidArgument("invalid " + field + " value") } + return uint32(parsed), nil } +func validateComputeInputs(plan *model.FeePlan, intent *feesv1.Intent) (*big.Rat, uint32, model.Trigger, error) { + if plan == nil { + return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("plan is required") + } + + if intent == nil { + return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("intent is required") + } + + trigger := convertTrigger(intent.GetTrigger()) + if trigger == model.TriggerUnspecified { + return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("unsupported trigger") + } + + baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount()) + if err != nil { + return nil, 0, trigger, merrors.InvalidArgument("invalid base amount") + } + + if baseAmount.Sign() < 0 { + return nil, 0, trigger, merrors.InvalidArgument("base amount cannot be negative") + } + + return baseAmount, inferScale(intent.GetBaseAmount().GetAmount()), trigger, nil +} + +func planIDFrom(plan *model.FeePlan) string { + if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() { + return planRef.Hex() + } + + return "" +} + +func resolvedCurrency(baseCurrency, ruleCurrency string) string { + if override := strings.TrimSpace(ruleCurrency); override != "" { + return override + } + + return baseCurrency +} + +func resolvedEntrySide(rule model.FeeRule) accountingv1.EntrySide { + // Default fees to debit (i.e. charge the customer) when entry side is not specified. + if side := mapEntrySide(rule.EntrySide); side != accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED { + return side + } + + return accountingv1.EntrySide_ENTRY_SIDE_DEBIT +} + +func buildPostingLine(rule model.FeeRule, amount *big.Rat, scale uint32, currency string, entrySide accountingv1.EntrySide, planID string) *feesv1.DerivedPostingLine { + return &feesv1.DerivedPostingLine{ + LedgerAccountRef: strings.TrimSpace(rule.LedgerAccountRef), + Money: &moneyv1.Money{ + Amount: dmath.FormatRat(amount, scale), + Currency: currency, + }, + LineType: mapLineType(rule.LineType), + Side: entrySide, + Meta: buildRuleMeta(rule, planID), + } +} + +func buildAppliedRule(rule model.FeeRule, planID string) *feesv1.AppliedRule { + return &feesv1.AppliedRule{ + RuleId: rule.RuleID, + RuleVersion: planID, + Formula: rule.Formula, + Rounding: mapRoundingMode(rule.Rounding), + TaxCode: metadataValue(rule.Metadata, "tax_code"), + TaxRate: metadataValue(rule.Metadata, "tax_rate"), + Parameters: cloneStringMap(rule.Metadata), + } +} + +func buildRuleMeta(rule model.FeeRule, planID string) map[string]string { + meta := map[string]string{"fee_rule_id": rule.RuleID} + if planID != "" { + meta["fee_plan_id"] = planID + } + + if rule.Metadata != nil { + if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" { + meta["tax_code"] = taxCode + } + + if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" { + meta["tax_rate"] = taxRate + } + } + + return meta +} + func metadataValue(meta map[string]string, key string) string { if meta == nil { return "" } + return strings.TrimSpace(meta[key]) } @@ -351,10 +450,10 @@ func cloneStringMap(src map[string]string) map[string]string { if len(src) == 0 { return nil } + cloned := make(map[string]string, len(src)) - for k, v := range src { - cloned[k] = v - } + maps.Copy(cloned, src) + return cloned } @@ -362,18 +461,22 @@ func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) boo if len(rule.AppliesTo) == 0 { return true } + for key, value := range rule.AppliesTo { if attributes == nil { return false } + attrValue, ok := attributes[key] if !ok { return false } + if !matchesAttributeValue(value, attrValue) { return false } } + return true } @@ -383,16 +486,17 @@ func matchesAttributeValue(expected, actual string) bool { return actual == "" } - values := strings.Split(trimmed, ",") - for _, value := range values { + for value := range strings.SplitSeq(trimmed, ",") { value = strings.TrimSpace(value) if value == "" { continue } + if value == "*" || value == actual { return true } } + return false } @@ -446,6 +550,8 @@ func mapRoundingMode(mode string) moneyv1.RoundingMode { func convertTrigger(trigger feesv1.Trigger) model.Trigger { switch trigger { + case feesv1.Trigger_TRIGGER_UNSPECIFIED: + return model.TriggerUnspecified case feesv1.Trigger_TRIGGER_CAPTURE: return model.TriggerCapture case feesv1.Trigger_TRIGGER_REFUND: diff --git a/api/billing/fees/internal/service/fees/internal/resolver/impl.go b/api/billing/fees/internal/service/fees/internal/resolver/impl.go index 714e7a07..881ecd2d 100644 --- a/api/billing/fees/internal/service/fees/internal/resolver/impl.go +++ b/api/billing/fees/internal/service/fees/internal/resolver/impl.go @@ -30,9 +30,11 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver { if pf, ok := plans.(planFinder); ok { finder = pf } + if logger == nil { logger = zap.NewNop() } + return &feeResolver{ plans: plans, finder: finder, @@ -40,63 +42,100 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver { } } -func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) { +func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) { if r.plans == nil { return nil, nil, merrors.InvalidArgument("fees: plans store is required") } // Try org-specific first if provided. - if orgRef != nil && !orgRef.IsZero() { - if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil { - if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { - return plan, rule, nil - } else if !errors.Is(selErr, ErrNoFeeRuleFound) { - r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), mzap.ObjRef("org_ref", *orgRef)) - return nil, nil, selErr - } - orgFields := []zap.Field{ - mzap.ObjRef("org_ref", *orgRef), - zap.String("trigger", string(trigger)), - zap.Time("booked_at", at), - zap.Any("attributes", attrs), - } - orgFields = append(orgFields, zapFieldsForPlan(plan)...) - r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...) - } else if !errors.Is(err, storage.ErrFeePlanNotFound) { - r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", *orgRef)) + if isOrgRef(orgRef) { + plan, rule, err := r.tryOrgRule(ctx, *orgRef, trigger, asOf, attrs) + if err != nil { return nil, nil, err } + + if rule != nil { + return plan, rule, nil + } } - plan, err := r.getGlobalPlan(ctx, at) + plan, err := r.getGlobalPlan(ctx, asOf) if err != nil { if errors.Is(err, storage.ErrFeePlanNotFound) { r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)), - zap.Time("booked_at", at), zap.Any("attributes", attrs), + zap.Time("booked_at", asOf), zap.Any("attributes", attrs), ) + return nil, nil, merrors.NoData("fees: no applicable fee rule found") } + r.logger.Warn("Failed resolving global fee plan", zap.Error(err)) + return nil, nil, err } - rule, err := selectRule(plan, trigger, at, attrs) + rule, err := selectRule(plan, trigger, asOf, attrs) if err != nil { if !errors.Is(err, ErrNoFeeRuleFound) { r.logger.Warn("Failed selecting rule in global plan", zap.Error(err)) } else { - globalFields := []zap.Field{ + globalFields := zapFieldsForPlan(plan) + globalFields = append([]zap.Field{ zap.String("trigger", string(trigger)), - zap.Time("booked_at", at), + zap.Time("booked_at", asOf), zap.Any("attributes", attrs), - } - globalFields = append(globalFields, zapFieldsForPlan(plan)...) + }, globalFields...) r.logger.Debug("No matching rule in global plan", globalFields...) } + return nil, nil, err } - selectedFields := []zap.Field{ + r.logSelectedRule(orgRef, trigger, asOf, attrs, rule, plan) + + return plan, rule, nil +} + +// tryOrgRule attempts to find a matching rule in the org-specific plan. +// Returns (plan, rule, nil) on success, (nil, nil, nil) to signal fallthrough to global, +// or (nil, nil, err) on a hard error. +func (r *feeResolver) tryOrgRule(ctx context.Context, orgRef bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) { + plan, err := r.getOrgPlan(ctx, orgRef, asOf) + if err != nil { + if errors.Is(err, storage.ErrFeePlanNotFound) { + return nil, nil, nil + } + + r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", orgRef)) + + return nil, nil, err + } + + rule, selErr := selectRule(plan, trigger, asOf, attrs) + if selErr == nil { + return plan, rule, nil + } + + if !errors.Is(selErr, ErrNoFeeRuleFound) { + r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), mzap.ObjRef("org_ref", orgRef)) + + return nil, nil, selErr + } + + orgFields := zapFieldsForPlan(plan) + orgFields = append([]zap.Field{ + mzap.ObjRef("org_ref", orgRef), + zap.String("trigger", string(trigger)), + zap.Time("booked_at", asOf), + zap.Any("attributes", attrs), + }, orgFields...) + r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...) + + return nil, nil, nil +} + +func (r *feeResolver) logSelectedRule(orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string, rule *model.FeeRule, plan *model.FeePlan) { + fields := []zap.Field{ zap.String("trigger", string(trigger)), zap.Time("booked_at", at), zap.Any("attributes", attrs), @@ -106,59 +145,65 @@ func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, zap.Time("rule_effective_from", rule.EffectiveFrom), } if rule.EffectiveTo != nil { - selectedFields = append(selectedFields, zap.Time("rule_effective_to", *rule.EffectiveTo)) + fields = append(fields, zap.Time("rule_effective_to", *rule.EffectiveTo)) } - if orgRef != nil && !orgRef.IsZero() { - selectedFields = append(selectedFields, mzap.ObjRef("org_ref", *orgRef)) - } - selectedFields = append(selectedFields, zapFieldsForPlan(plan)...) - r.logger.Debug("Selected fee rule", selectedFields...) - return plan, rule, nil + if isOrgRef(orgRef) { + fields = append(fields, mzap.ObjRef("org_ref", *orgRef)) + } + + fields = append(fields, zapFieldsForPlan(plan)...) + r.logger.Debug("Selected fee rule", fields...) +} + +func isOrgRef(ref *bson.ObjectID) bool { + return ref != nil && !ref.IsZero() } func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) { if r.finder != nil { return r.finder.FindActiveOrgPlan(ctx, orgRef, at) } + return r.plans.GetActivePlan(ctx, orgRef, at) } -func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) { +func (r *feeResolver) getGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) { if r.finder != nil { - return r.finder.FindActiveGlobalPlan(ctx, at) + return r.finder.FindActiveGlobalPlan(ctx, asOf) } + // Treat zero ObjectID as global in legacy path. - return r.plans.GetActivePlan(ctx, bson.NilObjectID, at) + return r.plans.GetActivePlan(ctx, bson.NilObjectID, asOf) } -func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) { +func selectRule(plan *model.FeePlan, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeeRule, error) { if plan == nil { return nil, merrors.NoData("fees: no applicable fee rule found") } - var selected *model.FeeRule - var highestPriority int + var ( + selected *model.FeeRule + highestPriority int + ) + for _, rule := range plan.Rules { - if rule.Trigger != trigger { - continue - } - if rule.EffectiveFrom.After(at) { - continue - } - if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) { + if !ruleIsActive(rule, trigger, asOf) { continue } + if !matchesAppliesTo(rule.AppliesTo, attrs) { continue } if selected == nil || rule.Priority > highestPriority { - copy := rule - selected = © + matched := rule + selected = &matched highestPriority = rule.Priority + continue } + if rule.Priority == highestPriority { return nil, merrors.DataConflict("fees: conflicting fee rules") } @@ -167,25 +212,46 @@ func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs if selected == nil { return nil, merrors.NoData("fees: no applicable fee rule found") } + return selected, nil } +func ruleIsActive(rule model.FeeRule, trigger model.Trigger, asOf time.Time) bool { + if rule.Trigger != trigger { + return false + } + + if rule.EffectiveFrom.After(asOf) { + return false + } + + if rule.EffectiveTo != nil && !rule.EffectiveTo.After(asOf) { + return false + } + + return true +} + func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool { if len(appliesTo) == 0 { return true } + for key, value := range appliesTo { if attrs == nil { return false } + attrValue, ok := attrs[key] if !ok { return false } + if !matchesAppliesValue(value, attrValue) { return false } } + return true } @@ -195,16 +261,17 @@ func matchesAppliesValue(expected, actual string) bool { return actual == "" } - values := strings.Split(trimmed, ",") - for _, value := range values { + for value := range strings.SplitSeq(trimmed, ",") { value = strings.TrimSpace(value) if value == "" { continue } + if value == "*" || value == actual { return true } } + return false } @@ -212,6 +279,7 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field { if plan == nil { return []zap.Field{zap.Bool("plan_present", false)} } + fields := []zap.Field{ zap.Bool("plan_present", true), zap.Bool("plan_active", plan.Active), @@ -223,13 +291,16 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field { } else { fields = append(fields, zap.Bool("plan_effective_to_set", false)) } + if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() { fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef)) } else { fields = append(fields, zap.Bool("plan_org_ref_set", false)) } + if plan.GetID() != nil && !plan.GetID().IsZero() { fields = append(fields, mzap.StorableRef(plan)) } + return fields } diff --git a/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go b/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go index 9ec54846..3f3487ad 100644 --- a/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go +++ b/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go @@ -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 } diff --git a/api/billing/fees/internal/service/fees/logging.go b/api/billing/fees/internal/service/fees/logging.go index d6216280..8fc6e033 100644 --- a/api/billing/fees/internal/service/fees/logging.go +++ b/api/billing/fees/internal/service/fees/logging.go @@ -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 } diff --git a/api/billing/fees/internal/service/fees/metrics.go b/api/billing/fees/internal/service/fees/metrics.go index 3f737137..d0ea82ed 100644 --- a/api/billing/fees/internal/service/fees/metrics.go +++ b/api/billing/fees/internal/service/fees/metrics.go @@ -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()) } diff --git a/api/billing/fees/internal/service/fees/service.go b/api/billing/fees/internal/service/fees/service.go index 220bc772..b13dabd3 100644 --- a/api/billing/fees/internal/service/fees/service.go +++ b/api/billing/fees/internal/service/fees/service.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" "strings" "time" @@ -33,6 +32,8 @@ import ( ) type Service struct { + feesv1.UnimplementedFeeEngineServer + logger mlogger.Logger storage storage.Repository producer msg.Producer @@ -42,7 +43,6 @@ type Service struct { resolver FeeResolver announcer *discovery.Announcer invokeURI string - feesv1.UnimplementedFeeEngineServer } func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service { @@ -52,6 +52,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro producer: producer, clock: clockpkg.NewSystem(), } + initMetrics() for _, opt := range opts { @@ -61,9 +62,11 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro if svc.clock == nil { svc.clock = clockpkg.NewSystem() } + if svc.calculator == nil { svc.calculator = internalcalculator.New(svc.logger, svc.oracle) } + if svc.resolver == nil { svc.resolver = resolver.New(repo.Plans(), svc.logger) } @@ -83,25 +86,12 @@ func (s *Service) Shutdown() { if s == nil { return } + if s.announcer != nil { s.announcer.Stop() } } -func (s *Service) startDiscoveryAnnouncer() { - if s == nil || s.producer == nil { - return - } - announce := discovery.Announcement{ - Service: "BILLING_FEES", - Operations: []string{"fee.calc"}, - InvokeURI: s.invokeURI, - Version: appversion.Create().Short(), - } - s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce) - s.announcer.Start() -} - func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) { var ( meta *feesv1.RequestMeta @@ -111,23 +101,29 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) ( meta = req.GetMeta() intent = req.GetIntent() } + logger := s.logger.With(requestLogFields(meta, intent)...) start := s.clock.Now() + trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED if intent != nil { trigger = intent.GetTrigger() } + var fxUsed bool + defer func() { statusLabel := statusFromError(err) linesCount := 0 appliedCount := 0 + if err == nil && resp != nil { fxUsed = resp.GetFxUsed() != nil linesCount = len(resp.GetLines()) appliedCount = len(resp.GetApplied()) } + observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start)) logFields := []zap.Field{ @@ -140,8 +136,10 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) ( } if err != nil { logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...) + return } + logger.Info("QuoteFees finished", logFields...) }() @@ -155,12 +153,14 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) ( if parseErr != nil { logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr)) err = status.Error(codes.InvalidArgument, "invalid organization_ref") + return nil, err } - lines, applied, fx, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace()) + lines, applied, fxResult, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace()) if computeErr != nil { err = computeErr + return nil, err } @@ -168,8 +168,9 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) ( Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()}, Lines: lines, Applied: applied, - FxUsed: fx, + FxUsed: fxResult, } + return resp, nil } @@ -182,48 +183,17 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees meta = req.GetMeta() intent = req.GetIntent() } + logger := s.logger.With(requestLogFields(meta, intent)...) start := s.clock.Now() + trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED if intent != nil { trigger = intent.GetTrigger() } - var ( - fxUsed bool - expiresAt time.Time - ) - defer func() { - statusLabel := statusFromError(err) - linesCount := 0 - appliedCount := 0 - if err == nil && resp != nil { - fxUsed = resp.GetFxUsed() != nil - linesCount = len(resp.GetLines()) - appliedCount = len(resp.GetApplied()) - if ts := resp.GetExpiresAt(); ts != nil { - expiresAt = ts.AsTime() - } - } - observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start)) - logFields := []zap.Field{ - zap.String("status", statusLabel), - zap.Duration("duration", time.Since(start)), - zap.Bool("fx_used", fxUsed), - zap.String("trigger", trigger.String()), - zap.Int("lines", linesCount), - zap.Int("applied_rules", appliedCount), - } - if !expiresAt.IsZero() { - logFields = append(logFields, zap.Time("expires_at", expiresAt)) - } - if err != nil { - logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...) - return - } - logger.Info("PrecomputeFees finished", logFields...) - }() + defer func() { s.observePrecomputeFees(logger, err, resp, trigger, start) }() logger.Debug("PrecomputeFees request received") @@ -237,12 +207,14 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees if parseErr != nil { logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr)) err = status.Error(codes.InvalidArgument, "invalid organization_ref") + return nil, err } - lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now) + lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now) if computeErr != nil { err = computeErr + return nil, err } @@ -250,7 +222,8 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees if ttl <= 0 { ttl = 60000 } - expiresAt = now.Add(time.Duration(ttl) * time.Millisecond) + + expiresAt := now.Add(time.Duration(ttl) * time.Millisecond) payload := feeQuoteTokenPayload{ OrganizationRef: req.GetMeta().GetOrganizationRef(), @@ -263,6 +236,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees if token, err = encodeTokenPayload(payload); err != nil { logger.Warn("Failed to encode fee quote token", zap.Error(err)) err = status.Error(codes.Internal, "failed to encode fee quote token") + return nil, err } @@ -272,8 +246,9 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees ExpiresAt: timestamppb.New(expiresAt), Lines: lines, Applied: applied, - FxUsed: fx, + FxUsed: fxResult, } + return resp, nil } @@ -282,49 +257,23 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT if req != nil { tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken())) } + logger := s.logger.With(zap.Int("token_length", tokenLen)) start := s.clock.Now() - trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED - var ( - fxUsed bool - resultReason string - ) - defer func() { - statusLabel := statusFromError(err) - if err == nil && resp != nil { - if !resp.GetValid() { - statusLabel = "invalid" - } - fxUsed = resp.GetFxUsed() != nil - if resp.GetIntent() != nil { - trigger = resp.GetIntent().GetTrigger() - } - } - observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start)) - logFields := []zap.Field{ - zap.String("status", statusLabel), - zap.Duration("duration", time.Since(start)), - zap.Bool("fx_used", fxUsed), - zap.String("trigger", trigger.String()), - zap.Bool("valid", resp != nil && resp.GetValid()), - } - if resultReason != "" { - logFields = append(logFields, zap.String("reason", resultReason)) - } - if err != nil { - logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...) - return - } - logger.Info("ValidateFeeToken finished", logFields...) - }() + trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED + + var resultReason string + + defer func() { s.observeValidateFeeToken(logger, err, resp, trigger, resultReason, start) }() logger.Debug("ValidateFeeToken request received") if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" { resultReason = "missing_token" err = status.Error(codes.InvalidArgument, "fee_quote_token is required") + return nil, err } @@ -333,8 +282,11 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken()) if decodeErr != nil { resultReason = "invalid_token" + logger.Warn("Failed to decode fee quote token", zap.Error(decodeErr)) + resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} + return resp, nil } @@ -346,22 +298,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT if now.UnixMilli() > payload.ExpiresAtUnixMs { resultReason = "expired" + logger.Info("Fee quote token expired") + resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"} + return resp, nil } orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef) if parseErr != nil { resultReason = "invalid_token" + logger.Warn("Token contained invalid organization reference", zap.Error(parseErr)) + resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} + return resp, nil } - lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now) + lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now) if computeErr != nil { err = computeErr + return nil, err } @@ -371,8 +330,9 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT Intent: payload.Intent, Lines: lines, Applied: applied, - FxUsed: fx, + FxUsed: fxResult, } + return resp, nil } @@ -380,24 +340,31 @@ func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error { if req == nil { return status.Error(codes.InvalidArgument, "request is required") } + if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" { return status.Error(codes.InvalidArgument, "meta.organization_ref is required") } + if req.GetIntent() == nil { return status.Error(codes.InvalidArgument, "intent is required") } + if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED { return status.Error(codes.InvalidArgument, "intent.trigger is required") } + if req.GetIntent().GetBaseAmount() == nil { return status.Error(codes.InvalidArgument, "intent.base_amount is required") } + if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" { return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required") } + if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" { return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required") } + return nil } @@ -405,6 +372,7 @@ func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) e if req == nil { return status.Error(codes.InvalidArgument, "request is required") } + return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()}) } @@ -413,17 +381,13 @@ func (s *Service) computeQuote(ctx context.Context, orgRef bson.ObjectID, intent } func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, _ *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) { - bookedAt := now - if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() { - bookedAt = intent.GetBookedAt().AsTime() - } + bookedAt := resolvedBookedAt(intent, now) - logFields := []zap.Field{ - zap.Time("booked_at_used", bookedAt), - } + logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)} if !orgRef.IsZero() { logFields = append(logFields, zap.String("organization_ref", orgRef.Hex())) } + logFields = append(logFields, logFieldsFromIntent(intent)...) logFields = append(logFields, logFieldsFromTrace(trace)...) logger := s.logger.With(logFields...) @@ -436,22 +400,13 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes()) if err != nil { s.logger.Warn("Failed to resolve fee rule", zap.Error(err)) - switch { - case errors.Is(err, merrors.ErrNoData): - return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee rule not found: %s", err.Error())) - case errors.Is(err, merrors.ErrDataConflict): - return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee rules: %s", err.Error())) - case errors.Is(err, storage.ErrConflictingFeePlans): - return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee plans: %s", err.Error())) - case errors.Is(err, storage.ErrFeePlanNotFound): - return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee plan not found: %s", err.Error())) - default: - return nil, nil, nil, status.Error(codes.Internal, fmt.Sprintf("failed to resolve fee rule: %s", err.Error())) - } + + return nil, nil, nil, mapResolveError(err) } originalRules := plan.Rules plan.Rules = []model.FeeRule{*rule} + defer func() { plan.Rules = originalRules }() @@ -461,13 +416,115 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID if errors.Is(calcErr, merrors.ErrInvalidArg) { return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error()) } + logger.Warn("Failed to compute fee quote", zap.Error(calcErr)) + return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote") } return result.Lines, result.Applied, result.FxUsed, nil } +func resolvedBookedAt(intent *feesv1.Intent, now time.Time) time.Time { + if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() { + return intent.GetBookedAt().AsTime() + } + + return now +} + +func mapResolveError(err error) error { + switch { + case errors.Is(err, merrors.ErrNoData): + return status.Error(codes.NotFound, "fee rule not found: "+err.Error()) + case errors.Is(err, merrors.ErrDataConflict): + return status.Error(codes.FailedPrecondition, "conflicting fee rules: "+err.Error()) + case errors.Is(err, storage.ErrConflictingFeePlans): + return status.Error(codes.FailedPrecondition, "conflicting fee plans: "+err.Error()) + case errors.Is(err, storage.ErrFeePlanNotFound): + return status.Error(codes.NotFound, "fee plan not found: "+err.Error()) + default: + return status.Error(codes.Internal, "failed to resolve fee rule: "+err.Error()) + } +} + +func (s *Service) observePrecomputeFees(logger mlogger.Logger, err error, resp *feesv1.PrecomputeFeesResponse, trigger feesv1.Trigger, start time.Time) { + statusLabel := statusFromError(err) + fxUsed := false + linesCount := 0 + appliedCount := 0 + + var expiresAt time.Time + + if err == nil && resp != nil { + fxUsed = resp.GetFxUsed() != nil + linesCount = len(resp.GetLines()) + appliedCount = len(resp.GetApplied()) + + if ts := resp.GetExpiresAt(); ts != nil { + expiresAt = ts.AsTime() + } + } + + observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start)) + + logFields := []zap.Field{ + zap.String("status", statusLabel), + zap.Duration("duration", time.Since(start)), + zap.Bool("fx_used", fxUsed), + zap.String("trigger", trigger.String()), + zap.Int("lines", linesCount), + zap.Int("applied_rules", appliedCount), + } + if !expiresAt.IsZero() { + logFields = append(logFields, zap.Time("expires_at", expiresAt)) + } + if err != nil { + logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...) + + return + } + + logger.Info("PrecomputeFees finished", logFields...) +} + +func (s *Service) observeValidateFeeToken(logger mlogger.Logger, err error, resp *feesv1.ValidateFeeTokenResponse, trigger feesv1.Trigger, resultReason string, start time.Time) { + statusLabel := statusFromError(err) + fxUsed := false + + if err == nil && resp != nil { + if !resp.GetValid() { + statusLabel = "invalid" + } + + fxUsed = resp.GetFxUsed() != nil + if resp.GetIntent() != nil { + trigger = resp.GetIntent().GetTrigger() + } + } + + observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start)) + + logFields := []zap.Field{ + zap.String("status", statusLabel), + zap.Duration("duration", time.Since(start)), + zap.Bool("fx_used", fxUsed), + zap.String("trigger", trigger.String()), + zap.Bool("valid", resp != nil && resp.GetValid()), + } + if resultReason != "" { + logFields = append(logFields, zap.String("reason", resultReason)) + } + + if err != nil { + logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...) + + return + } + + logger.Info("ValidateFeeToken finished", logFields...) +} + type feeQuoteTokenPayload struct { OrganizationRef string `json:"organization_ref"` Intent *feesv1.Intent `json:"intent"` @@ -480,17 +537,36 @@ func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) { if err != nil { return "", merrors.Internal("fees: failed to serialize token payload") } + return base64.StdEncoding.EncodeToString(data), nil } func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) { var payload feeQuoteTokenPayload + data, err := base64.StdEncoding.DecodeString(token) if err != nil { return payload, merrors.InvalidArgument("fees: invalid token encoding") } + if err := json.Unmarshal(data, &payload); err != nil { return payload, merrors.InvalidArgument("fees: invalid token payload") } + return payload, nil } + +func (s *Service) startDiscoveryAnnouncer() { + if s == nil || s.producer == nil { + return + } + + announce := discovery.Announcement{ + Service: "BILLING_FEES", + Operations: []string{"fee.calc"}, + InvokeURI: s.invokeURI, + Version: appversion.Create().Short(), + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.FeePlans, announce) + s.announcer.Start() +} diff --git a/api/billing/fees/internal/service/fees/service_test.go b/api/billing/fees/internal/service/fees/service_test.go index 78adb750..0b61b0cf 100644 --- a/api/billing/fees/internal/service/fees/service_test.go +++ b/api/billing/fees/internal/service/fees/service_test.go @@ -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 } diff --git a/api/billing/fees/internal/service/fees/trigger.go b/api/billing/fees/internal/service/fees/trigger.go index 5482e0be..8835814b 100644 --- a/api/billing/fees/internal/service/fees/trigger.go +++ b/api/billing/fees/internal/service/fees/trigger.go @@ -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: diff --git a/api/billing/fees/storage/model/plan.go b/api/billing/fees/storage/model/plan.go index c63f0284..787a8791 100644 --- a/api/billing/fees/storage/model/plan.go +++ b/api/billing/fees/storage/model/plan.go @@ -28,12 +28,13 @@ 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"` - EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` - Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"` - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + + OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` + Active bool `bson:"active" json:"active"` + EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` + EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` + Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` } // Collection implements storable.Storable. @@ -43,21 +44,21 @@ func (*FeePlan) Collection() string { // FeeRule represents a single pricing rule within a plan. type FeeRule struct { - RuleID string `bson:"ruleId" json:"ruleId"` - Trigger Trigger `bson:"trigger" json:"trigger"` - Priority int `bson:"priority" json:"priority"` - Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"` - FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"` - Currency string `bson:"currency,omitempty" json:"currency,omitempty"` - MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"` - MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"` - AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"` - Formula string `bson:"formula,omitempty" json:"formula,omitempty"` - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + RuleID string `bson:"ruleId" json:"ruleId"` + Trigger Trigger `bson:"trigger" json:"trigger"` + Priority int `bson:"priority" json:"priority"` + Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"` + FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"` + Currency string `bson:"currency,omitempty" json:"currency,omitempty"` + MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"` + MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"` + AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"` + Formula string `bson:"formula,omitempty" json:"formula,omitempty"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"` - LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"` - EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"` - Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"` - EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` - EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` + LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"` + EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"` + Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"` + EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` + EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` } diff --git a/api/billing/fees/storage/mongo/repository.go b/api/billing/fees/storage/mongo/repository.go index e8505f02..25fec723 100644 --- a/api/billing/fees/storage/mongo/repository.go +++ b/api/billing/fees/storage/mongo/repository.go @@ -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 } diff --git a/api/billing/fees/storage/mongo/store/plans.go b/api/billing/fees/storage/mongo/store/plans.go index 2d04f4d1..679c4221 100644 --- a/api/billing/fees/storage/mongo/store/plans.go +++ b/api/billing/fees/storage/mongo/store/plans.go @@ -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 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") - } + 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") + } + } + 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 } diff --git a/api/discovery/.golangci.yml b/api/discovery/.golangci.yml index f489facb..d292eb83 100644 --- a/api/discovery/.golangci.yml +++ b/api/discovery/.golangci.yml @@ -163,6 +163,7 @@ linters: # Exclude some linters from running on tests files. - path: _test\.go linters: + - funlen - gocyclo - errcheck - dupl diff --git a/api/fx/ingestor/.golangci.yml b/api/fx/ingestor/.golangci.yml index 0780108d..88d33b5c 100644 --- a/api/fx/ingestor/.golangci.yml +++ b/api/fx/ingestor/.golangci.yml @@ -163,6 +163,7 @@ linters: # Exclude some linters from running on tests files. - path: _test\.go linters: + - funlen - gocyclo - errcheck - dupl diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go index cdc925dc..a12c76c6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go @@ -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) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go index 24f4c719..f34057c0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go @@ -24,6 +24,7 @@ type Service interface { type FPInput struct { OrganizationRef string QuotationRef string + IntentRef string ClientPaymentRef string } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go index 76306c7c..0ba589ed 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go @@ -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) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go index ae79410e..e451e1ac 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go @@ -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") ) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index 336660b4..655d54b2 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go index d809c51e..cf5168fc 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go @@ -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 + return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch) } - if len(record.StatusesV2) > 0 { - if len(record.StatusesV2) != 1 { - return nil, fmt.Errorf("%w: expected single status", ErrQuoteShapeMismatch) + + 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 nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch) + } + + if record.Quote == nil { + return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch) + } + if isEmptyIntentSnapshot(record.Intent) { + return nil, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch) + } + if intentRef != "" { + recordIntentRef := strings.TrimSpace(record.Intent.Ref) + if recordIntentRef == "" || recordIntentRef != intentRef { + return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) } - if record.StatusesV2[0] == nil { + } + + intentSnapshot, err := cloneIntentSnapshot(record.Intent) + if err != nil { + return nil, err + } + quoteSnapshot, err := cloneQuoteSnapshot(record.Quote) + if err != nil { + return nil, err + } + + return &resolvedQuoteItem{ + Intent: intentSnapshot, + Quote: quoteSnapshot, + Status: record.StatusV2, + }, nil +} + +func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { + if len(record.Intents) == 0 { + return nil, fmt.Errorf("%w: intents are empty", ErrQuoteShapeMismatch) + } + if len(record.Quotes) == 0 { + return nil, fmt.Errorf("%w: quotes are empty", ErrQuoteShapeMismatch) + } + if len(record.Intents) != len(record.Quotes) { + return nil, fmt.Errorf("%w: intents and quotes count mismatch", ErrQuoteShapeMismatch) + } + if len(record.StatusesV2) > 0 && len(record.StatusesV2) != len(record.Quotes) { + return nil, fmt.Errorf("%w: statuses and quotes count mismatch", ErrQuoteShapeMismatch) + } + + index := 0 + if len(record.Intents) > 1 { + if intentRef == "" { + return nil, ErrIntentRefRequired + } + selected, found := findIntentIndex(record.Intents, intentRef) + if !found { + return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) + } + index = selected + } else if intentRef != "" { + if strings.TrimSpace(record.Intents[0].Ref) != intentRef { + return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) + } + } + + quoteSnapshot := record.Quotes[index] + if quoteSnapshot == nil { + return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch) + } + + intentSnapshot, err := cloneIntentSnapshot(record.Intents[index]) + if err != nil { + return nil, err + } + clonedQuote, err := cloneQuoteSnapshot(quoteSnapshot) + if err != nil { + return nil, err + } + + var statusSnapshot *model.QuoteStatusV2 + if len(record.StatusesV2) > 0 { + if record.StatusesV2[index] == nil { return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch) } - return record.StatusesV2[0], nil + statusSnapshot = record.StatusesV2[index] } - return record.StatusV2, nil + + return &resolvedQuoteItem{ + Intent: intentSnapshot, + Quote: clonedQuote, + Status: statusSnapshot, + }, nil } -func extractIntentSnapshot(record *model.PaymentQuoteRecord) (model.PaymentIntent, error) { - if record == nil { - return model.PaymentIntent{}, ErrQuoteShapeMismatch +func findIntentIndex(intents []model.PaymentIntent, targetRef string) (int, bool) { + target := strings.TrimSpace(targetRef) + if target == "" { + return -1, false } - - 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 isEmptyIntentSnapshot(record.Intent) { - return model.PaymentIntent{}, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch) - } - return cloneIntentSnapshot(record.Intent) -} - -func extractQuoteSnapshot(record *model.PaymentQuoteRecord) (*model.PaymentQuoteSnapshot, error) { - if record == nil { - return nil, ErrQuoteShapeMismatch - } - - if record.Quote != nil { - return cloneQuoteSnapshot(record.Quote) - } - if len(record.Quotes) > 1 { - return nil, fmt.Errorf("%w: expected single quote", ErrQuoteShapeMismatch) - } - if len(record.Quotes) == 1 { - if record.Quotes[0] == nil { - return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch) + for idx := range intents { + if strings.TrimSpace(intents[idx].Ref) == target { + return idx, true } - return cloneQuoteSnapshot(record.Quotes[0]) } - return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch) + return -1, false +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" } func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go index 7242133d..eb63e11c 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go @@ -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) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go index a5305549..9909033d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go index ea34985e..f801a6c6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go index f6d27a43..b5676074 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go @@ -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{ diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/errors.go new file mode 100644 index 00000000..1e9a33fd --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/errors.go @@ -0,0 +1,7 @@ +package xplan + +import "errors" + +var ( + ErrNotExecutable = errors.New("quote is not executable for runtime compilation") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go new file mode 100644 index 00000000..f2d8411f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go @@ -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{} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go new file mode 100644 index 00000000..c1cc2b8b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go @@ -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:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go new file mode 100644 index 00000000..bff3e3d5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go @@ -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 +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go index a38576a8..29a83569 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go @@ -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: "ationv2.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) } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go index d07bb43d..238a789e 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go @@ -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()) } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go index 81f50adc..80eea7b0 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go @@ -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() diff --git a/api/pkg/db/internal/mongo/db.go b/api/pkg/db/internal/mongo/db.go index a5ca103f..acd60767 100755 --- a/api/pkg/db/internal/mongo/db.go +++ b/api/pkg/db/internal/mongo/db.go @@ -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 { diff --git a/api/proto/connector/v1/connector.proto b/api/proto/connector/v1/connector.proto index 4735dda1..a956e833 100644 --- a/api/proto/connector/v1/connector.proto +++ b/api/proto/connector/v1/connector.proto @@ -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 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; diff --git a/api/proto/payments/orchestration/v2/orchestration.proto b/api/proto/payments/orchestration/v2/orchestration.proto index cb2a80a0..d47fb312 100644 --- a/api/proto/payments/orchestration/v2/orchestration.proto +++ b/api/proto/payments/orchestration/v2/orchestration.proto @@ -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. diff --git a/api/proto/payments/quotation/v2/interface.proto b/api/proto/payments/quotation/v2/interface.proto index 0a6af3fb..e267a700 100644 --- a/api/proto/payments/quotation/v2/interface.proto +++ b/api/proto/payments/quotation/v2/interface.proto @@ -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; } diff --git a/api/server/internal/server/walletapiimp/list.go b/api/server/internal/server/walletapiimp/list.go index 0923d31d..195d2a0e 100644 --- a/api/server/internal/server/walletapiimp/list.go +++ b/api/server/internal/server/walletapiimp/list.go @@ -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 diff --git a/api/server/internal/server/walletapiimp/list_test.go b/api/server/internal/server/walletapiimp/list_test.go new file mode 100644 index 00000000..65bf8c72 --- /dev/null +++ b/api/server/internal/server/walletapiimp/list_test.go @@ -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[""]) + } +}