Compare commits
9 Commits
SEND003
...
a24ead2c36
| Author | SHA1 | Date | |
|---|---|---|---|
| a24ead2c36 | |||
|
|
ce59cb1b26 | ||
| cecaebfc5e | |||
| e16f11d48a | |||
|
|
0804ad71f7 | ||
| 7a2f921de9 | |||
|
|
999f0684cb | ||
| 602b77ddc7 | |||
|
|
64ad8c8b38 |
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -2,448 +2,16 @@ package fees
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
dmath "github.com/tech/sendico/pkg/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
||||
// Implementation lives under internal/service/fees/internal/calculator.
|
||||
type Calculator interface {
|
||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
|
||||
}
|
||||
|
||||
// CalculationResult contains derived fee lines and audit metadata.
|
||||
type CalculationResult struct {
|
||||
Lines []*feesv1.DerivedPostingLine
|
||||
Applied []*feesv1.AppliedRule
|
||||
FxUsed *feesv1.FXUsed
|
||||
}
|
||||
|
||||
// quoteCalculator is the default Calculator implementation.
|
||||
type fxOracle interface {
|
||||
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||
}
|
||||
|
||||
type quoteCalculator struct {
|
||||
logger mlogger.Logger
|
||||
oracle fxOracle
|
||||
}
|
||||
|
||||
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
|
||||
return "eCalculator{
|
||||
logger: logger.Named("calculator"),
|
||||
oracle: oracle,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
||||
if plan == nil {
|
||||
return nil, merrors.InvalidArgument("plan is required")
|
||||
}
|
||||
if intent == nil {
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
|
||||
trigger := convertTrigger(intent.GetTrigger())
|
||||
if trigger == model.TriggerUnspecified {
|
||||
return nil, merrors.InvalidArgument("unsupported trigger")
|
||||
}
|
||||
|
||||
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid base amount")
|
||||
}
|
||||
if baseAmount.Sign() < 0 {
|
||||
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
||||
}
|
||||
|
||||
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
||||
|
||||
rules := make([]model.FeeRule, len(plan.Rules))
|
||||
copy(rules, plan.Rules)
|
||||
sort.SliceStable(rules, func(i, j int) bool {
|
||||
if rules[i].Priority == rules[j].Priority {
|
||||
return rules[i].RuleID < rules[j].RuleID
|
||||
}
|
||||
return rules[i].Priority < rules[j].Priority
|
||||
})
|
||||
|
||||
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
||||
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||
|
||||
planID := ""
|
||||
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
||||
planID = planRef.Hex()
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
||||
if ledgerAccountRef == "" {
|
||||
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
|
||||
continue
|
||||
}
|
||||
|
||||
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||
if calcErr != nil {
|
||||
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if amount.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
currency := intent.GetBaseAmount().GetCurrency()
|
||||
if override := strings.TrimSpace(rule.Currency); override != "" {
|
||||
currency = override
|
||||
}
|
||||
|
||||
entrySide := mapEntrySide(rule.EntrySide)
|
||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
"fee_rule_id": rule.RuleID,
|
||||
}
|
||||
if planID != "" {
|
||||
meta["fee_plan_id"] = planID
|
||||
}
|
||||
if rule.Metadata != nil {
|
||||
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
||||
meta["tax_code"] = taxCode
|
||||
}
|
||||
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
||||
meta["tax_rate"] = taxRate
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, &feesv1.DerivedPostingLine{
|
||||
LedgerAccountRef: ledgerAccountRef,
|
||||
Money: &moneyv1.Money{
|
||||
Amount: dmath.FormatRat(amount, scale),
|
||||
Currency: currency,
|
||||
},
|
||||
LineType: mapLineType(rule.LineType),
|
||||
Side: entrySide,
|
||||
Meta: meta,
|
||||
})
|
||||
|
||||
applied = append(applied, &feesv1.AppliedRule{
|
||||
RuleId: rule.RuleID,
|
||||
RuleVersion: planID,
|
||||
Formula: rule.Formula,
|
||||
Rounding: mapRoundingMode(rule.Rounding),
|
||||
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
||||
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
||||
Parameters: cloneStringMap(rule.Metadata),
|
||||
})
|
||||
}
|
||||
|
||||
var fxUsed *feesv1.FXUsed
|
||||
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
||||
fxUsed = c.buildFxUsed(ctx, intent)
|
||||
}
|
||||
|
||||
return &CalculationResult{
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fxUsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
||||
scale, err := resolveRuleScale(rule, baseScale)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
result := new(big.Rat)
|
||||
|
||||
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
||||
percentageRat, perr := dmath.RatFromString(percentage)
|
||||
if perr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
||||
}
|
||||
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
||||
}
|
||||
|
||||
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
||||
fixedRat, ferr := dmath.RatFromString(fixed)
|
||||
if ferr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
||||
}
|
||||
result = dmath.AddRat(result, fixedRat)
|
||||
}
|
||||
|
||||
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
||||
minRat, merr := dmath.RatFromString(minStr)
|
||||
if merr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
||||
}
|
||||
if dmath.CmpRat(result, minRat) < 0 {
|
||||
result = new(big.Rat).Set(minRat)
|
||||
}
|
||||
}
|
||||
|
||||
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
||||
maxRat, merr := dmath.RatFromString(maxStr)
|
||||
if merr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
||||
}
|
||||
if dmath.CmpRat(result, maxRat) > 0 {
|
||||
result = new(big.Rat).Set(maxRat)
|
||||
}
|
||||
}
|
||||
|
||||
if result.Sign() < 0 {
|
||||
result = new(big.Rat).Abs(result)
|
||||
}
|
||||
|
||||
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
||||
if rerr != nil {
|
||||
return nil, 0, rerr
|
||||
}
|
||||
|
||||
return rounded, scale, nil
|
||||
}
|
||||
|
||||
const (
|
||||
attrFxBaseCurrency = "fx_base_currency"
|
||||
attrFxQuoteCurrency = "fx_quote_currency"
|
||||
attrFxProvider = "fx_provider"
|
||||
attrFxSide = "fx_side"
|
||||
attrFxRateOverride = "fx_rate"
|
||||
)
|
||||
|
||||
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
||||
if intent == nil || c.oracle == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
attrs := intent.GetAttributes()
|
||||
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||
if base == "" || quote == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
||||
provider := strings.TrimSpace(attrs[attrFxProvider])
|
||||
|
||||
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
||||
Meta: oracleclient.RequestMeta{},
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Mid
|
||||
}
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Ask
|
||||
}
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Bid
|
||||
}
|
||||
|
||||
return &feesv1.FXUsed{
|
||||
Pair: pair,
|
||||
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
||||
Rate: &moneyv1.Decimal{Value: rateValue},
|
||||
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
||||
Provider: snapshot.Provider,
|
||||
RateRef: snapshot.RateRef,
|
||||
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
||||
}
|
||||
}
|
||||
|
||||
func parseFxSide(value string) fxv1.Side {
|
||||
switch strings.ToLower(value) {
|
||||
case "buy_base", "buy_base_sell_quote", "buy":
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case "sell_base", "sell_base_buy_quote", "sell":
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func inferScale(amount string) uint32 {
|
||||
value := strings.TrimSpace(amount)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
||||
value = value[1:]
|
||||
}
|
||||
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
||||
return uint32(len(value[dot+1:]))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
||||
if rule.Trigger != trigger {
|
||||
return false
|
||||
}
|
||||
if rule.EffectiveFrom.After(bookedAt) {
|
||||
return false
|
||||
}
|
||||
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
||||
return false
|
||||
}
|
||||
return ruleMatchesAttributes(rule, attributes)
|
||||
}
|
||||
|
||||
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
||||
if rule.Metadata != nil {
|
||||
for _, field := range []string{"scale", "decimals", "precision"} {
|
||||
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
||||
return parseScale(field, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func parseScale(field, value string) (uint32, error) {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean == "" {
|
||||
return 0, merrors.InvalidArgument(field + " is empty")
|
||||
}
|
||||
parsed, err := strconv.ParseUint(clean, 10, 32)
|
||||
if err != nil {
|
||||
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
||||
}
|
||||
return uint32(parsed), nil
|
||||
}
|
||||
|
||||
func metadataValue(meta map[string]string, key string) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(meta[key])
|
||||
}
|
||||
|
||||
func cloneStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
cloned[k] = v
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
||||
if len(rule.AppliesTo) == 0 {
|
||||
return true
|
||||
}
|
||||
for key, value := range rule.AppliesTo {
|
||||
if attributes == nil {
|
||||
return false
|
||||
}
|
||||
if attrValue, ok := attributes[key]; !ok || attrValue != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||
switch trigger {
|
||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||
return model.TriggerCapture
|
||||
case feesv1.Trigger_TRIGGER_REFUND:
|
||||
return model.TriggerRefund
|
||||
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||
return model.TriggerDispute
|
||||
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||
return model.TriggerPayout
|
||||
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||
return model.TriggerFXConversion
|
||||
default:
|
||||
return model.TriggerUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func mapLineType(lineType string) accountingv1.PostingLineType {
|
||||
switch strings.ToLower(lineType) {
|
||||
case "tax":
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||
case "spread":
|
||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||
case "reversal":
|
||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||
default:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||
}
|
||||
}
|
||||
|
||||
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
||||
switch strings.ToLower(entrySide) {
|
||||
case "debit":
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
case "credit":
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
default:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func toDecimalRounding(mode string) dmath.RoundingMode {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "half_up":
|
||||
return dmath.RoundingModeHalfUp
|
||||
case "down":
|
||||
return dmath.RoundingModeDown
|
||||
case "half_even", "bankers":
|
||||
return dmath.RoundingModeHalfEven
|
||||
default:
|
||||
return dmath.RoundingModeHalfEven
|
||||
}
|
||||
}
|
||||
|
||||
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
||||
switch strings.ToLower(mode) {
|
||||
case "half_up":
|
||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||
case "down":
|
||||
return moneyv1.RoundingMode_ROUND_DOWN
|
||||
default:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||
}
|
||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
dmath "github.com/tech/sendico/pkg/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fxOracle captures the oracle dependency for FX conversions.
|
||||
type fxOracle interface {
|
||||
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||
}
|
||||
|
||||
// New constructs the default calculator implementation.
|
||||
func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return "eCalculator{
|
||||
logger: logger.Named("calculator"),
|
||||
oracle: oracle,
|
||||
}
|
||||
}
|
||||
|
||||
type quoteCalculator struct {
|
||||
logger mlogger.Logger
|
||||
oracle fxOracle
|
||||
}
|
||||
|
||||
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||
if plan == nil {
|
||||
return nil, merrors.InvalidArgument("plan is required")
|
||||
}
|
||||
if intent == nil {
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
|
||||
trigger := convertTrigger(intent.GetTrigger())
|
||||
if trigger == model.TriggerUnspecified {
|
||||
return nil, merrors.InvalidArgument("unsupported trigger")
|
||||
}
|
||||
|
||||
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid base amount")
|
||||
}
|
||||
if baseAmount.Sign() < 0 {
|
||||
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
||||
}
|
||||
|
||||
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
||||
|
||||
rules := make([]model.FeeRule, len(plan.Rules))
|
||||
copy(rules, plan.Rules)
|
||||
sort.SliceStable(rules, func(i, j int) bool {
|
||||
if rules[i].Priority == rules[j].Priority {
|
||||
return rules[i].RuleID < rules[j].RuleID
|
||||
}
|
||||
return rules[i].Priority < rules[j].Priority
|
||||
})
|
||||
|
||||
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
||||
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||
|
||||
planID := ""
|
||||
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
||||
planID = planRef.Hex()
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
||||
if ledgerAccountRef == "" {
|
||||
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
|
||||
continue
|
||||
}
|
||||
|
||||
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||
if calcErr != nil {
|
||||
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if amount.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
currency := intent.GetBaseAmount().GetCurrency()
|
||||
if override := strings.TrimSpace(rule.Currency); override != "" {
|
||||
currency = override
|
||||
}
|
||||
|
||||
entrySide := mapEntrySide(rule.EntrySide)
|
||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
"fee_rule_id": rule.RuleID,
|
||||
}
|
||||
if planID != "" {
|
||||
meta["fee_plan_id"] = planID
|
||||
}
|
||||
if rule.Metadata != nil {
|
||||
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
||||
meta["tax_code"] = taxCode
|
||||
}
|
||||
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
||||
meta["tax_rate"] = taxRate
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, &feesv1.DerivedPostingLine{
|
||||
LedgerAccountRef: ledgerAccountRef,
|
||||
Money: &moneyv1.Money{
|
||||
Amount: dmath.FormatRat(amount, scale),
|
||||
Currency: currency,
|
||||
},
|
||||
LineType: mapLineType(rule.LineType),
|
||||
Side: entrySide,
|
||||
Meta: meta,
|
||||
})
|
||||
|
||||
applied = append(applied, &feesv1.AppliedRule{
|
||||
RuleId: rule.RuleID,
|
||||
RuleVersion: planID,
|
||||
Formula: rule.Formula,
|
||||
Rounding: mapRoundingMode(rule.Rounding),
|
||||
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
||||
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
||||
Parameters: cloneStringMap(rule.Metadata),
|
||||
})
|
||||
}
|
||||
|
||||
var fxUsed *feesv1.FXUsed
|
||||
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
||||
fxUsed = c.buildFxUsed(ctx, intent)
|
||||
}
|
||||
|
||||
return &types.CalculationResult{
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fxUsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
||||
scale, err := resolveRuleScale(rule, baseScale)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
result := new(big.Rat)
|
||||
|
||||
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
||||
percentageRat, perr := dmath.RatFromString(percentage)
|
||||
if perr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
||||
}
|
||||
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
||||
}
|
||||
|
||||
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
||||
fixedRat, ferr := dmath.RatFromString(fixed)
|
||||
if ferr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
||||
}
|
||||
result = dmath.AddRat(result, fixedRat)
|
||||
}
|
||||
|
||||
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
||||
minRat, merr := dmath.RatFromString(minStr)
|
||||
if merr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
||||
}
|
||||
if dmath.CmpRat(result, minRat) < 0 {
|
||||
result = new(big.Rat).Set(minRat)
|
||||
}
|
||||
}
|
||||
|
||||
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
||||
maxRat, merr := dmath.RatFromString(maxStr)
|
||||
if merr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
||||
}
|
||||
if dmath.CmpRat(result, maxRat) > 0 {
|
||||
result = new(big.Rat).Set(maxRat)
|
||||
}
|
||||
}
|
||||
|
||||
if result.Sign() < 0 {
|
||||
result = new(big.Rat).Abs(result)
|
||||
}
|
||||
|
||||
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
||||
if rerr != nil {
|
||||
return nil, 0, rerr
|
||||
}
|
||||
|
||||
return rounded, scale, nil
|
||||
}
|
||||
|
||||
const (
|
||||
attrFxBaseCurrency = "fx_base_currency"
|
||||
attrFxQuoteCurrency = "fx_quote_currency"
|
||||
attrFxProvider = "fx_provider"
|
||||
attrFxSide = "fx_side"
|
||||
attrFxRateOverride = "fx_rate"
|
||||
)
|
||||
|
||||
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
||||
if intent == nil || c.oracle == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
attrs := intent.GetAttributes()
|
||||
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||
if base == "" || quote == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
||||
provider := strings.TrimSpace(attrs[attrFxProvider])
|
||||
|
||||
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
||||
Meta: oracleclient.RequestMeta{},
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Mid
|
||||
}
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Ask
|
||||
}
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Bid
|
||||
}
|
||||
|
||||
return &feesv1.FXUsed{
|
||||
Pair: pair,
|
||||
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
||||
Rate: &moneyv1.Decimal{Value: rateValue},
|
||||
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
||||
Provider: snapshot.Provider,
|
||||
RateRef: snapshot.RateRef,
|
||||
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
||||
}
|
||||
}
|
||||
|
||||
func parseFxSide(value string) fxv1.Side {
|
||||
switch strings.ToLower(value) {
|
||||
case "buy_base", "buy_base_sell_quote", "buy":
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case "sell_base", "sell_base_buy_quote", "sell":
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func inferScale(amount string) uint32 {
|
||||
value := strings.TrimSpace(amount)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
||||
value = value[1:]
|
||||
}
|
||||
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
||||
return uint32(len(value[dot+1:]))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
||||
if rule.Trigger != trigger {
|
||||
return false
|
||||
}
|
||||
if rule.EffectiveFrom.After(bookedAt) {
|
||||
return false
|
||||
}
|
||||
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
||||
return false
|
||||
}
|
||||
return ruleMatchesAttributes(rule, attributes)
|
||||
}
|
||||
|
||||
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
||||
if rule.Metadata != nil {
|
||||
for _, field := range []string{"scale", "decimals", "precision"} {
|
||||
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
||||
return parseScale(field, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func parseScale(field, value string) (uint32, error) {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean == "" {
|
||||
return 0, merrors.InvalidArgument(field + " is empty")
|
||||
}
|
||||
parsed, err := strconv.ParseUint(clean, 10, 32)
|
||||
if err != nil {
|
||||
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
||||
}
|
||||
return uint32(parsed), nil
|
||||
}
|
||||
|
||||
func metadataValue(meta map[string]string, key string) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(meta[key])
|
||||
}
|
||||
|
||||
func cloneStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
cloned[k] = v
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
||||
if len(rule.AppliesTo) == 0 {
|
||||
return true
|
||||
}
|
||||
for key, value := range rule.AppliesTo {
|
||||
if attributes == nil {
|
||||
return false
|
||||
}
|
||||
if attrValue, ok := attributes[key]; !ok || attrValue != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mapLineType(lineType string) accountingv1.PostingLineType {
|
||||
switch strings.ToLower(lineType) {
|
||||
case "tax":
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||
case "spread":
|
||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||
case "reversal":
|
||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||
default:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||
}
|
||||
}
|
||||
|
||||
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
||||
switch strings.ToLower(entrySide) {
|
||||
case "debit":
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
case "credit":
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
default:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func toDecimalRounding(mode string) dmath.RoundingMode {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "half_up":
|
||||
return dmath.RoundingModeHalfUp
|
||||
case "down":
|
||||
return dmath.RoundingModeDown
|
||||
case "half_even", "bankers":
|
||||
return dmath.RoundingModeHalfEven
|
||||
default:
|
||||
return dmath.RoundingModeHalfEven
|
||||
}
|
||||
}
|
||||
|
||||
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
||||
switch strings.ToLower(mode) {
|
||||
case "half_up":
|
||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||
case "down":
|
||||
return moneyv1.RoundingMode_ROUND_DOWN
|
||||
default:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||
}
|
||||
}
|
||||
|
||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||
switch trigger {
|
||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||
return model.TriggerCapture
|
||||
case feesv1.Trigger_TRIGGER_REFUND:
|
||||
return model.TriggerRefund
|
||||
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||
return model.TriggerDispute
|
||||
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||
return model.TriggerPayout
|
||||
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||
return model.TriggerFXConversion
|
||||
default:
|
||||
return model.TriggerUnspecified
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
148
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
148
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal 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 = ©
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
15
api/billing/fees/internal/service/fees/resolver.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
23
api/billing/fees/internal/service/fees/trigger.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 + "\""}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
537
api/fx/ingestor/internal/market/cbr/connector.go
Normal file
537
api/fx/ingestor/internal/market/cbr/connector.go
Normal 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")
|
||||
}
|
||||
226
api/fx/ingestor/internal/market/cbr/connector_test.go
Normal file
226
api/fx/ingestor/internal/market/cbr/connector_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -10,6 +10,7 @@ type Driver string
|
||||
const (
|
||||
DriverBinance Driver = "BINANCE"
|
||||
DriverCoinGecko Driver = "COINGECKO"
|
||||
DriverCBR Driver = "CBR"
|
||||
)
|
||||
|
||||
func (d Driver) String() string {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
24
api/payments/orchestrator/storage/model/quote.go
Normal file
24
api/payments/orchestrator/storage/model/quote.go
Normal 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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
113
api/payments/orchestrator/storage/mongo/store/quotes.go
Normal file
113
api/payments/orchestrator/storage/mongo/store/quotes.go
Normal 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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
19
api/server/interface/api/srequest/payment.go
Normal file
19
api/server/interface/api/srequest/payment.go
Normal 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"`
|
||||
}
|
||||
18
api/server/interface/api/sresponse/money.go
Normal file
18
api/server/interface/api/sresponse/money.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
174
api/server/interface/api/sresponse/payment.go
Normal file
174
api/server/interface/api/sresponse/payment.go
Normal 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()),
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
12
api/server/interface/services/payment/payment.go
Normal file
12
api/server/interface/services/payment/payment.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
84
api/server/internal/server/paymentapiimp/pay.go
Normal file
84
api/server/internal/server/paymentapiimp/pay.go
Normal 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
|
||||
}
|
||||
13
api/server/internal/server/paymentapiimp/payimmediate.go
Normal file
13
api/server/internal/server/paymentapiimp/payimmediate.go
Normal 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)
|
||||
}
|
||||
13
api/server/internal/server/paymentapiimp/payquote.go
Normal file
13
api/server/internal/server/paymentapiimp/payquote.go
Normal 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)
|
||||
}
|
||||
74
api/server/internal/server/paymentapiimp/quote.go
Normal file
74
api/server/internal/server/paymentapiimp/quote.go
Normal 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
|
||||
}
|
||||
102
api/server/internal/server/paymentapiimp/service.go
Normal file
102
api/server/internal/server/paymentapiimp/service.go
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
frontend/pweb/lib/pages/payment_methods/payment_page/page.dart
Normal file
120
frontend/pweb/lib/pages/payment_methods/payment_page/page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
110
frontend/pweb/lib/providers/payment_flow.dart
Normal file
110
frontend/pweb/lib/providers/payment_flow.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user