9 Commits

Author SHA1 Message Date
a24ead2c36 Merge pull request 'quotation bff' (#46) from quote-#45 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #46
2025-12-09 15:30:46 +00:00
Stephan D
ce59cb1b26 quotation bff 2025-12-09 16:29:29 +01:00
cecaebfc5e Merge pull request 'Minor fixes for build to complete' (#44) from SEND001 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #44
2025-12-09 13:48:07 +00:00
e16f11d48a Merge branch 'main' into SEND001 2025-12-09 12:31:47 +00:00
Arseni
0804ad71f7 Minor fixes for build to complete 2025-12-09 15:30:46 +03:00
7a2f921de9 Merge pull request 'version bump + CBR fx ingestor' (#42) from cbr-#41 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #42
2025-12-08 18:54:03 +00:00
Stephan D
999f0684cb version bump + CBR fx ingestor 2025-12-08 19:52:03 +01:00
602b77ddc7 Merge pull request 'Navigation now flows entirely through go_router' (#35) from SEND001 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #35
2025-12-08 17:56:33 +00:00
Arseni
64ad8c8b38 Navigation now flows entirely through go_router 2025-12-08 17:40:25 +03:00
95 changed files with 3844 additions and 1373 deletions

View File

@@ -44,11 +44,11 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10
)

View File

@@ -176,35 +176,35 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.6.0/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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -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 &quoteCalculator{
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)
}

View File

@@ -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 &quoteCalculator{
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
}
}

View File

@@ -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
)

View File

@@ -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 = &copy
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
}

View File

@@ -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)

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -8,6 +8,9 @@ market:
- driver: COINGECKO
settings:
base_url: "https://api.coingecko.com/api/v3"
- driver: CBR
settings:
base_url: "https://www.cbr.ru"
pairs:
BINANCE:
- base: "USDT"
@@ -26,6 +29,15 @@ market:
- base: "USDT"
quote: "RUB"
symbol: "tether:rub"
CBR:
- base: "USD"
quote: "RUB"
symbol: "USD"
provider: "cbr"
- base: "EUR"
quote: "RUB"
symbol: "EUR"
provider: "cbr"
metrics:
enabled: true

View File

@@ -13,6 +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.48.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -44,11 +45,10 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect

View File

@@ -176,35 +176,35 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.6.0/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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -7,12 +7,12 @@ import (
"github.com/tech/sendico/fx/ingestor/internal/appversion"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/ingestor"
"github.com/tech/sendico/fx/ingestor/internal/metrics"
mongostorage "github.com/tech/sendico/fx/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
@@ -26,7 +26,7 @@ type App struct {
func New(logger mlogger.Logger, cfgPath string) (*App, error) {
if logger == nil {
return nil, fmerrors.New("app: logger is nil")
return nil, merrors.InvalidArgument("app: logger is nil")
}
path := strings.TrimSpace(cfgPath)
if path == "" {

View File

@@ -5,9 +5,9 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"gopkg.in/yaml.v3"
)
@@ -25,33 +25,33 @@ type Config struct {
func Load(path string) (*Config, error) {
if path == "" {
return nil, fmerrors.New("config: path is empty")
return nil, merrors.InvalidArgument("config: path is empty")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmerrors.Wrap("config: failed to read file", err)
return nil, merrors.InternalWrap(err, "config: failed to read file")
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
return nil, merrors.InternalWrap(err, "config: failed to parse yaml")
}
if len(cfg.Market.Sources) == 0 {
return nil, fmerrors.New("config: no market sources configured")
return nil, merrors.InvalidArgument("config: no market sources configured")
}
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
for idx := range cfg.Market.Sources {
src := &cfg.Market.Sources[idx]
if src.Driver.IsEmpty() {
return nil, fmerrors.New("config: market source driver is empty")
return nil, merrors.InvalidArgument("config: market source driver is empty")
}
sourceSet[src.Driver] = struct{}{}
}
if len(cfg.Market.Pairs) == 0 {
return nil, fmerrors.New("config: no pairs configured")
return nil, merrors.InvalidArgument("config: no pairs configured")
}
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
@@ -61,10 +61,10 @@ func Load(path string) (*Config, error) {
for rawSource, pairList := range cfg.Market.Pairs {
driver := mmodel.Driver(rawSource)
if driver.IsEmpty() {
return nil, fmerrors.New("config: pair source is empty")
return nil, merrors.InvalidArgument("config: pair source is empty")
}
if _, ok := sourceSet[driver]; !ok {
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
}
processed := make([]PairConfig, len(pairList))
@@ -74,7 +74,7 @@ func Load(path string) (*Config, error) {
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
pair.Symbol = strings.TrimSpace(pair.Symbol)
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
return nil, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
}
if strings.TrimSpace(pair.Provider) == "" {
pair.Provider = strings.ToLower(driver.String())
@@ -93,7 +93,7 @@ func Load(path string) (*Config, error) {
cfg.pairsBySource = pairsBySource
cfg.pairs = flattened
if cfg.Database == nil {
return nil, fmerrors.New("config: database configuration is required")
return nil, merrors.InvalidArgument("config: database configuration is required")
}
if cfg.Metrics != nil && cfg.Metrics.Enabled {

View File

@@ -1,35 +0,0 @@
package fmerrors
type Error struct {
message string
cause error
}
func (e *Error) Error() string {
if e == nil {
return ""
}
if e.cause == nil {
return e.message
}
return e.message + ": " + e.cause.Error()
}
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func New(message string) error {
return &Error{message: message}
}
func Wrap(message string, cause error) error {
return &Error{message: message, cause: cause}
}
func NewDecimal(value string) error {
return &Error{message: "invalid decimal \"" + value + "\""}
}

View File

@@ -6,11 +6,11 @@ import (
"time"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
@@ -26,18 +26,18 @@ type Service struct {
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
if logger == nil {
return nil, fmerrors.New("ingestor: nil logger")
return nil, merrors.InvalidArgument("ingestor: nil logger")
}
if cfg == nil {
return nil, fmerrors.New("ingestor: nil config")
return nil, merrors.InvalidArgument("ingestor: nil config")
}
if repo == nil {
return nil, fmerrors.New("ingestor: nil repository")
return nil, merrors.InvalidArgument("ingestor: nil repository")
}
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
if err != nil {
return nil, fmerrors.Wrap("build connectors", err)
return nil, merrors.InternalWrap(err, "build connectors")
}
return &Service{
@@ -110,21 +110,21 @@ func (s *Service) pollOnce(ctx context.Context) error {
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
connector, ok := s.connectors[pair.Source]
if !ok {
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
return merrors.InvalidArgument("connector not configured for source "+pair.Source.String(), "source")
}
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
if err != nil {
return fmerrors.Wrap("fetch ticker", err)
return merrors.InternalWrap(err, "fetch ticker")
}
bid, err := parseDecimal(ticker.BidPrice)
if err != nil {
return fmerrors.Wrap("parse bid price", err)
return merrors.InvalidArgumentWrap(err, "parse bid price", "bid")
}
ask, err := parseDecimal(ticker.AskPrice)
if err != nil {
return fmerrors.Wrap("parse ask price", err)
return merrors.InvalidArgumentWrap(err, "parse ask price", "ask")
}
if pair.Invert {
@@ -166,7 +166,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
}
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
return fmerrors.Wrap("upsert snapshot", err)
return merrors.InternalWrap(err, "upsert snapshot")
}
s.logger.Debug("Snapshot ingested",
@@ -183,7 +183,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
func parseDecimal(value string) (*big.Rat, error) {
r := new(big.Rat)
if _, ok := r.SetString(value); !ok {
return nil, fmerrors.NewDecimal(value)
return nil, merrors.InvalidArgument("invalid decimal \""+value+"\"", "value")
}
return r, nil
}

View File

@@ -7,10 +7,10 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
@@ -131,7 +131,7 @@ func TestServiceUpsertPairInvertsPrices(t *testing.T) {
}
func TestServicePollOnceReturnsFirstError(t *testing.T) {
errFetch := fmerrors.New("fetch failed")
errFetch := merrors.Internal("fetch failed")
connectorSuccess := &connectorStub{
id: mmarket.DriverBinance,
ticker: &mmarket.Ticker{

View File

@@ -10,9 +10,9 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/common"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
@@ -60,7 +60,7 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
parsed, err := url.Parse(baseURL)
if err != nil {
return nil, fmerrors.Wrap("binance: invalid base url", err)
return nil, merrors.InvalidArgumentWrap(err, "binance: invalid base url", "base_url")
}
transport := &http.Transport{
@@ -89,12 +89,12 @@ func (c *binanceConnector) ID() mmodel.Driver {
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
if strings.TrimSpace(symbol) == "" {
return nil, fmerrors.New("binance: symbol is empty")
return nil, merrors.InvalidArgument("binance: symbol is empty", "symbol")
}
endpoint, err := url.Parse(c.base)
if err != nil {
return nil, fmerrors.Wrap("binance: parse base url", err)
return nil, merrors.InternalWrap(err, "binance: parse base url")
}
endpoint.Path = "/api/v3/ticker/bookTicker"
query := endpoint.Query()
@@ -103,19 +103,19 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmerrors.Wrap("binance: build request", err)
return nil, merrors.InternalWrap(err, "binance: build request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("binance: request failed", err)
return nil, merrors.InternalWrap(err, "binance: request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
return nil, fmerrors.New("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
return nil, merrors.Internal("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
}
var payload struct {
@@ -126,7 +126,7 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("binance: decode response", err)
return nil, merrors.InternalWrap(err, "binance: decode response")
}
return &mmodel.Ticker{

View File

@@ -0,0 +1,537 @@
package cbr
import (
"context"
"encoding/xml"
"math/big"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/market/common"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
"golang.org/x/net/html/charset"
)
type cbrConnector struct {
id mmodel.Driver
provider string
client *http.Client
base string
dailyPath string
directoryPath string
dynamicPath string
logger mlogger.Logger
byISO map[string]valuteInfo
byID map[string]valuteInfo
}
const defaultCBRBaseURL = "https://www.cbr.ru"
const (
defaultDirectoryPath = "/scripts/XML_valFull.asp"
defaultDailyPath = "/scripts/XML_daily.asp"
defaultDynamicPath = "/scripts/XML_dynamic.asp"
)
const (
defaultDialTimeoutSeconds = 5 * time.Second
defaultDialKeepAliveSeconds = 30 * time.Second
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
defaultRequestTimeoutSeconds = 10 * time.Second
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
baseURL := defaultCBRBaseURL
provider := strings.ToLower(mmodel.DriverCBR.String())
dialTimeout := defaultDialTimeoutSeconds
dialKeepAlive := defaultDialKeepAliveSeconds
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
requestTimeout := defaultRequestTimeoutSeconds
directoryPath := defaultDirectoryPath
dailyPath := defaultDailyPath
dynamicPath := defaultDynamicPath
if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
baseURL = strings.TrimSpace(value)
}
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
provider = strings.TrimSpace(value)
}
if value, ok := settings["directory_path"].(string); ok && strings.TrimSpace(value) != "" {
directoryPath = strings.TrimSpace(value)
}
if value, ok := settings["daily_path"].(string); ok && strings.TrimSpace(value) != "" {
dailyPath = strings.TrimSpace(value)
}
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
dynamicPath = strings.TrimSpace(value)
}
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
}
parsed, err := url.Parse(baseURL)
if err != nil {
return nil, merrors.InvalidArgumentWrap(err, "cbr: invalid base url", "base_url")
}
var transport http.RoundTripper = &http.Transport{
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ResponseHeaderTimeout: responseHeaderTimeout,
}
if customTransport, ok := settings["http_round_tripper"].(http.RoundTripper); ok && customTransport != nil {
transport = customTransport
}
connector := &cbrConnector{
id: mmodel.DriverCBR,
provider: provider,
client: &http.Client{
Timeout: requestTimeout,
Transport: transport,
},
base: strings.TrimRight(parsed.String(), "/"),
dailyPath: dailyPath,
directoryPath: directoryPath,
dynamicPath: dynamicPath,
logger: logger.Named("cbr"),
}
if err := connector.refreshDirectory(); err != nil {
return nil, err
}
return connector, nil
}
func (c *cbrConnector) ID() mmodel.Driver {
return c.id
}
func (c *cbrConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
isoCode, asOfDate, err := parseSymbol(symbol)
if err != nil {
return nil, err
}
valute, ok := c.byISO[isoCode]
if !ok {
return nil, merrors.InvalidArgument("cbr: unknown currency "+isoCode, "symbol")
}
var price string
if asOfDate != nil {
price, err = c.fetchHistoricalRate(ctx, valute, *asOfDate)
} else {
price, err = c.fetchDailyRate(ctx, valute)
}
if err != nil {
return nil, err
}
now := time.Now().UnixMilli()
return &mmodel.Ticker{
Symbol: formatSymbol(isoCode, asOfDate),
BidPrice: price,
AskPrice: price,
Provider: c.provider,
Timestamp: now,
}, nil
}
func (c *cbrConnector) refreshDirectory() error {
endpoint, err := c.buildURL(c.directoryPath, nil)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return merrors.InternalWrap(err, "cbr: build directory request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CBR directory request failed", zap.Error(err))
return merrors.InternalWrap(err, "cbr: directory request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode))
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := xml.NewDecoder(resp.Body)
decoder.CharsetReader = charset.NewReaderLabel
var directory valuteDirectory
if err := decoder.Decode(&directory); err != nil {
c.logger.Warn("CBR directory decode failed", zap.Error(err))
return merrors.InternalWrap(err, "cbr: decode directory")
}
mapping, err := buildValuteMapping(directory.Items)
if err != nil {
return err
}
c.byISO = mapping.byISO
c.byID = mapping.byID
return nil
}
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
endpoint, err := c.buildURL(c.dailyPath, nil)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", merrors.InternalWrap(err, "cbr: build daily request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
return "", merrors.InternalWrap(err, "cbr: daily request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode))
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := xml.NewDecoder(resp.Body)
decoder.CharsetReader = charset.NewReaderLabel
var payload dailyRates
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR daily decode failed", zap.Error(err))
return "", merrors.InternalWrap(err, "cbr: decode daily response")
}
entry := payload.find(valute.ID)
if entry == nil {
return "", merrors.NoData("cbr: currency not found in daily rates: " + valute.ISOCharCode)
}
if err := validateDailyEntry(valute, entry); err != nil {
return "", err
}
return computePrice(entry.Value, entry.Nominal)
}
func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInfo, date time.Time) (string, error) {
query := map[string]string{
"date_req1": date.Format("02/01/2006"),
"date_req2": date.Format("02/01/2006"),
"VAL_NM_RQ": valute.ID,
}
endpoint, err := c.buildURL(c.dynamicPath, query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", merrors.InternalWrap(err, "cbr: build historical request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
return "", merrors.InternalWrap(err, "cbr: historical request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode))
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := xml.NewDecoder(resp.Body)
decoder.CharsetReader = charset.NewReaderLabel
var payload dynamicRates
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR historical decode failed", zap.Error(err))
return "", merrors.InternalWrap(err, "cbr: decode historical response")
}
record := payload.find(valute.ID, date)
if record == nil {
return "", merrors.NoData("cbr: historical rate not found for " + valute.ISOCharCode)
}
if record.Nominal != "" {
nominal, err := parseNominal(record.Nominal)
if err != nil {
return "", merrors.InvalidDataType(err.Error())
}
if nominal != valute.Nominal {
return "", merrors.Internal("cbr: historical nominal mismatch for " + valute.ISOCharCode)
}
}
return computePrice(record.Value, strconv.FormatInt(valute.Nominal, 10))
}
func (c *cbrConnector) buildURL(path string, query map[string]string) (string, error) {
base, err := url.Parse(c.base)
if err != nil {
return "", merrors.InternalWrap(err, "cbr: parse base url")
}
base.Path = strings.TrimRight(base.Path, "/") + path
q := base.Query()
for key, value := range query {
q.Set(key, value)
}
base.RawQuery = q.Encode()
return base.String(), nil
}
type valuteDirectory struct {
Items []valuteItem `xml:"Item"`
}
type valuteItem struct {
ID string `xml:"ID,attr"`
ISOChar string `xml:"ISO_Char_Code"`
ISONum string `xml:"ISO_Num_Code"`
Name string `xml:"Name"`
EngName string `xml:"EngName"`
NominalStr string `xml:"Nominal"`
}
type valuteInfo struct {
ID string
ISOCharCode string
ISONumCode string
Name string
EngName string
Nominal int64
}
type valuteMapping struct {
byISO map[string]valuteInfo
byID map[string]valuteInfo
}
func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items))
for _, item := range items {
id := strings.TrimSpace(item.ID)
isoChar := strings.ToUpper(strings.TrimSpace(item.ISOChar))
isoNum := strings.TrimSpace(item.ISONum)
name := strings.TrimSpace(item.Name)
engName := strings.TrimSpace(item.EngName)
nominal, err := parseNominal(item.NominalStr)
if err != nil {
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
}
if id == "" || isoChar == "" {
return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code")
}
info := valuteInfo{
ID: id,
ISOCharCode: isoChar,
ISONumCode: isoNum,
Name: name,
EngName: engName,
Nominal: nominal,
}
if existing, ok := byISO[isoChar]; ok && existing.ID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar)
}
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
}
if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
}
byNum[isoNum] = id
}
byISO[isoChar] = info
byID[id] = info
}
if len(byISO) == 0 {
return nil, merrors.InvalidDataType("cbr: empty directory received")
}
return &valuteMapping{
byISO: byISO,
byID: byID,
}, nil
}
type dailyRates struct {
Valutes []dailyValute `xml:"Valute"`
}
type dailyValute struct {
ID string `xml:"ID,attr"`
NumCode string `xml:"NumCode"`
CharCode string `xml:"CharCode"`
Nominal string `xml:"Nominal"`
Name string `xml:"Name"`
Value string `xml:"Value"`
}
func (d *dailyRates) find(id string) *dailyValute {
if d == nil {
return nil
}
for idx := range d.Valutes {
if strings.EqualFold(strings.TrimSpace(d.Valutes[idx].ID), id) {
return &d.Valutes[idx]
}
}
return nil
}
type dynamicRates struct {
Records []dynamicRecord `xml:"Record"`
}
type dynamicRecord struct {
ID string `xml:"Id,attr"`
DateRaw string `xml:"Date,attr"`
Nominal string `xml:"Nominal"`
Value string `xml:"Value"`
}
func (d *dynamicRates) find(id string, date time.Time) *dynamicRecord {
if d == nil {
return nil
}
target := date.Format("02.01.2006")
for idx := range d.Records {
rec := &d.Records[idx]
if !strings.EqualFold(strings.TrimSpace(rec.ID), id) {
continue
}
if strings.TrimSpace(rec.DateRaw) == target {
return rec
}
}
return nil
}
func validateDailyEntry(expected valuteInfo, entry *dailyValute) error {
if entry == nil {
return merrors.NoData("cbr: missing daily entry")
}
if !strings.EqualFold(strings.TrimSpace(entry.CharCode), expected.ISOCharCode) {
return merrors.Internal("cbr: char code mismatch for " + expected.ISOCharCode)
}
if expected.ISONumCode != "" && strings.TrimSpace(entry.NumCode) != expected.ISONumCode {
return merrors.Internal("cbr: iso numeric mismatch for " + expected.ISOCharCode)
}
if expected.Name != "" && strings.TrimSpace(entry.Name) != expected.Name {
return merrors.Internal("cbr: currency name mismatch for " + expected.ISOCharCode)
}
nominal, err := parseNominal(entry.Nominal)
if err != nil {
return merrors.InvalidDataType("cbr: parse daily nominal: " + err.Error())
}
if nominal != expected.Nominal {
return merrors.Internal("cbr: nominal mismatch for " + expected.ISOCharCode)
}
return nil
}
func parseSymbol(symbol string) (string, *time.Time, error) {
trimmed := strings.TrimSpace(symbol)
if trimmed == "" {
return "", nil, merrors.InvalidArgument("cbr: symbol is empty", "symbol")
}
parts := strings.Split(trimmed, "@")
if len(parts) > 2 {
return "", nil, merrors.InvalidArgument("cbr: invalid symbol format", "symbol")
}
iso := strings.ToUpper(strings.TrimSpace(parts[0]))
if len(iso) != 3 {
return "", nil, merrors.InvalidArgument("cbr: symbol must be ISO currency code", "symbol")
}
if len(parts) == 1 {
return iso, nil, nil
}
datePart := strings.TrimSpace(parts[1])
if datePart == "" {
return "", nil, merrors.InvalidArgument("cbr: date component is empty", "symbol")
}
parsed, err := time.Parse("2006-01-02", datePart)
if err != nil {
return "", nil, merrors.InvalidArgumentWrap(err, "cbr: invalid date component", "symbol")
}
return iso, &parsed, nil
}
func parseNominal(value string) (int64, error) {
nominal, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || nominal <= 0 {
return 0, merrors.InvalidDataType("cbr: invalid nominal \"" + value + "\"")
}
return nominal, nil
}
func computePrice(value string, nominalStr string) (string, error) {
raw := strings.ReplaceAll(strings.TrimSpace(value), " ", "")
raw = strings.ReplaceAll(raw, ",", ".")
r := new(big.Rat)
if _, ok := r.SetString(raw); !ok {
return "", merrors.InvalidDataType("invalid decimal \"" + value + "\"")
}
nominal, err := parseNominal(nominalStr)
if err != nil {
return "", err
}
den := big.NewRat(nominal, 1)
price := new(big.Rat).Quo(r, den)
return price.FloatString(8), nil
}
func formatSymbol(iso string, asOf *time.Time) string {
if asOf == nil {
return iso
}
return iso + "@" + asOf.Format("2006-01-02")
}

View File

@@ -0,0 +1,226 @@
package cbr
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
func TestFetchTickerDaily(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_daily.asp": {body: dailyRatesXML},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"http_round_tripper": transport,
"request_timeout_seconds": 1,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
ticker, err := conn.FetchTicker(context.Background(), "USD")
if err != nil {
t.Fatalf("FetchTicker returned error: %v", err)
}
if ticker.Provider != "cbr" {
t.Fatalf("unexpected provider: %s", ticker.Provider)
}
if ticker.BidPrice != "95.12340000" || ticker.AskPrice != "95.12340000" {
t.Fatalf("unexpected bid/ask: %s / %s", ticker.BidPrice, ticker.AskPrice)
}
if ticker.Symbol != "USD" {
t.Fatalf("unexpected symbol: %s", ticker.Symbol)
}
}
func TestFetchTickerValidatesDailyEntry(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_daily.asp": {body: strings.ReplaceAll(dailyRatesXML, "<CharCode>USD</CharCode>", "<CharCode>XXX</CharCode>")},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"http_round_tripper": transport,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
if _, err := conn.FetchTicker(context.Background(), "USD"); err == nil {
t.Fatalf("FetchTicker expected to fail due to mismatch")
}
}
func TestFetchTickerHistorical(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_dynamic.asp": {
body: dynamicRatesXML,
check: func(r *http.Request) error {
if got := r.URL.Query().Get("VAL_NM_RQ"); got != "R01235" {
return fmt.Errorf("unexpected valute id: %s", got)
}
if got := r.URL.Query().Get("date_req1"); got != "05/01/2023" {
return fmt.Errorf("unexpected date_req1: %s", got)
}
if got := r.URL.Query().Get("date_req2"); got != "05/01/2023" {
return fmt.Errorf("unexpected date_req2: %s", got)
}
return nil
},
},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"http_round_tripper": transport,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
ticker, err := conn.FetchTicker(context.Background(), "USD@2023-01-05")
if err != nil {
t.Fatalf("FetchTicker returned error: %v", err)
}
if ticker.BidPrice != "70.10000000" || ticker.AskPrice != "70.10000000" {
t.Fatalf("unexpected bid/ask: %s / %s", ticker.BidPrice, ticker.AskPrice)
}
if ticker.Symbol != "USD@2023-01-05" {
t.Fatalf("unexpected symbol: %s", ticker.Symbol)
}
}
func TestFetchTickerUnknownCurrency(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_daily.asp": {body: dailyRatesXML},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"http_round_tripper": transport,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
_, err = conn.FetchTicker(context.Background(), "ZZZ")
if err == nil {
t.Fatalf("FetchTicker expected to fail for unknown currency")
}
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error, got %v", err)
}
}
func TestFetchTickerRespectsCustomPaths(t *testing.T) {
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/dir.xml": {body: valuteDirectoryXML},
"/rates.xml": {body: dailyRatesXML},
},
}
conn, err := NewConnector(zap.NewNop(), map[string]any{
"base_url": "http://cbr.test",
"directory_path": "/dir.xml",
"daily_path": "/rates.xml",
"http_round_tripper": transport,
})
if err != nil {
t.Fatalf("NewConnector returned error: %v", err)
}
if _, err := conn.FetchTicker(context.Background(), "USD"); err != nil {
t.Fatalf("FetchTicker returned error with custom paths: %v", err)
}
}
const valuteDirectoryXML = `
<Valuta name="Foreign Currency Market">
<Item ID="R01235">
<ISO_Num_Code>840</ISO_Num_Code>
<ISO_Char_Code>USD</ISO_Char_Code>
<Nominal>1</Nominal>
<Name>US Dollar</Name>
<EngName>US Dollar</EngName>
</Item>
</Valuta>`
const dailyRatesXML = `
<ValCurs Date="02.09.2024" name="Foreign Currency Market">
<Valute ID="R01235">
<NumCode>840</NumCode>
<CharCode>USD</CharCode>
<Nominal>1</Nominal>
<Name>US Dollar</Name>
<Value>95,1234</Value>
</Valute>
</ValCurs>`
const dynamicRatesXML = `
<ValCurs ID="R01235" DateRange1="05/01/2023" DateRange2="05/01/2023" name="Foreign Currency Market Dynamic">
<Record Date="05.01.2023" Id="R01235">
<Nominal>1</Nominal>
<Value>70,1</Value>
</Record>
</ValCurs>`
type stubResponse struct {
status int
body string
check func(*http.Request) error
}
type stubRoundTripper struct {
responses map[string]stubResponse
}
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if s.responses == nil {
return nil, fmt.Errorf("no responses configured")
}
res, ok := s.responses[req.URL.Path]
if !ok {
return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path)
}
if res.check != nil {
if err := res.check(req); err != nil {
return nil, err
}
}
status := res.status
if status == 0 {
status = http.StatusOK
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(res.body)),
Header: http.Header{"Content-Type": []string{"text/xml"}},
Request: req,
}, nil
}

View File

@@ -10,9 +10,9 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/common"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
@@ -61,7 +61,7 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
parsed, err := url.Parse(baseURL)
if err != nil {
return nil, fmerrors.Wrap("coingecko: invalid base url", err)
return nil, merrors.InvalidArgumentWrap(err, "coingecko: invalid base url", "base_url")
}
transport := &http.Transport{
@@ -96,7 +96,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
endpoint, err := url.Parse(c.base)
if err != nil {
return nil, fmerrors.Wrap("coingecko: parse base url", err)
return nil, merrors.InternalWrap(err, "coingecko: parse base url")
}
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
query := endpoint.Query()
@@ -107,19 +107,19 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmerrors.Wrap("coingecko: build request", err)
return nil, merrors.InternalWrap(err, "coingecko: build request")
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("coingecko: request failed", err)
return nil, merrors.InternalWrap(err, "coingecko: request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
return nil, fmerrors.New("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
return nil, merrors.Internal("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := json.NewDecoder(resp.Body)
@@ -128,21 +128,21 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
var payload map[string]map[string]interface{}
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("coingecko: decode response", err)
return nil, merrors.InternalWrap(err, "coingecko: decode response")
}
coinData, ok := payload[coinID]
if !ok {
return nil, fmerrors.New("coingecko: coin id not found in response")
return nil, merrors.Internal("coingecko: coin id not found in response")
}
priceValue, ok := coinData[vsCurrency]
if !ok {
return nil, fmerrors.New("coingecko: vs currency not found in response")
return nil, merrors.Internal("coingecko: vs currency not found in response")
}
price, ok := toFloat(priceValue)
if !ok || price <= 0 {
return nil, fmerrors.New("coingecko: invalid price value in response")
return nil, merrors.Internal("coingecko: invalid price value in response")
}
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
@@ -171,7 +171,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
func parseSymbol(symbol string) (string, string, error) {
trimmed := strings.TrimSpace(symbol)
if trimmed == "" {
return "", "", fmerrors.New("coingecko: symbol is empty")
return "", "", merrors.InvalidArgument("coingecko: symbol is empty", "symbol")
}
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
@@ -183,13 +183,13 @@ func parseSymbol(symbol string) (string, string, error) {
})
if len(parts) != 2 {
return "", "", fmerrors.New("coingecko: symbol must be <coin_id>/<vs_currency>")
return "", "", merrors.InvalidArgument("coingecko: symbol must be <coin_id>/<vs_currency>", "symbol")
}
coinID := strings.TrimSpace(parts[0])
vsCurrency := strings.TrimSpace(parts[1])
if coinID == "" || vsCurrency == "" {
return "", "", fmerrors.New("coingecko: symbol contains empty segments")
return "", "", merrors.InvalidArgument("coingecko: symbol contains empty segments", "symbol")
}
return coinID, vsCurrency, nil

View File

@@ -5,10 +5,11 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
"github.com/tech/sendico/fx/ingestor/internal/market/cbr"
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
@@ -21,7 +22,7 @@ func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.
for _, cfg := range configs {
driver := mmodel.NormalizeDriver(cfg.Driver)
if driver.IsEmpty() {
return nil, fmerrors.New("market: connector driver is empty")
return nil, merrors.InvalidArgument("market: connector driver is empty", "driver")
}
var (
@@ -34,12 +35,14 @@ func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.
conn, err = binance.NewConnector(logger, cfg.Settings)
case mmodel.DriverCoinGecko:
conn, err = coingecko.NewConnector(logger, cfg.Settings)
case mmodel.DriverCBR:
conn, err = cbr.NewConnector(logger, cfg.Settings)
default:
err = fmerrors.New("market: unsupported driver " + driver.String())
err = merrors.InvalidArgument("market: unsupported driver "+driver.String(), "driver")
}
if err != nil {
return nil, fmerrors.Wrap("market: build connector "+driver.String(), err)
return nil, merrors.InternalWrap(err, "market: build connector "+driver.String())
}
connectors[driver] = conn
}

View File

@@ -8,12 +8,12 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
)
@@ -30,7 +30,7 @@ type Server interface {
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
if logger == nil {
return nil, fmerrors.New("metrics: logger is nil")
return nil, merrors.InvalidArgument("metrics: logger is nil")
}
if cfg == nil || !cfg.Enabled {
logger.Debug("Metrics disabled; using noop server")

View File

@@ -10,6 +10,7 @@ type Driver string
const (
DriverBinance Driver = "BINANCE"
DriverCoinGecko Driver = "COINGECKO"
DriverCBR Driver = "CBR"
)
func (d Driver) String() string {

View File

@@ -45,10 +45,10 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

View File

@@ -176,35 +176,35 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.6.0/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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -25,8 +25,8 @@ 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/sync v0.18.0 // indirect
golang.org/x/text v0.31.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
)

View File

@@ -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=
@@ -147,23 +147,23 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -22,7 +22,7 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
@@ -79,12 +79,12 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7 h1:uups37roJCTtR/BrJa0WoMrxt3rzgV+Qrj+TxYyJoAo=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -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,12 +329,12 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -343,16 +343,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -45,10 +45,10 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

View File

@@ -178,35 +178,35 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.6.0/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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -46,10 +46,10 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

View File

@@ -178,35 +178,35 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.6.0/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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -14,7 +14,7 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
golang.org/x/text v0.31.0
golang.org/x/text v0.32.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -48,10 +48,10 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.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
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect

View File

@@ -191,35 +191,35 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.6.0/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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -54,10 +54,10 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

View File

@@ -179,35 +179,35 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.6.0/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=
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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -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),
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

@@ -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.

View File

@@ -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"
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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,10 +88,10 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -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,15 +216,15 @@ 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=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -240,18 +240,18 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/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/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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -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<string, string> metadata = 6;
string quote_ref = 7;
}
message InitiatePaymentResponse {

View File

@@ -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:

View File

@@ -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,10 +134,10 @@ 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
)

View File

@@ -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,15 +304,15 @@ 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=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -328,18 +328,18 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/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/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=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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(),
}
}

View File

@@ -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()),
}
}

View File

@@ -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 ""

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/widgets.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
@@ -17,6 +19,9 @@ class PayoutRoutes {
static const editWallet = 'payout-edit-wallet';
static const walletTopUp = 'payout-wallet-top-up';
static const paymentTypeQuery = 'paymentType';
static const returnToQuery = 'returnTo';
static const dashboardPath = '/dashboard';
static const recipientsPath = '/dashboard/recipients';
static const addRecipientPath = '/dashboard/recipients/add';
@@ -103,10 +108,70 @@ class PayoutRoutes {
return null;
}
}
static Map<String, String> buildQueryParameters({
PaymentType? paymentType,
PayoutDestination? returnTo,
}) {
final params = <String, String>{
if (paymentType != null) paymentTypeQuery: paymentType.name,
if (returnTo != null) returnToQuery: nameFor(returnTo),
};
return params;
}
static PaymentType? paymentTypeFromState(GoRouterState state) =>
paymentTypeFromRaw(state.uri.queryParameters[paymentTypeQuery]);
static PaymentType? paymentTypeFromRaw(String? raw) => raw == null
? null
: PaymentType.values.firstWhereOrNull((type) => type.name == raw);
static PayoutDestination fallbackFromState(
GoRouterState state, {
PayoutDestination defaultDestination = PayoutDestination.dashboard,
}) {
final raw = state.uri.queryParameters[returnToQuery];
return destinationFor(raw) ?? defaultDestination;
}
}
extension PayoutNavigation on BuildContext {
void goToPayout(PayoutDestination destination) => goNamed(PayoutRoutes.nameFor(destination));
void pushToPayout(PayoutDestination destination) => pushNamed(PayoutRoutes.nameFor(destination));
}
void goToPayment({
PaymentType? paymentType,
PayoutDestination? returnTo,
}) =>
goNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: paymentType,
returnTo: returnTo,
),
);
void pushToPayment({
PaymentType? paymentType,
PayoutDestination? returnTo,
}) =>
pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: paymentType,
returnTo: returnTo,
),
);
void pushToWalletTopUp({PayoutDestination? returnTo}) => pushNamed(
PayoutRoutes.walletTopUp,
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
);
void pushToEditWallet({PayoutDestination? returnTo}) => pushNamed(
PayoutRoutes.editWallet,
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
);
}

View File

@@ -4,10 +4,13 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/pages/address_book/form/page.dart';
import 'package:pweb/pages/address_book/page/page.dart';
import 'package:pweb/pages/dashboard/dashboard.dart';
@@ -17,7 +20,7 @@ import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
import 'package:pweb/pages/report/page.dart';
import 'package:pweb/pages/settings/profile/page.dart';
import 'package:pweb/pages/wallet_top_up/page.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/sidebar/page.dart';
@@ -36,15 +39,22 @@ RouteBase payoutShellRoute() => ShellRoute(
path: routerPage(Pages.dashboard),
pageBuilder: (context, _) => NoTransitionPage(
child: DashboardPage(
onRecipientSelected: (recipient) => context
.read<PageSelectorProvider>()
.selectRecipient(context, recipient),
onGoToPaymentWithoutRecipient: (type) => context
.read<PageSelectorProvider>()
.startPaymentWithoutRecipient(context, type),
onTopUp: (wallet) => context
.read<PageSelectorProvider>()
.openWalletTopUp(context, wallet),
onRecipientSelected: (recipient) => _startPayment(
context,
recipient: recipient,
returnTo: PayoutDestination.dashboard,
),
onGoToPaymentWithoutRecipient: (type) => _startPayment(
context,
recipient: null,
paymentType: type,
returnTo: PayoutDestination.dashboard,
),
onTopUp: (wallet) => _openWalletTopUp(
context,
wallet,
returnTo: PayoutDestination.dashboard,
),
),
),
),
@@ -55,15 +65,16 @@ RouteBase payoutShellRoute() => ShellRoute(
final loc = AppLocalizations.of(context)!;
return NoTransitionPage(
child: RecipientAddressBookPage(
onRecipientSelected: (recipient) => context
.read<PageSelectorProvider>()
.selectRecipient(context, recipient, fromList: true),
onAddRecipient: () => context
.read<PageSelectorProvider>()
.goToAddRecipient(context),
onEditRecipient: (recipient) => context
.read<PageSelectorProvider>()
.editRecipient(context, recipient, fromList: true),
onRecipientSelected: (recipient) => _startPayment(
context,
recipient: recipient,
returnTo: PayoutDestination.recipients,
),
onAddRecipient: () => _openAddRecipient(context),
onEditRecipient: (recipient) => _openAddRecipient(
context,
recipient: recipient,
),
onDeleteRecipient: (recipient) => executeActionWithNotification(
context: context,
action: () async =>
@@ -79,15 +90,11 @@ RouteBase payoutShellRoute() => ShellRoute(
name: PayoutRoutes.addRecipient,
path: PayoutRoutes.addRecipientPath,
pageBuilder: (context, _) {
final selector = context.read<PageSelectorProvider>();
final recipient = selector.recipientProvider.currentObject;
final recipient = context.read<RecipientsProvider>().currentObject;
return NoTransitionPage(
child: AdressBookRecipientForm(
recipient: recipient,
onSaved: (_) => selector.selectPage(
context,
PayoutDestination.recipients,
),
onSaved: (_) => context.goToPayout(PayoutDestination.recipients),
),
);
},
@@ -95,13 +102,20 @@ RouteBase payoutShellRoute() => ShellRoute(
GoRoute(
name: PayoutRoutes.payment,
path: PayoutRoutes.paymentPath,
pageBuilder: (context, _) => NoTransitionPage(
child: PaymentPage(
onBack: (_) => context
.read<PageSelectorProvider>()
.goBackFromPayment(context),
),
),
pageBuilder: (context, state) {
final fallbackDestination = PayoutRoutes.fallbackFromState(
state,
defaultDestination: PayoutDestination.dashboard,
);
return NoTransitionPage(
child: PaymentPage(
onBack: (_) => _popOrGo(context, fallbackDestination),
initialPaymentType: PayoutRoutes.paymentTypeFromState(state),
fallbackDestination: fallbackDestination,
),
);
},
),
GoRoute(
name: PayoutRoutes.settings,
@@ -122,24 +136,30 @@ RouteBase payoutShellRoute() => ShellRoute(
path: PayoutRoutes.methodsPath,
pageBuilder: (context, _) => NoTransitionPage(
child: PaymentConfigPage(
onWalletTap: (wallet) => context
.read<PageSelectorProvider>()
.selectWallet(context, wallet),
onWalletTap: (wallet) => _openWalletEdit(
context,
wallet,
returnTo: PayoutDestination.methods,
),
),
),
),
GoRoute(
name: PayoutRoutes.editWallet,
path: PayoutRoutes.editWalletPath,
pageBuilder: (context, _) {
final provider = context.read<PageSelectorProvider>();
final wallet = provider.walletsProvider.selectedWallet;
pageBuilder: (context, state) {
final walletsProvider = context.read<WalletsProvider>();
final wallet = walletsProvider.selectedWallet;
final loc = AppLocalizations.of(context)!;
final fallbackDestination = PayoutRoutes.fallbackFromState(
state,
defaultDestination: PayoutDestination.methods,
);
return NoTransitionPage(
child: wallet != null
? WalletEditPage(
onBack: () => provider.goBackFromWalletEdit(context),
onBack: () => _popOrGo(context, fallbackDestination),
)
: Center(child: Text(loc.noWalletSelected)),
);
@@ -148,13 +168,65 @@ RouteBase payoutShellRoute() => ShellRoute(
GoRoute(
name: PayoutRoutes.walletTopUp,
path: PayoutRoutes.walletTopUpPath,
pageBuilder: (context, _) => NoTransitionPage(
child: WalletTopUpPage(
onBack: () => context
.read<PageSelectorProvider>()
.goBackFromWalletTopUp(context),
),
),
pageBuilder: (context, state) {
final fallbackDestination = PayoutRoutes.fallbackFromState(
state,
defaultDestination: PayoutDestination.dashboard,
);
return NoTransitionPage(
child: WalletTopUpPage(
onBack: () => _popOrGo(context, fallbackDestination),
),
);
},
),
],
);
void _startPayment(
BuildContext context, {
Recipient? recipient,
PaymentType? paymentType,
required PayoutDestination returnTo,
}) {
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
context.pushToPayment(
paymentType: paymentType,
returnTo: returnTo,
);
}
void _openAddRecipient(
BuildContext context, {
Recipient? recipient,
}) {
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
context.pushNamed(PayoutRoutes.addRecipient);
}
void _openWalletEdit(
BuildContext context,
Wallet wallet, {
required PayoutDestination returnTo,
}) {
context.read<WalletsProvider>().selectWallet(wallet);
context.pushToEditWallet(returnTo: returnTo);
}
void _openWalletTopUp(
BuildContext context,
Wallet wallet, {
required PayoutDestination returnTo,
}) {
context.read<WalletsProvider>().selectWallet(wallet);
context.pushToWalletTopUp(returnTo: returnTo);
}
void _popOrGo(BuildContext context, PayoutDestination destination) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.goToPayout(destination);
}
}

View File

@@ -20,7 +20,6 @@ import 'package:pweb/app/timeago.dart';
import 'package:pweb/providers/carousel.dart';
import 'package:pweb/providers/mock_payment.dart';
import 'package:pweb/providers/operatioins.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/providers/upload_history.dart';
import 'package:pweb/providers/wallets.dart';
@@ -94,12 +93,6 @@ void main() async {
create: (_) => MockPaymentProvider(),
),
ChangeNotifierProxyProvider3<RecipientsProvider, WalletsProvider, PaymentMethodsProvider, PageSelectorProvider>(
create: (context) => PageSelectorProvider(),
update: (context, recipientProv, walletsProv, methodsProv, previous) =>
previous ?? PageSelectorProvider()..update(recipientProv, walletsProv, methodsProv),
),
ChangeNotifierProvider(
create: (_) => OperationProvider(OperationService())..loadOperations(),
),

View File

@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Future<bool> showDeleteConfirmationDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(l10n.delete),
content: Text(l10n.deletePaymentConfirmation),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.delete),
),
],
),
) ?? false;
}

View File

@@ -1,19 +1,34 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/providers/payment_flow_provider.dart';
import 'package:pweb/pages/payment_methods/widgets/payment_page_body.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/providers/payment_flow.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
class PaymentPage extends StatefulWidget {
final ValueChanged<Recipient?>? onBack;
final PaymentType? initialPaymentType;
final PayoutDestination fallbackDestination;
const PaymentPage({super.key, this.onBack});
const PaymentPage({
super.key,
this.onBack,
this.initialPaymentType,
this.fallbackDestination = PayoutDestination.dashboard,
});
@override
State<PaymentPage> createState() => _PaymentPageState();
@@ -29,9 +44,8 @@ class _PaymentPageState extends State<PaymentPage> {
super.initState();
_searchController = TextEditingController();
_searchFocusNode = FocusNode();
final pageSelector = context.read<PageSelectorProvider>();
_flowProvider = PaymentFlowProvider(
initialType: pageSelector.getDefaultPaymentType(),
initialType: widget.initialPaymentType ?? PaymentType.bankAccount,
);
WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage());
@@ -46,11 +60,15 @@ class _PaymentPageState extends State<PaymentPage> {
}
void _initializePaymentPage() {
final pageSelector = context.read<PageSelectorProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>();
_handleWalletAutoSelection(methodsProvider);
pageSelector.handleWalletAutoSelection();
_flowProvider.syncWithSelector(pageSelector);
final recipient = context.read<RecipientsProvider>().currentObject;
_syncFlowProvider(
recipient: recipient,
methodsProvider: methodsProvider,
preferredType: widget.initialPaymentType,
);
}
void _handleSearchChanged(String query) {
@@ -58,22 +76,28 @@ class _PaymentPageState extends State<PaymentPage> {
}
void _handleRecipientSelected(Recipient recipient) {
final pageSelector = context.read<PageSelectorProvider>();
final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>();
recipientProvider.setCurrentObject(recipient.id);
pageSelector.selectRecipient(context, recipient);
_flowProvider.reset(pageSelector);
_flowProvider.reset(
recipient: recipient,
availableTypes: _availablePaymentTypes(recipient, methodsProvider),
preferredType: widget.initialPaymentType,
);
_clearSearchField();
}
void _handleRecipientCleared() {
final pageSelector = context.read<PageSelectorProvider>();
final recipientProvider = context.read<RecipientsProvider>();
final methodsProvider = context.read<PaymentMethodsProvider>();
recipientProvider.setCurrentObject(null);
pageSelector.selectRecipient(context, null);
_flowProvider.reset(pageSelector);
_flowProvider.reset(
recipient: null,
availableTypes: _availablePaymentTypes(null, methodsProvider),
preferredType: widget.initialPaymentType,
);
_clearSearchField();
}
@@ -90,13 +114,26 @@ class _PaymentPageState extends State<PaymentPage> {
@override
Widget build(BuildContext context) {
final pageSelector = context.watch<PageSelectorProvider>();
_flowProvider.syncWithSelector(pageSelector);
final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.watch<RecipientsProvider>();
final recipient = recipientProvider.currentObject;
final availableTypes = _availablePaymentTypes(recipient, methodsProvider);
_syncFlowProvider(
recipient: recipient,
methodsProvider: methodsProvider,
preferredType: recipient != null ? widget.initialPaymentType : null,
);
return ChangeNotifierProvider.value(
value: _flowProvider,
child: PaymentPageBody(
onBack: widget.onBack,
fallbackDestination: widget.fallbackDestination,
recipient: recipient,
recipientProvider: recipientProvider,
methodsProvider: methodsProvider,
availablePaymentTypes: availableTypes,
searchController: _searchController,
searchFocusNode: _searchFocusNode,
onSearchChanged: _handleSearchChanged,
@@ -106,4 +143,56 @@ class _PaymentPageState extends State<PaymentPage> {
),
);
}
}
void _handleWalletAutoSelection(PaymentMethodsProvider methodsProvider) {
final wallet = context.read<WalletsProvider>().selectedWallet;
if (wallet == null) return;
final matchingMethod = _getPaymentMethodForWallet(wallet, methodsProvider);
if (matchingMethod != null) {
methodsProvider.setCurrentObject(matchingMethod.id);
}
}
void _syncFlowProvider({
required Recipient? recipient,
required PaymentMethodsProvider methodsProvider,
PaymentType? preferredType,
}) {
_flowProvider.sync(
recipient: recipient,
availableTypes: _availablePaymentTypes(recipient, methodsProvider),
preferredType: preferredType,
);
}
MethodMap _availablePaymentTypes(
Recipient? recipient,
PaymentMethodsProvider methodsProvider,
) {
if (recipient == null || !methodsProvider.isReady) return {};
final methodsForRecipient = methodsProvider.methods.where(
(method) => !method.isArchived && method.recipientRef == recipient.id,
);
return {
for (final method in methodsForRecipient) method.type: method.data,
};
}
PaymentMethod? _getPaymentMethodForWallet(
Wallet wallet,
PaymentMethodsProvider methodsProvider,
) {
if (methodsProvider.methods.isEmpty) {
return null;
}
return methodsProvider.methods.firstWhereOrNull(
(method) =>
method.type == PaymentType.wallet &&
(method.description?.contains(wallet.walletUserID) ?? false),
);
}
}

View File

@@ -1,33 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
class PaymentDetailsSection extends StatelessWidget {
final bool isFormVisible;
final VoidCallback? onToggle;
final PaymentType selectedType;
final Object? data;
final bool isEditable;
const PaymentDetailsSection({
super.key,
required this.isFormVisible,
this.onToggle,
required this.selectedType,
required this.data,
required this.isEditable,
});
@override
Widget build(BuildContext context) {
return PaymentDetailsSection(
isFormVisible: isFormVisible,
onToggle: onToggle,
selectedType: selectedType,
data: data,
isEditable: isEditable,
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
class PaymentBackButton extends StatelessWidget {
final ValueChanged<Recipient?>? onBack;
final Recipient? recipient;
final PayoutDestination fallbackDestination;
const PaymentBackButton({
super.key,
required this.onBack,
required this.recipient,
required this.fallbackDestination,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (onBack != null) {
onBack!(recipient);
} else {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.goToPayout(fallbackDestination);
}
}
},
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/pages/payment_methods/widgets/state_view.dart';
import 'package:pweb/pages/payment_methods/payment_page/page.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageBody extends StatelessWidget {
final ValueChanged<Recipient?>? onBack;
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final ValueChanged<String> onSearchChanged;
final ValueChanged<Recipient> onRecipientSelected;
final VoidCallback onRecipientCleared;
final VoidCallback onSend;
const PaymentPageBody({
super.key,
required this.onBack,
required this.recipient,
required this.recipientProvider,
required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
required this.onSearchChanged,
required this.onRecipientSelected,
required this.onRecipientCleared,
required this.onSend,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
if (methodsProvider.isLoading) {
return const PaymentMethodsLoadingView();
}
if (methodsProvider.error != null) {
return PaymentMethodsErrorView(
message: loc.notificationError(methodsProvider.error ?? loc.noErrorInformation),
);
}
return PaymentPageContent(
onBack: onBack,
recipient: recipient,
recipientProvider: recipientProvider,
methodsProvider: methodsProvider,
availablePaymentTypes: availablePaymentTypes,
fallbackDestination: fallbackDestination,
searchController: searchController,
searchFocusNode: searchFocusNode,
onSearchChanged: onSearchChanged,
onRecipientSelected: onRecipientSelected,
onRecipientCleared: onRecipientCleared,
onSend: onSend,
);
}
}

View File

@@ -2,26 +2,33 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/payment_methods/header.dart';
import 'package:pweb/pages/payment_methods/method_selector.dart';
import 'package:pweb/pages/payment_methods/send_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
import 'package:pweb/pages/dashboard/payouts/payment_form.dart';
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/providers/payment_flow_provider.dart';
import 'package:pweb/providers/payment_flow.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageBody extends StatelessWidget {
class PaymentPageContent extends StatelessWidget {
final ValueChanged<Recipient?>? onBack;
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final ValueChanged<String> onSearchChanged;
@@ -29,9 +36,14 @@ class PaymentPageBody extends StatelessWidget {
final VoidCallback onRecipientCleared;
final VoidCallback onSend;
const PaymentPageBody({
const PaymentPageContent({
super.key,
required this.onBack,
required this.recipient,
required this.recipientProvider,
required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
required this.onSearchChanged,
@@ -43,21 +55,9 @@ class PaymentPageBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final pageSelector = context.watch<PageSelectorProvider>();
final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.watch<RecipientsProvider>();
final flowProvider = context.watch<PaymentFlowProvider>();
final recipient = pageSelector.selectedRecipient;
final loc = AppLocalizations.of(context)!;
if (methodsProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (methodsProvider.error != null) {
return Center(child: Text(loc.notificationError(methodsProvider.error ?? loc.noErrorInformation)));
}
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
@@ -73,18 +73,20 @@ class PaymentPageBody extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PaymentBackButton(onBack: onBack, pageSelector: pageSelector),
PaymentBackButton(
onBack: onBack,
recipient: recipient,
fallbackDestination: fallbackDestination,
),
SizedBox(height: dimensions.paddingSmall),
PaymentHeader(),
SizedBox(height: dimensions.paddingXXLarge),
SectionTitle(loc.sourceOfFunds),
SizedBox(height: dimensions.paddingSmall),
PaymentMethodSelector(
onMethodChanged: (m) => methodsProvider.setCurrentObject(m.id),
),
SizedBox(height: dimensions.paddingXLarge),
RecipientSection(
recipient: recipient,
dimensions: dimensions,
@@ -95,19 +97,15 @@ class PaymentPageBody extends StatelessWidget {
onRecipientSelected: onRecipientSelected,
onRecipientCleared: onRecipientCleared,
),
SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection(
dimensions: dimensions,
pageSelector: pageSelector,
flowProvider: flowProvider,
recipient: recipient,
availableTypes: availablePaymentTypes,
),
SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge),
SendButton(onPressed: onSend),
SizedBox(height: dimensions.paddingLarge),
@@ -120,31 +118,3 @@ class PaymentPageBody extends StatelessWidget {
);
}
}
class PaymentBackButton extends StatelessWidget {
final ValueChanged<Recipient?>? onBack;
final PageSelectorProvider pageSelector;
const PaymentBackButton({
super.key,
required this.onBack,
required this.pageSelector,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (onBack != null) {
onBack!(pageSelector.selectedRecipient);
} else {
pageSelector.goBackFromPayment(context);
}
},
),
);
}
}

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/dashboard/payouts/payment_form.dart';
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
import 'package:pweb/providers/payment_flow.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentPageContent extends StatelessWidget {
final ValueChanged<Recipient?>? onBack;
final Recipient? recipient;
final RecipientsProvider recipientProvider;
final PaymentMethodsProvider methodsProvider;
final MethodMap availablePaymentTypes;
final PayoutDestination fallbackDestination;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final ValueChanged<String> onSearchChanged;
final ValueChanged<Recipient> onRecipientSelected;
final VoidCallback onRecipientCleared;
final VoidCallback onSend;
const PaymentPageContent({
super.key,
required this.onBack,
required this.recipient,
required this.recipientProvider,
required this.methodsProvider,
required this.availablePaymentTypes,
required this.fallbackDestination,
required this.searchController,
required this.searchFocusNode,
required this.onSearchChanged,
required this.onRecipientSelected,
required this.onRecipientCleared,
required this.onSend,
});
@override
Widget build(BuildContext context) {
final dimensions = AppDimensions();
final flowProvider = context.watch<PaymentFlowProvider>();
final loc = AppLocalizations.of(context)!;
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth),
child: Material(
elevation: dimensions.elevationSmall,
borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium),
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: EdgeInsets.all(dimensions.paddingLarge),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PaymentBackButton(
onBack: onBack,
recipient: recipient,
fallbackDestination: fallbackDestination,
),
SizedBox(height: dimensions.paddingSmall),
PaymentHeader(),
SizedBox(height: dimensions.paddingXXLarge),
SectionTitle(loc.sourceOfFunds),
SizedBox(height: dimensions.paddingSmall),
PaymentMethodSelector(
onMethodChanged: (m) => methodsProvider.setCurrentObject(m.id),
),
SizedBox(height: dimensions.paddingXLarge),
RecipientSection(
recipient: recipient,
dimensions: dimensions,
recipientProvider: recipientProvider,
searchController: searchController,
searchFocusNode: searchFocusNode,
onSearchChanged: onSearchChanged,
onRecipientSelected: onRecipientSelected,
onRecipientCleared: onRecipientCleared,
),
SizedBox(height: dimensions.paddingXLarge),
PaymentInfoSection(
dimensions: dimensions,
flowProvider: flowProvider,
recipient: recipient,
availableTypes: availablePaymentTypes,
),
SizedBox(height: dimensions.paddingLarge),
const PaymentFormWidget(),
SizedBox(height: dimensions.paddingXXXLarge),
SendButton(onPressed: onSend),
SizedBox(height: dimensions.paddingLarge),
],
),
),
),
),
),
);
}
}

View File

@@ -6,8 +6,7 @@ import 'package:pshared/models/recipient/recipient.dart';
import 'package:pweb/pages/payment_methods/form.dart';
import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/providers/payment_flow_provider.dart';
import 'package:pweb/providers/payment_flow.dart';
import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/utils/payment/selector_type.dart';
@@ -16,14 +15,14 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentInfoSection extends StatelessWidget {
final AppDimensions dimensions;
final PageSelectorProvider pageSelector;
final MethodMap availableTypes;
final PaymentFlowProvider flowProvider;
final Recipient? recipient;
const PaymentInfoSection({
super.key,
required this.dimensions,
required this.pageSelector,
required this.availableTypes,
required this.flowProvider,
required this.recipient,
});
@@ -32,11 +31,11 @@ class PaymentInfoSection extends StatelessWidget {
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final hasRecipient = recipient != null;
final MethodMap availableTypes = hasRecipient
? pageSelector.getAvailablePaymentTypes()
final MethodMap resolvedAvailableTypes = hasRecipient
? availableTypes
: {for (final type in PaymentType.values) type: null};
if (hasRecipient && availableTypes.isEmpty) {
if (hasRecipient && resolvedAvailableTypes.isEmpty) {
return Text(loc.recipientNoPaymentDetails);
}
@@ -48,7 +47,7 @@ class PaymentInfoSection extends StatelessWidget {
SectionTitle(loc.paymentInfo),
SizedBox(height: dimensions.paddingSmall),
PaymentTypeSelector(
availableTypes: availableTypes,
availableTypes: resolvedAvailableTypes,
selectedType: selectedType,
onSelected: (type) => flowProvider.selectType(
type,
@@ -63,7 +62,7 @@ class PaymentInfoSection extends StatelessWidget {
flowProvider.setManualPaymentData(data);
}
},
initialData: hasRecipient ? availableTypes[selectedType] : flowProvider.manualPaymentData,
initialData: hasRecipient ? resolvedAvailableTypes[selectedType] : flowProvider.manualPaymentData,
isEditable: !hasRecipient,
),
],

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
class PaymentMethodsLoadingView extends StatelessWidget {
const PaymentMethodsLoadingView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
}
}
class PaymentMethodsErrorView extends StatelessWidget {
final String message;
const PaymentMethodsErrorView({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Center(child: Text(message));
}
}

View File

@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -20,12 +23,14 @@ class SendPayoutButton extends StatelessWidget {
elevation: 0,
),
onPressed: () {
final pageSelectorProvider = context.read<PageSelectorProvider>();
final walletsProvider = context.read<WalletsProvider>();
final wallet = walletsProvider.selectedWallet;
if (wallet != null) {
pageSelectorProvider.startPaymentFromWallet(context, wallet);
context.pushToPayment(
paymentType: PaymentType.wallet,
returnTo: PayoutDestination.editwallet,
);
}
},
child: Text(loc.payoutNavSendPayout),

View File

@@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -26,7 +27,7 @@ class TopUpButton extends StatelessWidget{
);
return;
}
context.read<PageSelectorProvider>().openWalletTopUp(context, wallet);
context.pushToWalletTopUp(returnTo: PayoutDestination.editwallet);
},
child: Text(loc.topUpBalance),
);

View File

@@ -1,264 +0,0 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/models/wallet.dart';
import 'package:pweb/providers/wallets.dart';
//import 'package:pweb/services/amplitude.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
class PageSelectorProvider extends ChangeNotifier {
PayoutDestination _selected = PayoutDestination.dashboard;
PaymentType? _type;
bool _cameFromRecipientList = false;
PayoutDestination? _previousDestination;
late RecipientsProvider recipientProvider;
late WalletsProvider walletsProvider;
late PaymentMethodsProvider methodsProvider;
PayoutDestination get selected => _selected;
PaymentType? get type => _type;
bool get cameFromRecipientList => _cameFromRecipientList;
PageSelectorProvider();
void update(
RecipientsProvider recipientProv,
WalletsProvider walletsProv,
PaymentMethodsProvider methodsProv,
) {
recipientProvider = recipientProv;
walletsProvider = walletsProv;
methodsProvider = methodsProv;
}
void syncDestination(PayoutDestination destination) {
if (_selected == destination) return;
_selected = destination;
notifyListeners();
}
void selectPage(
BuildContext context,
PayoutDestination dest, {
bool replace = true,
}) {
_selected = dest;
notifyListeners();
_navigateTo(context, dest, replace: replace);
}
void selectRecipient(
BuildContext context,
Recipient? recipient, {
bool fromList = false,
}) {
final previousDestination = _selected;
recipientProvider.setCurrentObject(recipient?.id);
_cameFromRecipientList = fromList;
_setPreviousDestination();
_selected = PayoutDestination.payment;
notifyListeners();
if (previousDestination != PayoutDestination.payment) {
_navigateTo(context, PayoutDestination.payment, replace: false);
}
}
void editRecipient(
BuildContext context,
Recipient? recipient, {
bool fromList = false,
}) {
final previousDestination = _selected;
recipientProvider.setCurrentObject(recipient?.id);
_cameFromRecipientList = fromList;
_selected = PayoutDestination.addrecipient;
notifyListeners();
if (previousDestination != PayoutDestination.addrecipient) {
_navigateTo(context, PayoutDestination.addrecipient, replace: false);
}
}
void goToAddRecipient(BuildContext context) {
// AmplitudeService.recipientAddStarted();
final previousDestination = _selected;
recipientProvider.setCurrentObject(null);
_selected = PayoutDestination.addrecipient;
_cameFromRecipientList = false;
notifyListeners();
if (previousDestination != PayoutDestination.addrecipient) {
_navigateTo(context, PayoutDestination.addrecipient, replace: false);
}
}
void startPaymentWithoutRecipient(
BuildContext context,
PaymentType type,
) {
final previousDestination = _selected;
recipientProvider.setCurrentObject(null);
_type = type;
_cameFromRecipientList = false;
_setPreviousDestination();
_selected = PayoutDestination.payment;
notifyListeners();
if (previousDestination != PayoutDestination.payment) {
_navigateTo(context, PayoutDestination.payment, replace: false);
}
}
void goBackFromPayment(BuildContext context) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
_navigateTo(
context,
_previousDestination ??
(_cameFromRecipientList
? PayoutDestination.recipients
: PayoutDestination.dashboard),
);
}
_selected = _previousDestination ??
(_cameFromRecipientList
? PayoutDestination.recipients
: PayoutDestination.dashboard);
_type = null;
_previousDestination = null;
_cameFromRecipientList = false;
notifyListeners();
}
void goBackFromWalletEdit(BuildContext context) {
selectPage(context, PayoutDestination.methods);
}
void selectWallet(BuildContext context, Wallet wallet) {
final previousDestination = _selected;
walletsProvider.selectWallet(wallet);
_selected = PayoutDestination.editwallet;
notifyListeners();
if (previousDestination != PayoutDestination.editwallet) {
_navigateTo(context, PayoutDestination.editwallet, replace: false);
}
}
void startPaymentFromWallet(BuildContext context, Wallet wallet) {
final previousDestination = _selected;
_type = PaymentType.wallet;
_cameFromRecipientList = false;
_setPreviousDestination();
_selected = PayoutDestination.payment;
notifyListeners();
if (previousDestination != PayoutDestination.payment) {
_navigateTo(context, PayoutDestination.payment, replace: false);
}
}
void openWalletTopUp(BuildContext context, Wallet wallet) {
final previousDestination = _selected;
_setPreviousDestination();
walletsProvider.selectWallet(wallet);
_selected = PayoutDestination.walletTopUp;
notifyListeners();
if (previousDestination != PayoutDestination.walletTopUp) {
_navigateTo(context, PayoutDestination.walletTopUp, replace: false);
}
}
void goBackFromWalletTopUp(BuildContext context) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
_navigateTo(
context,
_previousDestination ?? PayoutDestination.dashboard,
);
}
_selected = _previousDestination ?? PayoutDestination.dashboard;
_previousDestination = null;
notifyListeners();
}
PaymentMethod? getPaymentMethodForWallet(Wallet wallet) {
if (methodsProvider.methods.isEmpty) {
return null;
}
return methodsProvider.methods.firstWhereOrNull(
(method) => method.type == PaymentType.wallet && (method.description?.contains(wallet.walletUserID) ?? false),
);
}
MethodMap getAvailablePaymentTypes() {
final recipient = selectedRecipient;
if ((recipient == null) || !methodsProvider.isReady) return {};
final methodsForRecipient = methodsProvider.methods.where(
(method) => !method.isArchived && method.recipientRef == recipient.id,
);
return {
for (final method in methodsForRecipient) method.type: method.data,
};
}
PaymentType getDefaultPaymentType() {
final availableTypes = getAvailablePaymentTypes();
final currentType = _type ?? PaymentType.bankAccount;
if (availableTypes.containsKey(currentType)) {
return currentType;
}
if (availableTypes.isNotEmpty) {
return availableTypes.keys.first;
}
return PaymentType.bankAccount;
}
bool shouldShowPaymentForm() {
return selectedRecipient == null;
}
void handleWalletAutoSelection() {
if (selectedWallet != null) {
final wallet = selectedWallet!;
final matchingMethod = getPaymentMethodForWallet(wallet);
if (matchingMethod != null) {
methodsProvider.setCurrentObject(matchingMethod.id);
}
}
}
void _setPreviousDestination() {
if (_selected != PayoutDestination.payment &&
_selected != PayoutDestination.walletTopUp) {
_previousDestination = _selected;
}
}
void _navigateTo(
BuildContext context,
PayoutDestination destination, {
bool replace = true,
}) {
if (replace) {
context.goToPayout(destination);
} else {
context.pushToPayout(destination);
}
}
Recipient? get selectedRecipient => recipientProvider.currentObject;
Wallet? get selectedWallet => walletsProvider.selectedWallet;
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
class PaymentFlowProvider extends ChangeNotifier {
PaymentType _selectedType;
PaymentMethodData? _manualPaymentData;
PaymentFlowProvider({
required PaymentType initialType,
}) : _selectedType = initialType;
PaymentType get selectedType => _selectedType;
PaymentMethodData? get manualPaymentData => _manualPaymentData;
void sync({
required Recipient? recipient,
required MethodMap availableTypes,
PaymentType? preferredType,
}) {
final resolvedType = _resolveSelectedType(
recipient: recipient,
availableTypes: availableTypes,
preferredType: preferredType,
);
var hasChanges = false;
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
if (recipient != null && _manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
if (hasChanges) notifyListeners();
}
void reset({
required Recipient? recipient,
required MethodMap availableTypes,
PaymentType? preferredType,
}) {
final resolvedType = _resolveSelectedType(
recipient: recipient,
availableTypes: availableTypes,
preferredType: preferredType,
);
var hasChanges = false;
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
if (_manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
if (hasChanges) notifyListeners();
}
void selectType(PaymentType type, {bool resetManualData = false}) {
if (_selectedType == type && (!resetManualData || _manualPaymentData == null)) {
return;
}
_selectedType = type;
if (resetManualData) {
_manualPaymentData = null;
}
notifyListeners();
}
void setManualPaymentData(PaymentMethodData? data) {
_manualPaymentData = data;
notifyListeners();
}
PaymentType _resolveSelectedType({
required Recipient? recipient,
required MethodMap availableTypes,
PaymentType? preferredType,
}) {
if (recipient == null) {
return preferredType ?? _selectedType;
}
if (availableTypes.isEmpty) {
return preferredType ?? PaymentType.bankAccount;
}
if (availableTypes.keys.contains(_selectedType)) {
return _selectedType;
}
if (preferredType != null && availableTypes.keys.contains(preferredType)) {
return preferredType;
}
return availableTypes.keys.first;
}
}

View File

@@ -1,79 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pweb/providers/page_selector.dart';
class PaymentFlowProvider extends ChangeNotifier {
PaymentType _selectedType;
PaymentMethodData? _manualPaymentData;
PaymentFlowProvider({
required PaymentType initialType,
}) : _selectedType = initialType;
PaymentType get selectedType => _selectedType;
PaymentMethodData? get manualPaymentData => _manualPaymentData;
void syncWithSelector(PageSelectorProvider selector) {
final recipient = selector.selectedRecipient;
final resolvedType = _resolveSelectedType(selector, recipient);
var hasChanges = false;
if (resolvedType != _selectedType) {
_selectedType = resolvedType;
hasChanges = true;
}
if (recipient != null && _manualPaymentData != null) {
_manualPaymentData = null;
hasChanges = true;
}
if (hasChanges) notifyListeners();
}
void reset(PageSelectorProvider selector) {
_selectedType = selector.getDefaultPaymentType();
_manualPaymentData = null;
notifyListeners();
}
void selectType(PaymentType type, {bool resetManualData = false}) {
if (_selectedType == type && (!resetManualData || _manualPaymentData == null)) {
return;
}
_selectedType = type;
if (resetManualData) {
_manualPaymentData = null;
}
notifyListeners();
}
void setManualPaymentData(PaymentMethodData? data) {
_manualPaymentData = data;
notifyListeners();
}
PaymentType _resolveSelectedType(
PageSelectorProvider selector,
Recipient? recipient,
) {
final available = selector.getAvailablePaymentTypes();
final current = _selectedType;
if (recipient == null) {
return current;
}
if (available.keys.contains(current)) {
return current;
}
return selector.getDefaultPaymentType();
}
}

View File

@@ -8,7 +8,6 @@ import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/pages/loader.dart';
import 'package:pweb/providers/page_selector.dart';
import 'package:pweb/utils/logout.dart';
import 'package:pweb/widgets/appbar/app_bar.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
@@ -32,9 +31,10 @@ class PageSelector extends StatelessWidget {
final permissions = context.read<PermissionsProvider>();
if (!permissions.isReady) return Center(child: CircularProgressIndicator());
final provider = context.watch<PageSelectorProvider>();
final bool restrictedAccess = !permissions.canRead(ResourceType.chainWallets);
final fallbackDestination = restrictedAccess
? PayoutDestination.settings
: PayoutDestination.dashboard;
final allowedDestinations = restrictedAccess
? <PayoutDestination>{
PayoutDestination.settings,
@@ -44,10 +44,10 @@ class PageSelector extends StatelessWidget {
}
: PayoutDestination.values.toSet();
final routeDestination = _destinationFromState(routerState) ?? provider.selected;
final selected = allowedDestinations.contains(routeDestination)
final routeDestination = _destinationFromState(routerState);
final selected = routeDestination != null && allowedDestinations.contains(routeDestination)
? routeDestination
: (restrictedAccess ? PayoutDestination.settings : PayoutDestination.dashboard);
: fallbackDestination;
if (selected != routeDestination) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -55,10 +55,6 @@ class PageSelector extends StatelessWidget {
});
}
if (provider.selected != selected) {
provider.syncDestination(selected);
}
return Scaffold(
appBar: PayoutAppBar(
title: Text(selected.localizedLabel(context)),