diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index 18ca5d2..0827803 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -44,8 +44,8 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index 4d80caf..1f4c761 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -176,15 +176,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/api/billing/fees/internal/service/fees/calculator.go b/api/billing/fees/internal/service/fees/calculator.go index ce7b4eb..687eb39 100644 --- a/api/billing/fees/internal/service/fees/calculator.go +++ b/api/billing/fees/internal/service/fees/calculator.go @@ -2,448 +2,16 @@ package fees import ( "context" - "errors" - "math/big" - "sort" - "strconv" - "strings" "time" + "github.com/tech/sendico/billing/fees/internal/service/fees/types" "github.com/tech/sendico/billing/fees/storage/model" - oracleclient "github.com/tech/sendico/fx/oracle/client" - dmath "github.com/tech/sendico/pkg/decimal" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" - "go.uber.org/zap" ) // Calculator isolates fee rule evaluation logic so it can be reused and tested. +// Implementation lives under internal/service/fees/internal/calculator. type Calculator interface { - Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error) -} - -// CalculationResult contains derived fee lines and audit metadata. -type CalculationResult struct { - Lines []*feesv1.DerivedPostingLine - Applied []*feesv1.AppliedRule - FxUsed *feesv1.FXUsed -} - -// quoteCalculator is the default Calculator implementation. -type fxOracle interface { - LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) -} - -type quoteCalculator struct { - logger mlogger.Logger - oracle fxOracle -} - -func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator { - return "eCalculator{ - logger: logger.Named("calculator"), - oracle: oracle, - } -} - -func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) { - if plan == nil { - return nil, merrors.InvalidArgument("plan is required") - } - if intent == nil { - return nil, merrors.InvalidArgument("intent is required") - } - - trigger := convertTrigger(intent.GetTrigger()) - if trigger == model.TriggerUnspecified { - return nil, merrors.InvalidArgument("unsupported trigger") - } - - baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount()) - if err != nil { - return nil, merrors.InvalidArgument("invalid base amount") - } - if baseAmount.Sign() < 0 { - return nil, merrors.InvalidArgument("base amount cannot be negative") - } - - baseScale := inferScale(intent.GetBaseAmount().GetAmount()) - - rules := make([]model.FeeRule, len(plan.Rules)) - copy(rules, plan.Rules) - sort.SliceStable(rules, func(i, j int) bool { - if rules[i].Priority == rules[j].Priority { - return rules[i].RuleID < rules[j].RuleID - } - return rules[i].Priority < rules[j].Priority - }) - - lines := make([]*feesv1.DerivedPostingLine, 0, len(rules)) - applied := make([]*feesv1.AppliedRule, 0, len(rules)) - - planID := "" - if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() { - planID = planRef.Hex() - } - - for _, rule := range rules { - if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) { - continue - } - - ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef) - if ledgerAccountRef == "" { - c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID)) - continue - } - - amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule) - if calcErr != nil { - if !errors.Is(calcErr, merrors.ErrInvalidArg) { - c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr)) - } - continue - } - if amount.Sign() == 0 { - continue - } - - currency := intent.GetBaseAmount().GetCurrency() - if override := strings.TrimSpace(rule.Currency); override != "" { - currency = override - } - - entrySide := mapEntrySide(rule.EntrySide) - if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED { - entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT - } - - meta := map[string]string{ - "fee_rule_id": rule.RuleID, - } - if planID != "" { - meta["fee_plan_id"] = planID - } - if rule.Metadata != nil { - if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" { - meta["tax_code"] = taxCode - } - if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" { - meta["tax_rate"] = taxRate - } - } - - lines = append(lines, &feesv1.DerivedPostingLine{ - LedgerAccountRef: ledgerAccountRef, - Money: &moneyv1.Money{ - Amount: dmath.FormatRat(amount, scale), - Currency: currency, - }, - LineType: mapLineType(rule.LineType), - Side: entrySide, - Meta: meta, - }) - - applied = append(applied, &feesv1.AppliedRule{ - RuleId: rule.RuleID, - RuleVersion: planID, - Formula: rule.Formula, - Rounding: mapRoundingMode(rule.Rounding), - TaxCode: metadataValue(rule.Metadata, "tax_code"), - TaxRate: metadataValue(rule.Metadata, "tax_rate"), - Parameters: cloneStringMap(rule.Metadata), - }) - } - - var fxUsed *feesv1.FXUsed - if trigger == model.TriggerFXConversion && c.oracle != nil { - fxUsed = c.buildFxUsed(ctx, intent) - } - - return &CalculationResult{ - Lines: lines, - Applied: applied, - FxUsed: fxUsed, - }, nil -} - -func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) { - scale, err := resolveRuleScale(rule, baseScale) - if err != nil { - return nil, 0, err - } - - result := new(big.Rat) - - if percentage := strings.TrimSpace(rule.Percentage); percentage != "" { - percentageRat, perr := dmath.RatFromString(percentage) - if perr != nil { - return nil, 0, merrors.InvalidArgument("invalid percentage") - } - result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat)) - } - - if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" { - fixedRat, ferr := dmath.RatFromString(fixed) - if ferr != nil { - return nil, 0, merrors.InvalidArgument("invalid fixed amount") - } - result = dmath.AddRat(result, fixedRat) - } - - if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" { - minRat, merr := dmath.RatFromString(minStr) - if merr != nil { - return nil, 0, merrors.InvalidArgument("invalid minimum amount") - } - if dmath.CmpRat(result, minRat) < 0 { - result = new(big.Rat).Set(minRat) - } - } - - if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" { - maxRat, merr := dmath.RatFromString(maxStr) - if merr != nil { - return nil, 0, merrors.InvalidArgument("invalid maximum amount") - } - if dmath.CmpRat(result, maxRat) > 0 { - result = new(big.Rat).Set(maxRat) - } - } - - if result.Sign() < 0 { - result = new(big.Rat).Abs(result) - } - - rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding)) - if rerr != nil { - return nil, 0, rerr - } - - return rounded, scale, nil -} - -const ( - attrFxBaseCurrency = "fx_base_currency" - attrFxQuoteCurrency = "fx_quote_currency" - attrFxProvider = "fx_provider" - attrFxSide = "fx_side" - attrFxRateOverride = "fx_rate" -) - -func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed { - if intent == nil || c.oracle == nil { - return nil - } - - attrs := intent.GetAttributes() - base := strings.TrimSpace(attrs[attrFxBaseCurrency]) - quote := strings.TrimSpace(attrs[attrFxQuoteCurrency]) - if base == "" || quote == "" { - return nil - } - - pair := &fxv1.CurrencyPair{Base: base, Quote: quote} - provider := strings.TrimSpace(attrs[attrFxProvider]) - - snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{ - Meta: oracleclient.RequestMeta{}, - Pair: pair, - Provider: provider, - }) - if err != nil { - c.logger.Warn("fees: failed to fetch FX context", zap.Error(err)) - return nil - } - if snapshot == nil { - return nil - } - - rateValue := strings.TrimSpace(attrs[attrFxRateOverride]) - if rateValue == "" { - rateValue = snapshot.Mid - } - if rateValue == "" { - rateValue = snapshot.Ask - } - if rateValue == "" { - rateValue = snapshot.Bid - } - - return &feesv1.FXUsed{ - Pair: pair, - Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])), - Rate: &moneyv1.Decimal{Value: rateValue}, - AsofUnixMs: snapshot.AsOf.UnixMilli(), - Provider: snapshot.Provider, - RateRef: snapshot.RateRef, - SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps}, - } -} - -func parseFxSide(value string) fxv1.Side { - switch strings.ToLower(value) { - case "buy_base", "buy_base_sell_quote", "buy": - return fxv1.Side_BUY_BASE_SELL_QUOTE - case "sell_base", "sell_base_buy_quote", "sell": - return fxv1.Side_SELL_BASE_BUY_QUOTE - default: - return fxv1.Side_SIDE_UNSPECIFIED - } -} - -func inferScale(amount string) uint32 { - value := strings.TrimSpace(amount) - if value == "" { - return 0 - } - if idx := strings.IndexAny(value, "eE"); idx >= 0 { - value = value[:idx] - } - if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") { - value = value[1:] - } - if dot := strings.IndexByte(value, '.'); dot >= 0 { - return uint32(len(value[dot+1:])) - } - return 0 -} - -func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool { - if rule.Trigger != trigger { - return false - } - if rule.EffectiveFrom.After(bookedAt) { - return false - } - if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) { - return false - } - return ruleMatchesAttributes(rule, attributes) -} - -func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) { - if rule.Metadata != nil { - for _, field := range []string{"scale", "decimals", "precision"} { - if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" { - return parseScale(field, value) - } - } - } - return fallback, nil -} - -func parseScale(field, value string) (uint32, error) { - clean := strings.TrimSpace(value) - if clean == "" { - return 0, merrors.InvalidArgument(field + " is empty") - } - parsed, err := strconv.ParseUint(clean, 10, 32) - if err != nil { - return 0, merrors.InvalidArgument("invalid " + field + " value") - } - return uint32(parsed), nil -} - -func metadataValue(meta map[string]string, key string) string { - if meta == nil { - return "" - } - return strings.TrimSpace(meta[key]) -} - -func cloneStringMap(src map[string]string) map[string]string { - if len(src) == 0 { - return nil - } - cloned := make(map[string]string, len(src)) - for k, v := range src { - cloned[k] = v - } - return cloned -} - -func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool { - if len(rule.AppliesTo) == 0 { - return true - } - for key, value := range rule.AppliesTo { - if attributes == nil { - return false - } - if attrValue, ok := attributes[key]; !ok || attrValue != value { - return false - } - } - return true -} - -func convertTrigger(trigger feesv1.Trigger) model.Trigger { - switch trigger { - case feesv1.Trigger_TRIGGER_CAPTURE: - return model.TriggerCapture - case feesv1.Trigger_TRIGGER_REFUND: - return model.TriggerRefund - case feesv1.Trigger_TRIGGER_DISPUTE: - return model.TriggerDispute - case feesv1.Trigger_TRIGGER_PAYOUT: - return model.TriggerPayout - case feesv1.Trigger_TRIGGER_FX_CONVERSION: - return model.TriggerFXConversion - default: - return model.TriggerUnspecified - } -} - -func mapLineType(lineType string) accountingv1.PostingLineType { - switch strings.ToLower(lineType) { - case "tax": - return accountingv1.PostingLineType_POSTING_LINE_TAX - case "spread": - return accountingv1.PostingLineType_POSTING_LINE_SPREAD - case "reversal": - return accountingv1.PostingLineType_POSTING_LINE_REVERSAL - default: - return accountingv1.PostingLineType_POSTING_LINE_FEE - } -} - -func mapEntrySide(entrySide string) accountingv1.EntrySide { - switch strings.ToLower(entrySide) { - case "debit": - return accountingv1.EntrySide_ENTRY_SIDE_DEBIT - case "credit": - return accountingv1.EntrySide_ENTRY_SIDE_CREDIT - default: - return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED - } -} - -func toDecimalRounding(mode string) dmath.RoundingMode { - switch strings.ToLower(strings.TrimSpace(mode)) { - case "half_up": - return dmath.RoundingModeHalfUp - case "down": - return dmath.RoundingModeDown - case "half_even", "bankers": - return dmath.RoundingModeHalfEven - default: - return dmath.RoundingModeHalfEven - } -} - -func mapRoundingMode(mode string) moneyv1.RoundingMode { - switch strings.ToLower(mode) { - case "half_up": - return moneyv1.RoundingMode_ROUND_HALF_UP - case "down": - return moneyv1.RoundingMode_ROUND_DOWN - default: - return moneyv1.RoundingMode_ROUND_HALF_EVEN - } + Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error) } diff --git a/api/billing/fees/internal/service/fees/internal/calculator/impl.go b/api/billing/fees/internal/service/fees/internal/calculator/impl.go new file mode 100644 index 0000000..926752d --- /dev/null +++ b/api/billing/fees/internal/service/fees/internal/calculator/impl.go @@ -0,0 +1,442 @@ +package calculator + +import ( + "context" + "errors" + "math/big" + "sort" + "strconv" + "strings" + "time" + + "github.com/tech/sendico/billing/fees/internal/service/fees/types" + "github.com/tech/sendico/billing/fees/storage/model" + oracleclient "github.com/tech/sendico/fx/oracle/client" + dmath "github.com/tech/sendico/pkg/decimal" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" + "go.uber.org/zap" +) + +// fxOracle captures the oracle dependency for FX conversions. +type fxOracle interface { + LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) +} + +// New constructs the default calculator implementation. +func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator { + if logger == nil { + logger = zap.NewNop() + } + return "eCalculator{ + logger: logger.Named("calculator"), + oracle: oracle, + } +} + +type quoteCalculator struct { + logger mlogger.Logger + oracle fxOracle +} + +func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) { + if plan == nil { + return nil, merrors.InvalidArgument("plan is required") + } + if intent == nil { + return nil, merrors.InvalidArgument("intent is required") + } + + trigger := convertTrigger(intent.GetTrigger()) + if trigger == model.TriggerUnspecified { + return nil, merrors.InvalidArgument("unsupported trigger") + } + + baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount()) + if err != nil { + return nil, merrors.InvalidArgument("invalid base amount") + } + if baseAmount.Sign() < 0 { + return nil, merrors.InvalidArgument("base amount cannot be negative") + } + + baseScale := inferScale(intent.GetBaseAmount().GetAmount()) + + rules := make([]model.FeeRule, len(plan.Rules)) + copy(rules, plan.Rules) + sort.SliceStable(rules, func(i, j int) bool { + if rules[i].Priority == rules[j].Priority { + return rules[i].RuleID < rules[j].RuleID + } + return rules[i].Priority < rules[j].Priority + }) + + lines := make([]*feesv1.DerivedPostingLine, 0, len(rules)) + applied := make([]*feesv1.AppliedRule, 0, len(rules)) + + planID := "" + if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() { + planID = planRef.Hex() + } + + for _, rule := range rules { + if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) { + continue + } + + ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef) + if ledgerAccountRef == "" { + c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID)) + continue + } + + amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule) + if calcErr != nil { + if !errors.Is(calcErr, merrors.ErrInvalidArg) { + c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr)) + } + continue + } + if amount.Sign() == 0 { + continue + } + + currency := intent.GetBaseAmount().GetCurrency() + if override := strings.TrimSpace(rule.Currency); override != "" { + currency = override + } + + entrySide := mapEntrySide(rule.EntrySide) + if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED { + entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT + } + + meta := map[string]string{ + "fee_rule_id": rule.RuleID, + } + if planID != "" { + meta["fee_plan_id"] = planID + } + if rule.Metadata != nil { + if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" { + meta["tax_code"] = taxCode + } + if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" { + meta["tax_rate"] = taxRate + } + } + + lines = append(lines, &feesv1.DerivedPostingLine{ + LedgerAccountRef: ledgerAccountRef, + Money: &moneyv1.Money{ + Amount: dmath.FormatRat(amount, scale), + Currency: currency, + }, + LineType: mapLineType(rule.LineType), + Side: entrySide, + Meta: meta, + }) + + applied = append(applied, &feesv1.AppliedRule{ + RuleId: rule.RuleID, + RuleVersion: planID, + Formula: rule.Formula, + Rounding: mapRoundingMode(rule.Rounding), + TaxCode: metadataValue(rule.Metadata, "tax_code"), + TaxRate: metadataValue(rule.Metadata, "tax_rate"), + Parameters: cloneStringMap(rule.Metadata), + }) + } + + var fxUsed *feesv1.FXUsed + if trigger == model.TriggerFXConversion && c.oracle != nil { + fxUsed = c.buildFxUsed(ctx, intent) + } + + return &types.CalculationResult{ + Lines: lines, + Applied: applied, + FxUsed: fxUsed, + }, nil +} + +func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) { + scale, err := resolveRuleScale(rule, baseScale) + if err != nil { + return nil, 0, err + } + + result := new(big.Rat) + + if percentage := strings.TrimSpace(rule.Percentage); percentage != "" { + percentageRat, perr := dmath.RatFromString(percentage) + if perr != nil { + return nil, 0, merrors.InvalidArgument("invalid percentage") + } + result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat)) + } + + if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" { + fixedRat, ferr := dmath.RatFromString(fixed) + if ferr != nil { + return nil, 0, merrors.InvalidArgument("invalid fixed amount") + } + result = dmath.AddRat(result, fixedRat) + } + + if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" { + minRat, merr := dmath.RatFromString(minStr) + if merr != nil { + return nil, 0, merrors.InvalidArgument("invalid minimum amount") + } + if dmath.CmpRat(result, minRat) < 0 { + result = new(big.Rat).Set(minRat) + } + } + + if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" { + maxRat, merr := dmath.RatFromString(maxStr) + if merr != nil { + return nil, 0, merrors.InvalidArgument("invalid maximum amount") + } + if dmath.CmpRat(result, maxRat) > 0 { + result = new(big.Rat).Set(maxRat) + } + } + + if result.Sign() < 0 { + result = new(big.Rat).Abs(result) + } + + rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding)) + if rerr != nil { + return nil, 0, rerr + } + + return rounded, scale, nil +} + +const ( + attrFxBaseCurrency = "fx_base_currency" + attrFxQuoteCurrency = "fx_quote_currency" + attrFxProvider = "fx_provider" + attrFxSide = "fx_side" + attrFxRateOverride = "fx_rate" +) + +func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed { + if intent == nil || c.oracle == nil { + return nil + } + + attrs := intent.GetAttributes() + base := strings.TrimSpace(attrs[attrFxBaseCurrency]) + quote := strings.TrimSpace(attrs[attrFxQuoteCurrency]) + if base == "" || quote == "" { + return nil + } + + pair := &fxv1.CurrencyPair{Base: base, Quote: quote} + provider := strings.TrimSpace(attrs[attrFxProvider]) + + snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{ + Meta: oracleclient.RequestMeta{}, + Pair: pair, + Provider: provider, + }) + if err != nil { + c.logger.Warn("fees: failed to fetch FX context", zap.Error(err)) + return nil + } + if snapshot == nil { + return nil + } + + rateValue := strings.TrimSpace(attrs[attrFxRateOverride]) + if rateValue == "" { + rateValue = snapshot.Mid + } + if rateValue == "" { + rateValue = snapshot.Ask + } + if rateValue == "" { + rateValue = snapshot.Bid + } + + return &feesv1.FXUsed{ + Pair: pair, + Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])), + Rate: &moneyv1.Decimal{Value: rateValue}, + AsofUnixMs: snapshot.AsOf.UnixMilli(), + Provider: snapshot.Provider, + RateRef: snapshot.RateRef, + SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps}, + } +} + +func parseFxSide(value string) fxv1.Side { + switch strings.ToLower(value) { + case "buy_base", "buy_base_sell_quote", "buy": + return fxv1.Side_BUY_BASE_SELL_QUOTE + case "sell_base", "sell_base_buy_quote", "sell": + return fxv1.Side_SELL_BASE_BUY_QUOTE + default: + return fxv1.Side_SIDE_UNSPECIFIED + } +} + +func inferScale(amount string) uint32 { + value := strings.TrimSpace(amount) + if value == "" { + return 0 + } + if idx := strings.IndexAny(value, "eE"); idx >= 0 { + value = value[:idx] + } + if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") { + value = value[1:] + } + if dot := strings.IndexByte(value, '.'); dot >= 0 { + return uint32(len(value[dot+1:])) + } + return 0 +} + +func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool { + if rule.Trigger != trigger { + return false + } + if rule.EffectiveFrom.After(bookedAt) { + return false + } + if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) { + return false + } + return ruleMatchesAttributes(rule, attributes) +} + +func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) { + if rule.Metadata != nil { + for _, field := range []string{"scale", "decimals", "precision"} { + if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" { + return parseScale(field, value) + } + } + } + return fallback, nil +} + +func parseScale(field, value string) (uint32, error) { + clean := strings.TrimSpace(value) + if clean == "" { + return 0, merrors.InvalidArgument(field + " is empty") + } + parsed, err := strconv.ParseUint(clean, 10, 32) + if err != nil { + return 0, merrors.InvalidArgument("invalid " + field + " value") + } + return uint32(parsed), nil +} + +func metadataValue(meta map[string]string, key string) string { + if meta == nil { + return "" + } + return strings.TrimSpace(meta[key]) +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + cloned := make(map[string]string, len(src)) + for k, v := range src { + cloned[k] = v + } + return cloned +} + +func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool { + if len(rule.AppliesTo) == 0 { + return true + } + for key, value := range rule.AppliesTo { + if attributes == nil { + return false + } + if attrValue, ok := attributes[key]; !ok || attrValue != value { + return false + } + } + return true +} + +func mapLineType(lineType string) accountingv1.PostingLineType { + switch strings.ToLower(lineType) { + case "tax": + return accountingv1.PostingLineType_POSTING_LINE_TAX + case "spread": + return accountingv1.PostingLineType_POSTING_LINE_SPREAD + case "reversal": + return accountingv1.PostingLineType_POSTING_LINE_REVERSAL + default: + return accountingv1.PostingLineType_POSTING_LINE_FEE + } +} + +func mapEntrySide(entrySide string) accountingv1.EntrySide { + switch strings.ToLower(entrySide) { + case "debit": + return accountingv1.EntrySide_ENTRY_SIDE_DEBIT + case "credit": + return accountingv1.EntrySide_ENTRY_SIDE_CREDIT + default: + return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED + } +} + +func toDecimalRounding(mode string) dmath.RoundingMode { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "half_up": + return dmath.RoundingModeHalfUp + case "down": + return dmath.RoundingModeDown + case "half_even", "bankers": + return dmath.RoundingModeHalfEven + default: + return dmath.RoundingModeHalfEven + } +} + +func mapRoundingMode(mode string) moneyv1.RoundingMode { + switch strings.ToLower(mode) { + case "half_up": + return moneyv1.RoundingMode_ROUND_HALF_UP + case "down": + return moneyv1.RoundingMode_ROUND_DOWN + default: + return moneyv1.RoundingMode_ROUND_HALF_EVEN + } +} + +func convertTrigger(trigger feesv1.Trigger) model.Trigger { + switch trigger { + case feesv1.Trigger_TRIGGER_CAPTURE: + return model.TriggerCapture + case feesv1.Trigger_TRIGGER_REFUND: + return model.TriggerRefund + case feesv1.Trigger_TRIGGER_DISPUTE: + return model.TriggerDispute + case feesv1.Trigger_TRIGGER_PAYOUT: + return model.TriggerPayout + case feesv1.Trigger_TRIGGER_FX_CONVERSION: + return model.TriggerFXConversion + default: + return model.TriggerUnspecified + } +} diff --git a/api/billing/fees/internal/service/fees/internal/resolver/errors.go b/api/billing/fees/internal/service/fees/internal/resolver/errors.go new file mode 100644 index 0000000..aec97ec --- /dev/null +++ b/api/billing/fees/internal/service/fees/internal/resolver/errors.go @@ -0,0 +1,10 @@ +package resolver + +import "github.com/tech/sendico/pkg/merrors" + +var ( + // ErrNoFeeRuleFound indicates that no applicable rule exists for the given context. + ErrNoFeeRuleFound = merrors.ErrNoData + // ErrConflictingFeeRules indicates multiple rules share the same highest priority. + ErrConflictingFeeRules = merrors.ErrDataConflict +) diff --git a/api/billing/fees/internal/service/fees/internal/resolver/impl.go b/api/billing/fees/internal/service/fees/internal/resolver/impl.go new file mode 100644 index 0000000..881980d --- /dev/null +++ b/api/billing/fees/internal/service/fees/internal/resolver/impl.go @@ -0,0 +1,148 @@ +package resolver + +import ( + "context" + "errors" + "time" + + "github.com/tech/sendico/billing/fees/storage" + "github.com/tech/sendico/billing/fees/storage/model" + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +type planFinder interface { + FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) + FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) +} + +type feeResolver struct { + plans storage.PlansStore + finder planFinder + logger *zap.Logger +} + +func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver { + var finder planFinder + if pf, ok := plans.(planFinder); ok { + finder = pf + } + if logger == nil { + logger = zap.NewNop() + } + return &feeResolver{ + plans: plans, + finder: finder, + logger: logger.Named("resolver"), + } +} + +func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) { + if r.plans == nil { + return nil, nil, merrors.InvalidArgument("fees: plans store is required") + } + + // Try org-specific first if provided. + if orgID != nil && !orgID.IsZero() { + if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil { + if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { + return plan, rule, nil + } else if !errors.Is(selErr, ErrNoFeeRuleFound) { + r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex())) + return nil, nil, selErr + } + r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex())) + } else if !errors.Is(err, storage.ErrFeePlanNotFound) { + r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex())) + return nil, nil, err + } + } + + plan, err := r.getGlobalPlan(ctx, at) + if err != nil { + if errors.Is(err, storage.ErrFeePlanNotFound) { + return nil, nil, merrors.NoData("fees: no applicable fee rule found") + } + r.logger.Warn("failed resolving global fee plan", zap.Error(err)) + return nil, nil, err + } + + rule, err := selectRule(plan, trigger, at, attrs) + if err != nil { + if !errors.Is(err, ErrNoFeeRuleFound) { + r.logger.Warn("failed selecting rule in global plan", zap.Error(err)) + } + return nil, nil, err + } + + return plan, rule, nil +} + +func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { + if r.finder != nil { + return r.finder.FindActiveOrgPlan(ctx, orgRef, at) + } + return r.plans.GetActivePlan(ctx, orgRef, at) +} + +func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) { + if r.finder != nil { + return r.finder.FindActiveGlobalPlan(ctx, at) + } + // Treat zero ObjectID as global in legacy path. + return r.plans.GetActivePlan(ctx, primitive.NilObjectID, at) +} + +func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) { + if plan == nil { + return nil, merrors.NoData("fees: no applicable fee rule found") + } + + var selected *model.FeeRule + var highestPriority int + for _, rule := range plan.Rules { + if rule.Trigger != trigger { + continue + } + if rule.EffectiveFrom.After(at) { + continue + } + if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) { + continue + } + if !matchesAppliesTo(rule.AppliesTo, attrs) { + continue + } + + if selected == nil || rule.Priority > highestPriority { + copy := rule + selected = © + highestPriority = rule.Priority + continue + } + if rule.Priority == highestPriority { + return nil, merrors.DataConflict("fees: conflicting fee rules") + } + } + + if selected == nil { + return nil, merrors.NoData("fees: no applicable fee rule found") + } + return selected, nil +} + +func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool { + if len(appliesTo) == 0 { + return true + } + for key, value := range appliesTo { + if attrs == nil { + return false + } + if attrs[key] != value { + return false + } + } + return true +} 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 new file mode 100644 index 0000000..bfc2e23 --- /dev/null +++ b/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go @@ -0,0 +1,315 @@ +package resolver + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/tech/sendico/billing/fees/storage" + "github.com/tech/sendico/billing/fees/storage/model" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) { + t.Helper() + + now := time.Now() + globalPlan := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + {RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + + store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan}} + resolver := New(store, zap.NewNop()) + + orgA := primitive.NewObjectID() + plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil) + if err != nil { + t.Fatalf("expected fallback to global, got error: %v", err) + } + if !plan.GetOrganizationRef().IsZero() { + t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex()) + } + if rule.RuleID != "global_capture" { + t.Fatalf("unexpected rule selected: %s", rule.RuleID) + } +} + +func TestResolver_OrgOverridesGlobal(t *testing.T) { + t.Helper() + + now := time.Now() + org := primitive.NewObjectID() + globalPlan := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + {RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + orgPlan := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + {RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + orgPlan.SetOrganizationRef(org) + + store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}} + resolver := New(store, zap.NewNop()) + + _, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil) + if err != nil { + t.Fatalf("expected org plan rule, got error: %v", err) + } + if rule.RuleID != "org_capture" { + t.Fatalf("expected org rule, got %s", rule.RuleID) + } + + otherOrg := primitive.NewObjectID() + _, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil) + if err != nil { + t.Fatalf("expected global fallback for other org, got error: %v", err) + } + if rule.RuleID != "global_capture" { + t.Fatalf("expected global rule, got %s", rule.RuleID) + } +} + +func TestResolver_SelectsHighestPriority(t *testing.T) { + t.Helper() + + now := time.Now() + org := primitive.NewObjectID() + plan := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + {RuleID: "low", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, + {RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + plan.SetOrganizationRef(org) + + store := &memoryPlansStore{plans: []*model.FeePlan{plan}} + resolver := New(store, zap.NewNop()) + + _, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil) + if err != nil { + t.Fatalf("expected rule resolution, got error: %v", err) + } + if rule.RuleID != "high" { + t.Fatalf("expected highest priority rule, got %s", rule.RuleID) + } + + plan.Rules = append(plan.Rules, model.FeeRule{ + RuleID: "conflict", + Trigger: model.TriggerCapture, + Priority: 200, + Percentage: "0.02", + EffectiveFrom: now.Add(-time.Hour), + }) + + if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, ErrConflictingFeeRules) { + t.Fatalf("expected conflicting fee rules error, got %v", err) + } +} + +func TestResolver_EffectiveDateFiltering(t *testing.T) { + t.Helper() + + now := time.Now() + org := primitive.NewObjectID() + past := now.Add(-24 * time.Hour) + future := now.Add(24 * time.Hour) + + orgPlan := &model.FeePlan{ + Active: true, + EffectiveFrom: past, + Rules: []model.FeeRule{ + {RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past}, + }, + } + orgPlan.SetOrganizationRef(org) + + globalPlan := &model.FeePlan{ + Active: true, + EffectiveFrom: past, + Rules: []model.FeeRule{ + {RuleID: "current", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &future}, + }, + } + + store := &memoryPlansStore{plans: []*model.FeePlan{orgPlan, globalPlan}} + resolver := New(store, zap.NewNop()) + + _, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil) + if err != nil { + t.Fatalf("expected fallback to global, got error: %v", err) + } + if rule.RuleID != "current" { + t.Fatalf("expected current global rule, got %s", rule.RuleID) + } +} + +func TestResolver_AppliesToFiltering(t *testing.T) { + t.Helper() + + now := time.Now() + plan := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + {RuleID: "card", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", AppliesTo: map[string]string{"paymentMethod": "card"}, EffectiveFrom: now.Add(-time.Hour)}, + {RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + store := &memoryPlansStore{plans: []*model.FeePlan{plan}} + resolver := New(store, zap.NewNop()) + + _, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "card"}) + if err != nil { + t.Fatalf("expected card rule, got error: %v", err) + } + if rule.RuleID != "card" { + t.Fatalf("expected card rule, got %s", rule.RuleID) + } + + _, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "bank"}) + if err != nil { + t.Fatalf("expected default rule, got error: %v", err) + } + if rule.RuleID != "default" { + t.Fatalf("expected default rule, got %s", rule.RuleID) + } +} + +func TestResolver_MissingTriggerReturnsErr(t *testing.T) { + t.Helper() + + now := time.Now() + plan := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + {RuleID: "capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + store := &memoryPlansStore{plans: []*model.FeePlan{plan}} + resolver := New(store, zap.NewNop()) + + if _, _, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerRefund, now, nil); !errors.Is(err, ErrNoFeeRuleFound) { + t.Fatalf("expected ErrNoFeeRuleFound, got %v", err) + } +} + +func TestResolver_MultipleActivePlansConflict(t *testing.T) { + t.Helper() + + now := time.Now() + org := primitive.NewObjectID() + p1 := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + {RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + p1.SetOrganizationRef(org) + p2 := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-30 * time.Minute), + Rules: []model.FeeRule{ + {RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + p2.SetOrganizationRef(org) + + store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}} + resolver := New(store, zap.NewNop()) + + if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) { + t.Fatalf("expected conflicting plans error, got %v", err) + } +} + +type memoryPlansStore struct { + plans []*model.FeePlan +} + +func (m *memoryPlansStore) Create(context.Context, *model.FeePlan) error { return nil } +func (m *memoryPlansStore) Update(context.Context, *model.FeePlan) error { return nil } +func (m *memoryPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) { + return nil, storage.ErrFeePlanNotFound +} + +func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { + if !orgRef.IsZero() { + if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil { + return plan, nil + } else if !errors.Is(err, storage.ErrFeePlanNotFound) { + return nil, err + } + } + return m.FindActiveGlobalPlan(ctx, at) +} + +func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { + var matches []*model.FeePlan + for _, plan := range m.plans { + if plan == nil || plan.GetOrganizationRef() != orgRef { + continue + } + if !plan.Active { + continue + } + if plan.EffectiveFrom.After(at) { + continue + } + if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) { + continue + } + matches = append(matches, plan) + } + if len(matches) == 0 { + return nil, storage.ErrFeePlanNotFound + } + if len(matches) > 1 { + return nil, storage.ErrConflictingFeePlans + } + return matches[0], nil +} + +func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) { + var matches []*model.FeePlan + for _, plan := range m.plans { + if plan == nil || !plan.GetOrganizationRef().IsZero() { + continue + } + if !plan.Active { + continue + } + if plan.EffectiveFrom.After(at) { + continue + } + if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) { + continue + } + matches = append(matches, plan) + } + if len(matches) == 0 { + return nil, storage.ErrFeePlanNotFound + } + if len(matches) > 1 { + return nil, storage.ErrConflictingFeePlans + } + return matches[0], nil +} + +var _ storage.PlansStore = (*memoryPlansStore)(nil) diff --git a/api/billing/fees/internal/service/fees/options.go b/api/billing/fees/internal/service/fees/options.go index 4982e99..2f761f3 100644 --- a/api/billing/fees/internal/service/fees/options.go +++ b/api/billing/fees/internal/service/fees/options.go @@ -1,6 +1,7 @@ package fees import ( + internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator" oracleclient "github.com/tech/sendico/fx/oracle/client" clockpkg "github.com/tech/sendico/pkg/clock" ) @@ -30,8 +31,18 @@ func WithCalculator(calculator Calculator) Option { func WithOracleClient(oracle oracleclient.Client) Option { return func(s *Service) { s.oracle = oracle - if qc, ok := s.calculator.(*quoteCalculator); ok { - qc.oracle = oracle + // Rebuild default calculator if none was injected. + if s.calculator == nil { + s.calculator = internalcalculator.New(s.logger, oracle) + } + } +} + +// WithFeeResolver injects a custom fee resolver (useful for tests). +func WithFeeResolver(r FeeResolver) Option { + return func(s *Service) { + if r != nil { + s.resolver = r } } } diff --git a/api/billing/fees/internal/service/fees/resolver.go b/api/billing/fees/internal/service/fees/resolver.go new file mode 100644 index 0000000..92ba819 --- /dev/null +++ b/api/billing/fees/internal/service/fees/resolver.go @@ -0,0 +1,15 @@ +package fees + +import ( + "context" + "time" + + "github.com/tech/sendico/billing/fees/storage/model" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// FeeResolver centralises plan/rule resolution with org override and global fallback. +// Implementations live under the internal/resolver package. +type FeeResolver interface { + ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) +} diff --git a/api/billing/fees/internal/service/fees/service.go b/api/billing/fees/internal/service/fees/service.go index c5b2ad6..f788601 100644 --- a/api/billing/fees/internal/service/fees/service.go +++ b/api/billing/fees/internal/service/fees/service.go @@ -8,7 +8,10 @@ import ( "strings" "time" + internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator" + "github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver" "github.com/tech/sendico/billing/fees/storage" + "github.com/tech/sendico/billing/fees/storage/model" oracleclient "github.com/tech/sendico/fx/oracle/client" "github.com/tech/sendico/pkg/api/routers" clockpkg "github.com/tech/sendico/pkg/clock" @@ -32,6 +35,7 @@ type Service struct { clock clockpkg.Clock calculator Calculator oracle oracleclient.Client + resolver FeeResolver feesv1.UnimplementedFeeEngineServer } @@ -52,7 +56,10 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro svc.clock = clockpkg.NewSystem() } if svc.calculator == nil { - svc.calculator = newQuoteCalculator(svc.logger, svc.oracle) + svc.calculator = internalcalculator.New(svc.logger, svc.oracle) + } + if svc.resolver == nil { + svc.resolver = resolver.New(repo.Plans(), svc.logger) } return svc @@ -273,15 +280,34 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj bookedAt = intent.GetBookedAt().AsTime() } - plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt) - if err != nil { - if errors.Is(err, storage.ErrFeePlanNotFound) { - return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found") - } - s.logger.Warn("failed to load active fee plan", zap.Error(err)) - return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan") + var orgPtr *primitive.ObjectID + if !orgRef.IsZero() { + orgPtr = &orgRef } + plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes()) + if err != nil { + switch { + case errors.Is(err, merrors.ErrNoData): + return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found") + case errors.Is(err, merrors.ErrDataConflict): + return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules") + case errors.Is(err, storage.ErrConflictingFeePlans): + return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee plans") + case errors.Is(err, storage.ErrFeePlanNotFound): + return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found") + default: + s.logger.Warn("failed to resolve fee rule", zap.Error(err)) + return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule") + } + } + + originalRules := plan.Rules + plan.Rules = []model.FeeRule{*rule} + defer func() { + plan.Rules = originalRules + }() + result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace) if calcErr != nil { if errors.Is(calcErr, merrors.ErrInvalidArg) { diff --git a/api/billing/fees/internal/service/fees/service_test.go b/api/billing/fees/internal/service/fees/service_test.go index bb3ea24..f53a170 100644 --- a/api/billing/fees/internal/service/fees/service_test.go +++ b/api/billing/fees/internal/service/fees/service_test.go @@ -2,9 +2,11 @@ package fees import ( "context" + "errors" "testing" "time" + "github.com/tech/sendico/billing/fees/internal/service/fees/types" "github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage/model" oracleclient "github.com/tech/sendico/fx/oracle/client" @@ -263,11 +265,21 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) { plan := &model.FeePlan{ Active: true, EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + { + RuleID: "stub", + Trigger: model.TriggerCapture, + Priority: 1, + Percentage: "0.01", + LedgerAccountRef: "acct:stub", + EffectiveFrom: now.Add(-time.Hour), + }, + }, } plan.SetID(primitive.NewObjectID()) plan.SetOrganizationRef(orgRef) - result := &CalculationResult{ + result := &types.CalculationResult{ Lines: []*feesv1.DerivedPostingLine{ { LedgerAccountRef: "acct:stub", @@ -409,7 +421,8 @@ func (s *stubRepository) Plans() storage.PlansStore { } type stubPlansStore struct { - plan *model.FeePlan + plan *model.FeePlan + globalPlan *model.FeePlan } func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error { @@ -425,6 +438,17 @@ func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePla } func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { + if !orgRef.IsZero() { + if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil { + return plan, nil + } else if !errors.Is(err, storage.ErrFeePlanNotFound) { + return nil, err + } + } + return s.FindActiveGlobalPlan(context.Background(), at) +} + +func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { if s.plan == nil { return nil, storage.ErrFeePlanNotFound } @@ -434,15 +458,31 @@ func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.Objec if !s.plan.Active { return nil, storage.ErrFeePlanNotFound } - if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) { + if s.plan.EffectiveFrom.After(at) { return nil, storage.ErrFeePlanNotFound } - if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) { + if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) { return nil, storage.ErrFeePlanNotFound } return s.plan, nil } +func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) { + if s.globalPlan == nil { + return nil, storage.ErrFeePlanNotFound + } + if !s.globalPlan.Active { + return nil, storage.ErrFeePlanNotFound + } + if s.globalPlan.EffectiveFrom.After(at) { + return nil, storage.ErrFeePlanNotFound + } + if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) { + return nil, storage.ErrFeePlanNotFound + } + return s.globalPlan, nil +} + type noopProducer struct{} func (noopProducer) SendMessage(me.Envelope) error { @@ -458,14 +498,14 @@ func (f fixedClock) Now() time.Time { } type stubCalculator struct { - result *CalculationResult + result *types.CalculationResult err error called bool gotPlan *model.FeePlan bookedAt time.Time } -func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) { +func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) { s.called = true s.gotPlan = plan s.bookedAt = bookedAt diff --git a/api/billing/fees/internal/service/fees/trigger.go b/api/billing/fees/internal/service/fees/trigger.go new file mode 100644 index 0000000..5482e0b --- /dev/null +++ b/api/billing/fees/internal/service/fees/trigger.go @@ -0,0 +1,23 @@ +package fees + +import ( + "github.com/tech/sendico/billing/fees/storage/model" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" +) + +func convertTrigger(trigger feesv1.Trigger) model.Trigger { + switch trigger { + case feesv1.Trigger_TRIGGER_CAPTURE: + return model.TriggerCapture + case feesv1.Trigger_TRIGGER_REFUND: + return model.TriggerRefund + case feesv1.Trigger_TRIGGER_DISPUTE: + return model.TriggerDispute + case feesv1.Trigger_TRIGGER_PAYOUT: + return model.TriggerPayout + case feesv1.Trigger_TRIGGER_FX_CONVERSION: + return model.TriggerFXConversion + default: + return model.TriggerUnspecified + } +} diff --git a/api/billing/fees/internal/service/fees/types/calculation_result.go b/api/billing/fees/internal/service/fees/types/calculation_result.go new file mode 100644 index 0000000..545d2df --- /dev/null +++ b/api/billing/fees/internal/service/fees/types/calculation_result.go @@ -0,0 +1,12 @@ +package types + +import ( + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" +) + +// CalculationResult contains derived fee lines and audit metadata. +type CalculationResult struct { + Lines []*feesv1.DerivedPostingLine + Applied []*feesv1.AppliedRule + FxUsed *feesv1.FXUsed +} diff --git a/api/billing/fees/storage/model/plan.go b/api/billing/fees/storage/model/plan.go index f539f5f..7a59492 100644 --- a/api/billing/fees/storage/model/plan.go +++ b/api/billing/fees/storage/model/plan.go @@ -42,21 +42,21 @@ func (*FeePlan) Collection() string { // FeeRule represents a single pricing rule within a plan. type FeeRule struct { - RuleID string `bson:"ruleId" json:"ruleId"` - Trigger Trigger `bson:"trigger" json:"trigger"` - Priority int `bson:"priority" json:"priority"` - Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"` - FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"` - Currency string `bson:"currency,omitempty" json:"currency,omitempty"` - MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"` - MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"` - AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"` - Formula string `bson:"formula,omitempty" json:"formula,omitempty"` - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` - LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"` - LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"` - EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"` - Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"` - EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` - EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` + RuleID string `bson:"ruleId" json:"ruleId"` + Trigger Trigger `bson:"trigger" json:"trigger"` + Priority int `bson:"priority" json:"priority"` + Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"` + FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"` + Currency string `bson:"currency,omitempty" json:"currency,omitempty"` + MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"` + MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"` + AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"` + Formula string `bson:"formula,omitempty" json:"formula,omitempty"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"` + LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"` + EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"` + Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"` + EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` + EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` } diff --git a/api/billing/fees/storage/mongo/store/plans.go b/api/billing/fees/storage/mongo/store/plans.go index f4c8f50..568a0b0 100644 --- a/api/billing/fees/storage/mongo/store/plans.go +++ b/api/billing/fees/storage/mongo/store/plans.go @@ -3,10 +3,14 @@ package store import ( "context" "errors" + "fmt" + "sort" + "strings" "time" "github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage/model" + dmath "github.com/tech/sendico/pkg/decimal" "github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/repository/builder" ri "github.com/tech/sendico/pkg/db/repository/index" @@ -53,6 +57,19 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er return nil, err } + // Recommended index to speed up active-plan lookups (org/global + active + dates). + activeIndex := &ri.Definition{ + Keys: []ri.Key{ + {Field: m.OrganizationRefField, Sort: ri.Asc}, + {Field: "active", Sort: ri.Asc}, + {Field: "effectiveFrom", Sort: ri.Asc}, + {Field: "effectiveTo", Sort: ri.Asc}, + }, + } + if err := repo.CreateIndex(activeIndex); err != nil { + logger.Warn("failed to ensure fee plan active index", zap.Error(err)) + } + return &plansStore{ logger: logger.Named("plans"), repo: repo, @@ -60,9 +77,13 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er } func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error { - if plan == nil { - return merrors.InvalidArgument("plansStore: nil fee plan") + if err := validatePlan(plan); err != nil { + return err } + if err := p.ensureNoOverlap(ctx, plan); err != nil { + return err + } + if err := p.repo.Insert(ctx, plan, nil); err != nil { if errors.Is(err, merrors.ErrDataConflict) { return storage.ErrDuplicateFeePlan @@ -77,6 +98,13 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error { if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() { return merrors.InvalidArgument("plansStore: invalid fee plan reference") } + if err := validatePlan(plan); err != nil { + return err + } + if err := p.ensureNoOverlap(ctx, plan); err != nil { + return err + } + if err := p.repo.Update(ctx, plan); err != nil { p.logger.Warn("failed to update fee plan", zap.Error(err)) return err @@ -99,13 +127,42 @@ func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*mode } func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { + // Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global. + if orgRef.IsZero() { + return p.FindActiveGlobalPlan(ctx, at) + } + + plan, err := p.FindActiveOrgPlan(ctx, orgRef, at) + if err == nil { + return plan, nil + } + if errors.Is(err, storage.ErrFeePlanNotFound) { + return p.FindActiveGlobalPlan(ctx, at) + } + return nil, err +} + +func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { if orgRef.IsZero() { return nil, merrors.InvalidArgument("plansStore: zero organization reference") } + query := repository.Query().Filter(repository.OrgField(), orgRef) + return p.findActivePlan(ctx, query, at) +} - limit := int64(1) - query := repository.Query(). - Filter(repository.OrgField(), orgRef). +func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) { + globalQuery := repository.Query().Or( + repository.Exists(repository.OrgField(), false), + repository.Query().Filter(repository.OrgField(), nil), + ) + return p.findActivePlan(ctx, globalQuery, at) +} + +var _ storage.PlansStore = (*plansStore)(nil) + +func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) { + limit := int64(2) + query := orgQuery. Filter(repository.Field("active"), true). Comparison(repository.Field("effectiveFrom"), builder.Lte, at). Sort(repository.Field("effectiveFrom"), false). @@ -118,13 +175,13 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI ), ) - var plan *model.FeePlan + var plans []*model.FeePlan decoder := func(cursor *mongo.Cursor) error { target := &model.FeePlan{} if err := cursor.Decode(target); err != nil { return err } - plan = target + plans = append(plans, target) return nil } @@ -135,10 +192,127 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI return nil, err } - if plan == nil { + if len(plans) == 0 { return nil, storage.ErrFeePlanNotFound } - return plan, nil + if len(plans) > 1 { + return nil, storage.ErrConflictingFeePlans + } + return plans[0], nil } -var _ storage.PlansStore = (*plansStore)(nil) +func validatePlan(plan *model.FeePlan) error { + if plan == nil { + return merrors.InvalidArgument("plansStore: nil fee plan") + } + if len(plan.Rules) == 0 { + return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule") + } + if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) { + return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom") + } + + // Ensure unique priority per (trigger, appliesTo) combination. + seen := make(map[string]struct{}) + for _, rule := range plan.Rules { + if strings.TrimSpace(rule.Percentage) != "" { + if _, err := dmath.RatFromString(rule.Percentage); err != nil { + return merrors.InvalidArgument("plansStore: invalid rule percentage") + } + } + if strings.TrimSpace(rule.FixedAmount) != "" { + if _, err := dmath.RatFromString(rule.FixedAmount); err != nil { + return merrors.InvalidArgument("plansStore: invalid rule fixed amount") + } + } + if strings.TrimSpace(rule.MinimumAmount) != "" { + if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil { + return merrors.InvalidArgument("plansStore: invalid rule minimum amount") + } + } + if strings.TrimSpace(rule.MaximumAmount) != "" { + if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil { + return merrors.InvalidArgument("plansStore: invalid rule maximum amount") + } + } + + appliesKey := normalizeAppliesTo(rule.AppliesTo) + priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey) + if _, ok := seen[priorityKey]; ok { + return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo") + } + seen[priorityKey] = struct{}{} + } + return nil +} + +func normalizeAppliesTo(applies map[string]string) string { + if len(applies) == 0 { + return "" + } + keys := make([]string, 0, len(applies)) + for k := range applies { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+applies[k]) + } + return strings.Join(parts, ",") +} + +func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error { + if plan == nil || !plan.Active { + return nil + } + + orgQuery := repository.Query() + if plan.OrganizationRef.IsZero() { + orgQuery = repository.Query().Or( + repository.Exists(repository.OrgField(), false), + repository.Query().Filter(repository.OrgField(), nil), + ) + } else { + orgQuery = repository.Query().Filter(repository.OrgField(), plan.OrganizationRef) + } + + maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) + newFrom := plan.EffectiveFrom + newTo := maxTime + if plan.EffectiveTo != nil { + newTo = *plan.EffectiveTo + } + + query := orgQuery. + Filter(repository.Field("active"), true). + Comparison(repository.Field("effectiveFrom"), builder.Lte, newTo). + And(repository.Query().Or( + repository.Query().Filter(repository.Field("effectiveTo"), nil), + repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, newFrom), + )) + + if id := plan.GetID(); id != nil && !id.IsZero() { + query = query.And(repository.Query().Comparison(repository.IDField(), builder.Ne, *id)) + } + + limit := int64(1) + query = query.Limit(&limit) + + var overlapFound bool + decoder := func(cursor *mongo.Cursor) error { + overlapFound = true + return nil + } + + if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return nil + } + return err + } + if overlapFound { + return storage.ErrConflictingFeePlans + } + return nil +} diff --git a/api/billing/fees/storage/storage.go b/api/billing/fees/storage/storage.go index 9ae6451..e0b128b 100644 --- a/api/billing/fees/storage/storage.go +++ b/api/billing/fees/storage/storage.go @@ -19,6 +19,8 @@ var ( ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found") // ErrDuplicateFeePlan indicates that a unique plan constraint was violated. ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan") + // ErrConflictingFeePlans indicates multiple active plans matched a query. + ErrConflictingFeePlans = storageError("billing.fees.storage: conflicting fee plans") ) // Repository defines the root storage contract for the fees service. @@ -32,5 +34,6 @@ type PlansStore interface { Create(ctx context.Context, plan *model.FeePlan) error Update(ctx context.Context, plan *model.FeePlan) error Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error) + // Legacy helper that now prefers an org plan and falls back to a global plan. GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) } diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index 4307706..d2d6a1e 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -13,7 +13,7 @@ require ( github.com/tech/sendico/fx/storage v0.0.0 github.com/tech/sendico/pkg v0.1.0 go.uber.org/zap v1.27.1 - golang.org/x/net v0.47.0 + golang.org/x/net v0.48.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -45,7 +45,7 @@ require ( go.mongodb.org/mongo-driver v1.17.6 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index 4d80caf..1f4c761 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -176,15 +176,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/api/fx/oracle/go.mod b/api/fx/oracle/go.mod index 64b70ba..5794037 100644 --- a/api/fx/oracle/go.mod +++ b/api/fx/oracle/go.mod @@ -45,8 +45,8 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/fx/oracle/go.sum b/api/fx/oracle/go.sum index 4d80caf..1f4c761 100644 --- a/api/fx/oracle/go.sum +++ b/api/fx/oracle/go.sum @@ -176,15 +176,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/api/fx/storage/go.mod b/api/fx/storage/go.mod index 8027b12..345ccaa 100644 --- a/api/fx/storage/go.mod +++ b/api/fx/storage/go.mod @@ -25,7 +25,7 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/api/fx/storage/go.sum b/api/fx/storage/go.sum index eee8330..f9113c6 100644 --- a/api/fx/storage/go.sum +++ b/api/fx/storage/go.sum @@ -138,8 +138,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -154,8 +154,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index a295b32..16344da 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -79,9 +79,9 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 5a9570f..73e1cf8 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -320,8 +320,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -329,8 +329,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index 4aecbf3..904e688 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -45,8 +45,8 @@ require ( go.mongodb.org/mongo-driver v1.17.6 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index 5b881ba..cae6cd3 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -178,15 +178,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/api/ledger/go.mod b/api/ledger/go.mod index 24ab43e..19013dd 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -46,8 +46,8 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/ledger/go.sum b/api/ledger/go.sum index 51e42ef..3f90d69 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -178,15 +178,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/api/notification/go.mod b/api/notification/go.mod index e89ca0b..c9ba580 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -48,8 +48,8 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect diff --git a/api/notification/go.sum b/api/notification/go.sum index 6d7f489..dee973a 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -191,15 +191,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index 55a9b77..9e382ca 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -54,8 +54,8 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index f9d3e69..6039d97 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -179,15 +179,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index aca4b94..5cbe63b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -97,6 +97,7 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS FXQuote: cloneFXQuote(src.GetFxQuote()), NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()), FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()), + QuoteRef: strings.TrimSpace(src.GetQuoteRef()), } } @@ -220,6 +221,7 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ FxQuote: cloneFXQuote(src.FXQuote), NetworkFee: cloneNetworkEstimate(src.NetworkFee), FeeQuoteToken: src.FeeQuoteToken, + QuoteRef: strings.TrimSpace(src.QuoteRef), } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution.go b/api/payments/orchestrator/internal/service/orchestrator/execution.go index 5bf91c2..b43362a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/execution.go +++ b/api/payments/orchestrator/internal/service/orchestrator/execution.go @@ -19,13 +19,13 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, error) { +func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) { intent := req.GetIntent() amount := intent.GetAmount() baseAmount := cloneMoney(amount) feeQuote, err := s.quoteFees(ctx, orgRef, req) if err != nil { - return nil, err + return nil, time.Time{}, err } feeTotal := extractFeeTotal(feeQuote.GetLines(), amount.GetCurrency()) @@ -33,7 +33,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc if shouldEstimateNetworkFee(intent) { networkFee, err = s.estimateNetworkFee(ctx, intent) if err != nil { - return nil, err + return nil, time.Time{}, err } } @@ -41,13 +41,13 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc if shouldRequestFX(intent) { fxQuote, err = s.requestFXQuote(ctx, orgRef, req) if err != nil { - return nil, err + return nil, time.Time{}, err } } debitAmount, settlementAmount := computeAggregates(baseAmount, feeTotal, networkFee) - return &orchestratorv1.PaymentQuote{ + quote := &orchestratorv1.PaymentQuote{ DebitAmount: debitAmount, ExpectedSettlementAmount: settlementAmount, ExpectedFeeTotal: feeTotal, @@ -56,7 +56,11 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc FxQuote: fxQuote, NetworkFee: networkFee, FeeQuoteToken: feeQuote.GetFeeQuoteToken(), - }, nil + } + + expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote) + + return quote, expiresAt, nil } func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*feesv1.PrecomputeFeesResponse, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go index 41a8240..bbae435 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/helpers.go @@ -2,6 +2,7 @@ package orchestrator import ( "strings" + "time" "github.com/shopspring/decimal" oracleclient "github.com/tech/sendico/fx/oracle/client" @@ -219,6 +220,23 @@ func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv } } +func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time { + expiry := time.Time{} + if feeQuote != nil && feeQuote.GetExpiresAt() != nil { + expiry = feeQuote.GetExpiresAt().AsTime() + } + if expiry.IsZero() { + expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond) + } + if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 { + fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC() + if fxExpiry.Before(expiry) { + expiry = fxExpiry + } + } + return expiry +} + func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown { if quote == nil { return nil diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index de0d5f3..80c49b8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -3,8 +3,8 @@ package orchestrator import ( "time" - chainclient "github.com/tech/sendico/gateway/chain/client" oracleclient "github.com/tech/sendico/fx/oracle/client" + chainclient "github.com/tech/sendico/gateway/chain/client" ledgerclient "github.com/tech/sendico/ledger/client" clockpkg "github.com/tech/sendico/pkg/clock" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 3dce79a..be105a2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -16,6 +16,7 @@ import ( orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "go.mongodb.org/mongo-driver/bson/primitive" "google.golang.org/grpc" + "google.golang.org/protobuf/proto" ) type serviceError string @@ -132,6 +133,10 @@ func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.Q if orgRef == "" { return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required")) } + orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef) + if parseErr != nil { + return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID")) + } intent := req.GetIntent() if intent == nil { return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required")) @@ -140,11 +145,31 @@ func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.Q return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required")) } - quote, err := s.buildPaymentQuote(ctx, orgRef, req) + quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req) if err != nil { return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) } + if !req.GetPreviewOnly() { + quotesStore := s.storage.Quotes() + if quotesStore == nil { + return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) + } + quoteRef := primitive.NewObjectID().Hex() + quote.QuoteRef = quoteRef + record := &model.PaymentQuoteRecord{ + QuoteRef: quoteRef, + Intent: intentFromProto(intent), + Quote: quoteSnapshotToModel(quote), + ExpiresAt: expiresAt, + } + record.SetID(primitive.NewObjectID()) + record.SetOrganizationRef(orgObjectID) + if err := quotesStore.Create(ctx, record); err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) + } + } + return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote}) } @@ -194,10 +219,34 @@ func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) } - quote := req.GetFeeQuoteToken() + quoteRef := strings.TrimSpace(req.GetQuoteRef()) + quote := strings.TrimSpace(req.GetFeeQuoteToken()) var quoteSnapshot *orchestratorv1.PaymentQuote - if quote == "" { - quoteSnapshot, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ + if quoteRef != "" { + quotesStore := s.storage.Quotes() + if quotesStore == nil { + return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable) + } + record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef) + if err != nil { + if err == storage.ErrQuoteNotFound { + return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired")) + } + return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) + } + if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { + return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired")) + } + if !proto.Equal(protoIntentFromModel(record.Intent), intent) { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent")) + } + quoteSnapshot = modelQuoteToProto(record.Quote) + if quoteSnapshot == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty")) + } + quoteSnapshot.QuoteRef = quoteRef + } else if quote == "" { + quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ Meta: req.GetMeta(), IdempotencyKey: req.GetIdempotencyKey(), Intent: req.GetIntent(), @@ -389,7 +438,7 @@ func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestrat FeePolicy: req.GetFeePolicy(), } - quote, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ + quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ Meta: req.GetMeta(), IdempotencyKey: req.GetIdempotencyKey(), Intent: intentProto, diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index 4935a95..1d33b98 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" mo "github.com/tech/sendico/pkg/model" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" @@ -208,11 +209,43 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { // ---------------------------------------------------------------------- type stubRepository struct { - store *stubPaymentsStore + store *stubPaymentsStore + quotes storage.QuotesStore } func (r *stubRepository) Ping(context.Context) error { return nil } func (r *stubRepository) Payments() storage.PaymentsStore { return r.store } +func (r *stubRepository) Quotes() storage.QuotesStore { + if r.quotes != nil { + return r.quotes + } + return &stubQuotesStore{} +} + +type stubQuotesStore struct { + quotes map[string]*model.PaymentQuoteRecord +} + +func (s *stubQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error { + if quote == nil { + return merrors.InvalidArgument("nil quote") + } + if s.quotes == nil { + s.quotes = map[string]*model.PaymentQuoteRecord{} + } + s.quotes[strings.TrimSpace(quote.QuoteRef)] = quote + return nil +} + +func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { + if s.quotes == nil { + return nil, storage.ErrQuoteNotFound + } + if q, ok := s.quotes[strings.TrimSpace(quoteRef)]; ok { + return q, nil + } + return nil, storage.ErrQuoteNotFound +} type stubPaymentsStore struct { payments map[string]*model.Payment diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index 78cf083..53915ea 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -119,6 +119,7 @@ type PaymentQuoteSnapshot struct { FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"` FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` } // ExecutionRefs links to downstream systems. diff --git a/api/payments/orchestrator/storage/model/quote.go b/api/payments/orchestrator/storage/model/quote.go new file mode 100644 index 0000000..97324f0 --- /dev/null +++ b/api/payments/orchestrator/storage/model/quote.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/model" +) + +// PaymentQuoteRecord stores a quoted payment snapshot for later execution. +type PaymentQuoteRecord struct { + storable.Base `bson:",inline" json:",inline"` + model.OrganizationBoundBase `bson:",inline" json:",inline"` + + QuoteRef string `bson:"quoteRef" json:"quoteRef"` + Intent PaymentIntent `bson:"intent" json:"intent"` + Quote *PaymentQuoteSnapshot `bson:"quote" json:"quote"` + ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` +} + +// Collection implements storable.Storable. +func (*PaymentQuoteRecord) Collection() string { + return "payment_quotes" +} diff --git a/api/payments/orchestrator/storage/mongo/repository.go b/api/payments/orchestrator/storage/mongo/repository.go index 6074102..c8a24a0 100644 --- a/api/payments/orchestrator/storage/mongo/repository.go +++ b/api/payments/orchestrator/storage/mongo/repository.go @@ -18,6 +18,7 @@ type Store struct { ping func(context.Context) error payments storage.PaymentsStore + quotes storage.QuotesStore } // New constructs a Mongo-backed payments repository from a Mongo connection. @@ -25,28 +26,37 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { if conn == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil") } - repo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection()) - return NewWithRepository(logger, conn.Ping, repo) + paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection()) + quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection()) + return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo) } // NewWithRepository constructs a payments repository using the provided primitives. -func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository) (*Store, error) { +func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository) (*Store, error) { if ping == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil") } if paymentsRepo == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil") } + if quotesRepo == nil { + return nil, merrors.InvalidArgument("payments.storage.mongo: quotes repository is nil") + } childLogger := logger.Named("storage").Named("mongo") paymentsStore, err := store.NewPayments(childLogger, paymentsRepo) if err != nil { return nil, err } + quotesStore, err := store.NewQuotes(childLogger, quotesRepo) + if err != nil { + return nil, err + } result := &Store{ logger: childLogger, ping: ping, payments: paymentsStore, + quotes: quotesStore, } return result, nil @@ -65,4 +75,9 @@ func (s *Store) Payments() storage.PaymentsStore { return s.payments } +// Quotes returns the quotes store. +func (s *Store) Quotes() storage.QuotesStore { + return s.quotes +} + var _ storage.Repository = (*Store)(nil) diff --git a/api/payments/orchestrator/storage/mongo/store/quotes.go b/api/payments/orchestrator/storage/mongo/store/quotes.go new file mode 100644 index 0000000..2c369ee --- /dev/null +++ b/api/payments/orchestrator/storage/mongo/store/quotes.go @@ -0,0 +1,113 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/db/repository" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +type Quotes struct { + logger mlogger.Logger + repo repository.Repository +} + +// NewQuotes constructs a Mongo-backed quotes store. +func NewQuotes(logger mlogger.Logger, repo repository.Repository) (*Quotes, error) { + if repo == nil { + return nil, merrors.InvalidArgument("quotesStore: repository is nil") + } + + indexes := []*ri.Definition{ + { + Keys: []ri.Key{{Field: "quoteRef", Sort: ri.Asc}}, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}}, + }, + { + Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, + ExpireAfterSeconds: 0, + }, + } + + for _, def := range indexes { + if err := repo.CreateIndex(def); err != nil { + logger.Error("failed to ensure quotes index", zap.Error(err), zap.String("collection", repo.Collection())) + return nil, err + } + } + + return &Quotes{ + logger: logger.Named("quotes"), + repo: repo, + }, nil +} + +func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error { + if quote == nil { + return merrors.InvalidArgument("quotesStore: nil quote") + } + quote.QuoteRef = strings.TrimSpace(quote.QuoteRef) + if quote.QuoteRef == "" { + return merrors.InvalidArgument("quotesStore: empty quoteRef") + } + if quote.OrganizationRef == primitive.NilObjectID { + return merrors.InvalidArgument("quotesStore: organization_ref is required") + } + if quote.ExpiresAt.IsZero() { + return merrors.InvalidArgument("quotesStore: expires_at is required") + } + if quote.Intent.Attributes != nil { + for k, v := range quote.Intent.Attributes { + quote.Intent.Attributes[k] = strings.TrimSpace(v) + } + } + quote.Update() + + filter := repository.OrgFilter(quote.OrganizationRef).And( + repository.Filter("quoteRef", quote.QuoteRef), + ) + + if err := q.repo.Insert(ctx, quote, filter); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + return storage.ErrDuplicateQuote + } + return err + } + return nil +} + +func (q *Quotes) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { + quoteRef = strings.TrimSpace(quoteRef) + if quoteRef == "" { + return nil, merrors.InvalidArgument("quotesStore: empty quoteRef") + } + if orgRef == primitive.NilObjectID { + return nil, merrors.InvalidArgument("quotesStore: organization_ref is required") + } + entity := &model.PaymentQuoteRecord{} + query := repository.OrgFilter(orgRef).And(repository.Filter("quoteRef", quoteRef)) + if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return nil, storage.ErrQuoteNotFound + } + return nil, err + } + if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) { + return nil, storage.ErrQuoteNotFound + } + return entity, nil +} + +var _ storage.QuotesStore = (*Quotes)(nil) diff --git a/api/payments/orchestrator/storage/storage.go b/api/payments/orchestrator/storage/storage.go index df6bb38..a06fd70 100644 --- a/api/payments/orchestrator/storage/storage.go +++ b/api/payments/orchestrator/storage/storage.go @@ -18,12 +18,17 @@ var ( ErrPaymentNotFound = storageError("payments.orchestrator.storage: payment not found") // ErrDuplicatePayment signals that idempotency constraints were violated. ErrDuplicatePayment = storageError("payments.orchestrator.storage: duplicate payment") + // ErrQuoteNotFound signals that a stored quote does not exist or expired. + ErrQuoteNotFound = storageError("payments.orchestrator.storage: quote not found") + // ErrDuplicateQuote signals that a quote reference already exists. + ErrDuplicateQuote = storageError("payments.orchestrator.storage: duplicate quote") ) // Repository exposes persistence primitives for the orchestrator domain. type Repository interface { Ping(ctx context.Context) error Payments() PaymentsStore + Quotes() QuotesStore } // PaymentsStore manages payment lifecycle state. @@ -35,3 +40,9 @@ type PaymentsStore interface { GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error) List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error) } + +// QuotesStore manages temporary stored payment quotes. +type QuotesStore interface { + Create(ctx context.Context, quote *model.PaymentQuoteRecord) error + GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) +} diff --git a/api/pkg/go.mod b/api/pkg/go.mod index ebd34f4..4dbe687 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -16,7 +16,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 go.mongodb.org/mongo-driver v1.17.6 go.uber.org/zap v1.27.1 - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.46.0 google.golang.org/grpc v1.77.0 google.golang.org/protobuf v1.36.10 ) @@ -88,7 +88,7 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/pkg/go.sum b/api/pkg/go.sum index d705953..253e4ae 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -202,8 +202,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -216,8 +216,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -244,8 +244,8 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index b4635e3..49ca5ed 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -99,6 +99,7 @@ message PaymentQuote { oracle.v1.Quote fx_quote = 6; chain.gateway.v1.EstimateTransferFeeResponse network_fee = 7; string fee_quote_token = 8; + string quote_ref = 9; } message ExecutionRefs { @@ -140,6 +141,7 @@ message InitiatePaymentRequest { string fee_quote_token = 4; string fx_quote_ref = 5; map metadata = 6; + string quote_ref = 7; } message InitiatePaymentResponse { diff --git a/api/server/config.yml b/api/server/config.yml index b017934..a12b9d1 100755 --- a/api/server/config.yml +++ b/api/server/config.yml @@ -90,6 +90,12 @@ api: dial_timeout_seconds: 5 call_timeout_seconds: 5 insecure: true + payment_orchestrator: + address: sendico_payment_orchestrator:50062 + address_env: PAYMENT_ORCHESTRATOR_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true app: diff --git a/api/server/go.mod b/api/server/go.mod index f612b60..bf84e2a 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -6,13 +6,15 @@ replace github.com/tech/sendico/pkg => ../pkg replace github.com/tech/sendico/ledger => ../ledger +replace github.com/tech/sendico/payments/orchestrator => ../payments/orchestrator + replace github.com/tech/sendico/gateway/chain => ../gateway/chain require ( - github.com/aws/aws-sdk-go-v2 v1.40.1 - github.com/aws/aws-sdk-go-v2/config v1.32.3 - github.com/aws/aws-sdk-go-v2/credentials v1.19.3 - github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 + github.com/aws/aws-sdk-go-v2 v1.41.0 + github.com/aws/aws-sdk-go-v2/config v1.32.4 + github.com/aws/aws-sdk-go-v2/credentials v1.19.4 + github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.3.3 @@ -22,12 +24,13 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tech/sendico/gateway/chain v0.1.0 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 + github.com/tech/sendico/payments/orchestrator v0.0.0-00010101000000-000000000000 github.com/tech/sendico/pkg v0.1.0 github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 go.mongodb.org/mongo-driver v1.17.6 go.uber.org/zap v1.27.1 - golang.org/x/net v0.47.0 + golang.org/x/net v0.48.0 google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 moul.io/chizap v1.0.3 @@ -44,19 +47,19 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect @@ -131,7 +134,7 @@ require ( go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/api/server/go.sum b/api/server/go.sum index 1adc889..c9ffe33 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -6,42 +6,42 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc= -github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.3 h1:cpz7H2uMNTDa0h/5CYL5dLUEzPSLo2g0NkbxTRJtSSU= -github.com/aws/aws-sdk-go-v2/config v1.32.3/go.mod h1:srtPKaJJe3McW6T/+GMBZyIPc+SeqJsNPJsd4mOYZ6s= -github.com/aws/aws-sdk-go-v2/credentials v1.19.3 h1:01Ym72hK43hjwDeJUfi1l2oYLXBAOR8gNSZNmXmvuas= -github.com/aws/aws-sdk-go-v2/credentials v1.19.3/go.mod h1:55nWF/Sr9Zvls0bGnWkRxUdhzKqj9uRNlPvgV1vgxKc= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 h1:utxLraaifrSBkeyII9mIbVwXXWrZdlPO7FIKmyLCEcY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15/go.mod h1:hW6zjYUDQwfz3icf4g2O41PHi77u10oAzJ84iSzR/lo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 h1:Y5YXgygXwDI5P4RkteB5yF7v35neH7LfJKBG+hzIons= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15/go.mod h1:K+/1EpG42dFSY7CBj+Fruzm8PsCGWTXJ3jdeJ659oGQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 h1:AvltKnW9ewxX2hFmQS0FyJH93aSvJVUEFvXfU+HWtSE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15/go.mod h1:3I4oCdZdmgrREhU74qS1dK9yZ62yumob+58AbFR4cQA= +github.com/aws/aws-sdk-go-v2/config v1.32.4 h1:gl+DxVuadpkYoaDcWllZqLkhGEbvwyqgNVRTmlaf5PI= +github.com/aws/aws-sdk-go-v2/config v1.32.4/go.mod h1:MBUp9Og/bzMmQHjMwace4aJfyvJeadzXjoTcR/SxLV0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.4 h1:KeIZxHVbGWRLhPvhdPbbi/DtFBHNKm6OsVDuiuFefdQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.4/go.mod h1:Smw5n0nCZE9PeFEguofdXyt8kUC4JNrkDTfBOioPhFA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 h1:P1MU/SuhadGvg2jtviDXPEejU3jBNhoeeAlRadHzvHI= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6/go.mod h1:5KYaMG6wmVKMFBSfWoyG/zH8pWwzQFnKgpoSRlXHKdQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 h1:3/u/4yZOffg5jdNk1sDpOQ4Y+R6Xbh+GzpDrSZjuy3U= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15/go.mod h1:4Zkjq0FKjE78NKjabuM4tRXKFzUJWXgP0ItEZK8l7JU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1crz0Ap7VBZrV4nNqZt4CIBBT8mnwoNc= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0= -github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI= -github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 h1:d/6xOGIllc/XW1lzG9a4AUBMmpLA9PXcQnVPTuHHcik= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.3/go.mod h1:fQ7E7Qj9GiW8y0ClD7cUJk3Bz5Iw8wZkWDHsTe8vDKs= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 h1:8sTTiw+9yuNXcfWeqKF2x01GqCF49CpP4Z9nKrrk/ts= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.6/go.mod h1:8WYg+Y40Sn3X2hioaaWAAIngndR8n1XFdRPPX+7QBaM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 h1:E+KqWoVsSrj1tJ6I/fjDIu5xoS2Zacuu1zT+H7KtiIk= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11/go.mod h1:qyWHz+4lvkXcr3+PoGlGHEI+3DLLiU6/GdrFfMaAhB0= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 h1:tzMkjh0yTChUqJDgGkcDdxvZDSrJ/WB6R6ymI5ehqJI= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.3/go.mod h1:T270C0R5sZNLbWUe8ueiAF42XSZxxPocTaGSgs5c/60= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= +github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 h1:5FhzzN6JmlGQF6c04kDIb5KNGm6KnNdLISNrfivIhHg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 h1:YCu/iAhQer8WZ66lldyKkpvMyv+HkPufMa4dyT6wils= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.4/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -288,8 +288,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -304,8 +304,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -332,8 +332,8 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/api/server/interface/api/config.go b/api/server/interface/api/config.go index 7b10963..09a40a5 100644 --- a/api/server/interface/api/config.go +++ b/api/server/interface/api/config.go @@ -6,10 +6,11 @@ import ( ) type Config struct { - Mw *mwa.Config `yaml:"middleware"` - Storage *fsc.Config `yaml:"storage"` - ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"` - Ledger *LedgerConfig `yaml:"ledger"` + Mw *mwa.Config `yaml:"middleware"` + Storage *fsc.Config `yaml:"storage"` + ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"` + Ledger *LedgerConfig `yaml:"ledger"` + PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"` } type ChainGatewayConfig struct { @@ -34,3 +35,11 @@ type LedgerConfig struct { CallTimeoutSeconds int `yaml:"call_timeout_seconds"` Insecure bool `yaml:"insecure"` } + +type PaymentOrchestratorConfig struct { + Address string `yaml:"address"` + AddressEnv string `yaml:"address_env"` + DialTimeoutSeconds int `yaml:"dial_timeout_seconds"` + CallTimeoutSeconds int `yaml:"call_timeout_seconds"` + Insecure bool `yaml:"insecure"` +} diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go new file mode 100644 index 0000000..0d4ff59 --- /dev/null +++ b/api/server/interface/api/srequest/payment.go @@ -0,0 +1,19 @@ +package srequest + +import orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + +type QuotePaymentPayload struct { + IdempotencyKey string `json:"idempotencyKey"` + Intent *orchestratorv1.PaymentIntent `json:"intent"` + PreviewOnly bool `json:"previewOnly"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type InitiatePaymentPayload struct { + IdempotencyKey string `json:"idempotencyKey"` + Intent *orchestratorv1.PaymentIntent `json:"intent"` + Metadata map[string]string `json:"metadata,omitempty"` + FeeQuoteToken string `json:"feeQuoteToken,omitempty"` + FxQuoteRef string `json:"fxQuoteRef,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` +} diff --git a/api/server/interface/api/sresponse/money.go b/api/server/interface/api/sresponse/money.go new file mode 100644 index 0000000..ea7291c --- /dev/null +++ b/api/server/interface/api/sresponse/money.go @@ -0,0 +1,18 @@ +package sresponse + +import moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + +type Money struct { + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +func toMoney(m *moneyv1.Money) *Money { + if m == nil { + return nil + } + return &Money{ + Amount: m.GetAmount(), + Currency: m.GetCurrency(), + } +} diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go new file mode 100644 index 0000000..4acd973 --- /dev/null +++ b/api/server/interface/api/sresponse/payment.go @@ -0,0 +1,174 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +type FeeLine struct { + LedgerAccountRef string `json:"ledgerAccountRef,omitempty"` + Amount *Money `json:"amount,omitempty"` + LineType string `json:"lineType,omitempty"` + Side string `json:"side,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +type NetworkFee struct { + NetworkFee *Money `json:"networkFee,omitempty"` + EstimationContext string `json:"estimationContext,omitempty"` +} + +type FxQuote struct { + QuoteRef string `json:"quoteRef,omitempty"` + BaseCurrency string `json:"baseCurrency,omitempty"` + QuoteCurrency string `json:"quoteCurrency,omitempty"` + Side string `json:"side,omitempty"` + Price string `json:"price,omitempty"` + BaseAmount *Money `json:"baseAmount,omitempty"` + QuoteAmount *Money `json:"quoteAmount,omitempty"` + ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"` + Provider string `json:"provider,omitempty"` + RateRef string `json:"rateRef,omitempty"` + Firm bool `json:"firm,omitempty"` +} + +type PaymentQuote struct { + QuoteRef string `json:"quoteRef,omitempty"` + DebitAmount *Money `json:"debitAmount,omitempty"` + ExpectedSettlementAmount *Money `json:"expectedSettlementAmount,omitempty"` + ExpectedFeeTotal *Money `json:"expectedFeeTotal,omitempty"` + FeeQuoteToken string `json:"feeQuoteToken,omitempty"` + FeeLines []FeeLine `json:"feeLines,omitempty"` + NetworkFee *NetworkFee `json:"networkFee,omitempty"` + FxQuote *FxQuote `json:"fxQuote,omitempty"` +} + +type Payment struct { + PaymentRef string `json:"paymentRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + State string `json:"state,omitempty"` + FailureCode string `json:"failureCode,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + LastQuote *PaymentQuote `json:"lastQuote,omitempty"` +} + +type paymentQuoteResponse struct { + authResponse `json:",inline"` + Quote *PaymentQuote `json:"quote"` +} + +type paymentResponse struct { + authResponse `json:",inline"` + Payment *Payment `json:"payment"` +} + +// PaymentQuote wraps a payment quote with refreshed access token. +func PaymentQuoteResponse(logger mlogger.Logger, quote *orchestratorv1.PaymentQuote, token *TokenData) http.HandlerFunc { + return response.Ok(logger, paymentQuoteResponse{ + Quote: toPaymentQuote(quote), + authResponse: authResponse{AccessToken: *token}, + }) +} + +// Payment wraps a payment with refreshed access token. +func PaymentResponse(logger mlogger.Logger, payment *orchestratorv1.Payment, token *TokenData) http.HandlerFunc { + return response.Ok(logger, paymentResponse{ + Payment: toPayment(payment), + authResponse: authResponse{AccessToken: *token}, + }) +} + +func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine { + if len(lines) == 0 { + return nil + } + result := make([]FeeLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + continue + } + result = append(result, FeeLine{ + LedgerAccountRef: line.GetLedgerAccountRef(), + Amount: toMoney(line.GetMoney()), + LineType: line.GetLineType().String(), + Side: line.GetSide().String(), + Meta: line.GetMeta(), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func toNetworkFee(n *chainv1.EstimateTransferFeeResponse) *NetworkFee { + if n == nil { + return nil + } + return &NetworkFee{ + NetworkFee: toMoney(n.GetNetworkFee()), + EstimationContext: n.GetEstimationContext(), + } +} + +func toFxQuote(q *oraclev1.Quote) *FxQuote { + if q == nil { + return nil + } + pair := q.GetPair() + base := "" + quote := "" + if pair != nil { + base = pair.GetBase() + quote = pair.GetQuote() + } + return &FxQuote{ + QuoteRef: q.GetQuoteRef(), + BaseCurrency: base, + QuoteCurrency: quote, + Side: q.GetSide().String(), + Price: q.GetPrice().GetValue(), + BaseAmount: toMoney(q.GetBaseAmount()), + QuoteAmount: toMoney(q.GetQuoteAmount()), + ExpiresAtUnixMs: q.GetExpiresAtUnixMs(), + Provider: q.GetProvider(), + RateRef: q.GetRateRef(), + Firm: q.GetFirm(), + } +} + +func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote { + if q == nil { + return nil + } + return &PaymentQuote{ + QuoteRef: q.GetQuoteRef(), + DebitAmount: toMoney(q.GetDebitAmount()), + ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()), + ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()), + FeeQuoteToken: q.GetFeeQuoteToken(), + FeeLines: toFeeLines(q.GetFeeLines()), + NetworkFee: toNetworkFee(q.GetNetworkFee()), + FxQuote: toFxQuote(q.GetFxQuote()), + } +} + +func toPayment(p *orchestratorv1.Payment) *Payment { + if p == nil { + return nil + } + return &Payment{ + PaymentRef: p.GetPaymentRef(), + IdempotencyKey: p.GetIdempotencyKey(), + State: p.GetState().String(), + FailureCode: p.GetFailureCode().String(), + FailureReason: p.GetFailureReason(), + LastQuote: toPaymentQuote(p.GetLastQuote()), + } +} diff --git a/api/server/interface/api/sresponse/wallet.go b/api/server/interface/api/sresponse/wallet.go index 555b875..92b7431 100644 --- a/api/server/interface/api/sresponse/wallet.go +++ b/api/server/interface/api/sresponse/wallet.go @@ -6,7 +6,6 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/mlogger" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "google.golang.org/protobuf/types/known/timestamppb" @@ -36,16 +35,11 @@ type walletsResponse struct { Page *paginationv1.CursorPageResponse `json:"page,omitempty"` } -type walletMoney struct { - Amount string `json:"amount"` - Currency string `json:"currency"` -} - type walletBalance struct { - Available *walletMoney `json:"available,omitempty"` - PendingInbound *walletMoney `json:"pendingInbound,omitempty"` - PendingOutbound *walletMoney `json:"pendingOutbound,omitempty"` - CalculatedAt string `json:"calculatedAt,omitempty"` + Available *Money `json:"available,omitempty"` + PendingInbound *Money `json:"pendingInbound,omitempty"` + PendingOutbound *Money `json:"pendingOutbound,omitempty"` + CalculatedAt string `json:"calculatedAt,omitempty"` } type walletBalanceResponse struct { @@ -114,16 +108,6 @@ func toWalletBalance(b *chainv1.WalletBalance) walletBalance { } } -func toMoney(m *moneyv1.Money) *walletMoney { - if m == nil { - return nil - } - return &walletMoney{ - Amount: m.GetAmount(), - Currency: m.GetCurrency(), - } -} - func tsToString(ts *timestamppb.Timestamp) string { if ts == nil { return "" diff --git a/api/server/interface/services/payment/payment.go b/api/server/interface/services/payment/payment.go new file mode 100644 index 0000000..d1d7d1d --- /dev/null +++ b/api/server/interface/services/payment/payment.go @@ -0,0 +1,12 @@ +package payment + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/paymentapiimp" +) + +// Create wires payment orchestrator BFF API. +func Create(a api.API) (mservice.MicroService, error) { + return paymentapiimp.CreateAPI(a) +} diff --git a/api/server/internal/api/api.go b/api/server/internal/api/api.go index 52a2ab4..c8ee501 100644 --- a/api/server/internal/api/api.go +++ b/api/server/internal/api/api.go @@ -17,6 +17,7 @@ import ( "github.com/tech/sendico/server/interface/services/ledger" "github.com/tech/sendico/server/interface/services/logo" "github.com/tech/sendico/server/interface/services/organization" + "github.com/tech/sendico/server/interface/services/payment" "github.com/tech/sendico/server/interface/services/paymethod" "github.com/tech/sendico/server/interface/services/permission" "github.com/tech/sendico/server/interface/services/recipient" @@ -91,6 +92,7 @@ func (a *APIImp) installServices() error { srvf = append(srvf, ledger.Create) srvf = append(srvf, recipient.Create) srvf = append(srvf, paymethod.Create) + srvf = append(srvf, payment.Create) for _, v := range srvf { if err := a.addMicroservice(v); err != nil { diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go new file mode 100644 index 0000000..9ac128a --- /dev/null +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -0,0 +1,84 @@ +package paymentapiimp + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "github.com/tech/sendico/server/interface/api/srequest" + "github.com/tech/sendico/server/interface/api/sresponse" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +// shared initiation pipeline +func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc { + orgRef, err := a.oph.GetRef(r) + if err != nil { + a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r))) + return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + } + + ctx := r.Context() + allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate) + if err != nil { + a.logger.Warn("Failed to check payments access permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex())) + return response.Auto(a.logger, a.Name(), err) + } + if !allowed { + a.logger.Debug("Access denied when initiating payment", zap.String(a.oph.Name(), orgRef.Hex())) + return response.AccessDenied(a.logger, a.Name(), "payments write permission denied") + } + + payload, err := decodeInitiatePayload(r) + if err != nil { + return response.BadPayload(a.logger, a.Name(), err) + } + if expectQuote && strings.TrimSpace(payload.QuoteRef) == "" { + return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quote_ref is required")) + } + if !expectQuote { + payload.QuoteRef = "" + } + + req := &orchestratorv1.InitiatePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{ + OrganizationRef: orgRef.Hex(), + }, + IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), + Intent: payload.Intent, + FeeQuoteToken: strings.TrimSpace(payload.FeeQuoteToken), + FxQuoteRef: strings.TrimSpace(payload.FxQuoteRef), + QuoteRef: strings.TrimSpace(payload.QuoteRef), + Metadata: payload.Metadata, + } + + resp, err := a.client.InitiatePayment(ctx, req) + if err != nil { + a.logger.Warn("Failed to initiate payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) + return response.Auto(a.logger, a.Name(), err) + } + + return sresponse.PaymentResponse(a.logger, resp.GetPayment(), token) +} + +func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePaymentPayload, error) { + defer r.Body.Close() + + payload := &srequest.InitiatePaymentPayload{} + if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) + } + payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey) + if payload.IdempotencyKey == "" { + return nil, merrors.InvalidArgument("idempotencyKey is required") + } + if payload.Intent == nil { + return nil, merrors.InvalidArgument("intent is required") + } + return payload, nil +} diff --git a/api/server/internal/server/paymentapiimp/payimmediate.go b/api/server/internal/server/paymentapiimp/payimmediate.go new file mode 100644 index 0000000..d547153 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/payimmediate.go @@ -0,0 +1,13 @@ +package paymentapiimp + +import ( + "net/http" + + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/server/interface/api/sresponse" +) + +// initiateImmediate runs a one-shot payment using a fresh quote. +func (a *PaymentAPI) initiateImmediate(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { + return a.initiatePayment(r, account, token, false) +} diff --git a/api/server/internal/server/paymentapiimp/payquote.go b/api/server/internal/server/paymentapiimp/payquote.go new file mode 100644 index 0000000..0b96ed7 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/payquote.go @@ -0,0 +1,13 @@ +package paymentapiimp + +import ( + "net/http" + + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/server/interface/api/sresponse" +) + +// initiateByQuote executes a payment using a previously issued quote_ref. +func (a *PaymentAPI) initiateByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { + return a.initiatePayment(r, account, token, true) +} diff --git a/api/server/internal/server/paymentapiimp/quote.go b/api/server/internal/server/paymentapiimp/quote.go new file mode 100644 index 0000000..c14af3d --- /dev/null +++ b/api/server/internal/server/paymentapiimp/quote.go @@ -0,0 +1,74 @@ +package paymentapiimp + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "github.com/tech/sendico/server/interface/api/srequest" + "github.com/tech/sendico/server/interface/api/sresponse" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { + orgRef, err := a.oph.GetRef(r) + if err != nil { + a.logger.Warn("Failed to parse organization reference for quote", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r))) + return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + } + + ctx := r.Context() + allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate) + if err != nil { + a.logger.Warn("Failed to check payments access permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex())) + return response.Auto(a.logger, a.Name(), err) + } + if !allowed { + a.logger.Debug("Access denied when quoting payment", zap.String(a.oph.Name(), orgRef.Hex())) + return response.AccessDenied(a.logger, a.Name(), "payments write permission denied") + } + + payload, err := decodeQuotePayload(r) + if err != nil { + return response.BadPayload(a.logger, a.Name(), err) + } + + req := &orchestratorv1.QuotePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{ + OrganizationRef: orgRef.Hex(), + }, + IdempotencyKey: payload.IdempotencyKey, + Intent: payload.Intent, + PreviewOnly: payload.PreviewOnly, + } + + resp, err := a.client.QuotePayment(ctx, req) + if err != nil { + a.logger.Warn("Failed to quote payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) + return response.Auto(a.logger, a.Name(), err) + } + + return sresponse.PaymentQuoteResponse(a.logger, resp.GetQuote(), token) +} + +func decodeQuotePayload(r *http.Request) (*srequest.QuotePaymentPayload, error) { + defer r.Body.Close() + + payload := &srequest.QuotePaymentPayload{} + if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) + } + payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey) + if payload.IdempotencyKey == "" { + return nil, merrors.InvalidArgument("idempotencyKey is required") + } + if payload.Intent == nil { + return nil, merrors.InvalidArgument("intent is required") + } + return payload, nil +} diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go new file mode 100644 index 0000000..84748ed --- /dev/null +++ b/api/server/internal/server/paymentapiimp/service.go @@ -0,0 +1,102 @@ +package paymentapiimp + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + orchestratorclient "github.com/tech/sendico/payments/orchestrator/client" + api "github.com/tech/sendico/pkg/api/http" + "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + eapi "github.com/tech/sendico/server/interface/api" + mutil "github.com/tech/sendico/server/internal/mutil/param" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +type paymentClient interface { + QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) + InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) + Close() error +} + +type PaymentAPI struct { + logger mlogger.Logger + client paymentClient + enf auth.Enforcer + oph mutil.ParamHelper + + permissionRef primitive.ObjectID +} + +func (a *PaymentAPI) Name() mservice.Type { return mservice.PaymentOrchestrator } + +func (a *PaymentAPI) Finish(ctx context.Context) error { + if a.client != nil { + if err := a.client.Close(); err != nil { + a.logger.Warn("Failed to close payment orchestrator client", zap.Error(err)) + } + } + return nil +} + +func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { + p := &PaymentAPI{ + logger: apiCtx.Logger().Named(mservice.PaymentOrchestrator), + enf: apiCtx.Permissions().Enforcer(), + oph: mutil.CreatePH(mservice.Organizations), + } + + desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.PaymentOrchestrator) + if err != nil { + p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err)) + return nil, err + } + p.permissionRef = desc.ID + + if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator); err != nil { + p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err)) + return nil, err + } + + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/quote"), api.Post, p.quotePayment) + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate) + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote) + + return p, nil +} + +func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig) error { + if cfg == nil { + return merrors.InvalidArgument("payment orchestrator configuration is not provided") + } + + address := strings.TrimSpace(cfg.Address) + if address == "" { + address = strings.TrimSpace(os.Getenv(cfg.AddressEnv)) + } + if address == "" { + return merrors.InvalidArgument(fmt.Sprintf("payment orchestrator address is not specified and address env %s is empty", cfg.AddressEnv)) + } + + clientCfg := orchestratorclient.Config{ + Address: address, + DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second, + CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second, + Insecure: cfg.Insecure, + } + + client, err := orchestratorclient.New(context.Background(), clientCfg) + if err != nil { + return err + } + + a.client = client + return nil +}