payment quotation v2 + payment orchestration v2 draft
This commit is contained in:
@@ -17,7 +17,6 @@ replace github.com/tech/sendico/ledger => ../../ledger
|
||||
replace github.com/tech/sendico/payments/storage => ../storage
|
||||
|
||||
require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
|
||||
@@ -45,9 +44,10 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
@@ -63,5 +63,5 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||
)
|
||||
|
||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
@@ -211,8 +211,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -64,7 +64,7 @@ func (i *Imp) Start() error {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "payments_quotation", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
app, err := grpcapp.NewApp(i.logger, "payments.quotation", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func SendDirectionForRail(rail model.Rail) SendDirection {
|
||||
}
|
||||
|
||||
func IsGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir SendDirection, amount decimal.Decimal) error {
|
||||
return isGatewayEligible(gw, rail, network, currency, action, sendDirection(dir), amount)
|
||||
return model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(sendDirection(dir)), amount)
|
||||
}
|
||||
|
||||
func ParseRailValue(value string) model.Rail {
|
||||
|
||||
@@ -2,7 +2,6 @@ package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -58,8 +57,8 @@ func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string,
|
||||
amt = value
|
||||
currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||
}
|
||||
if err := isGatewayEligible(gw, gw.Rail, network, currency, action, dir, amt); err != nil {
|
||||
return merrors.InvalidArgument("plan builder: gateway instance is not eligible: " + err.Error())
|
||||
if err := model.IsGatewayEligible(gw, gw.Rail, network, currency, action, toGatewayDirection(dir), amt); err != nil {
|
||||
return merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -105,19 +104,14 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
|
||||
network = strings.ToUpper(strings.TrimSpace(network))
|
||||
|
||||
eligible := make([]*model.GatewayInstanceDescriptor, 0)
|
||||
var lastErr error
|
||||
for _, gw := range all {
|
||||
if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil {
|
||||
lastErr = err
|
||||
if err := model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(dir), amt); err != nil {
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, gw)
|
||||
}
|
||||
if len(eligible) == 0 {
|
||||
if lastErr != nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: " + lastErr.Error())
|
||||
}
|
||||
return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found")
|
||||
return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir)))
|
||||
}
|
||||
sort.Slice(eligible, func(i, j int) bool {
|
||||
return eligible[i].ID < eligible[j].ID
|
||||
@@ -132,142 +126,17 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
|
||||
return eligible[0], nil
|
||||
}
|
||||
|
||||
type gatewayIneligibleError struct {
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e gatewayIneligibleError) Error() string {
|
||||
return e.reason
|
||||
}
|
||||
|
||||
func gatewayIneligible(gw *model.GatewayInstanceDescriptor, reason string) error {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "gateway instance is not eligible"
|
||||
}
|
||||
return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", gw.InstanceID, reason)}
|
||||
}
|
||||
|
||||
func sendDirectionLabel(dir sendDirection) string {
|
||||
return toGatewayDirection(dir).String()
|
||||
}
|
||||
|
||||
func toGatewayDirection(dir sendDirection) model.GatewayDirection {
|
||||
switch dir {
|
||||
case sendDirectionOut:
|
||||
return "out"
|
||||
return model.GatewayDirectionOut
|
||||
case sendDirectionIn:
|
||||
return "in"
|
||||
return model.GatewayDirectionIn
|
||||
default:
|
||||
return "any"
|
||||
return model.GatewayDirectionAny
|
||||
}
|
||||
}
|
||||
|
||||
func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) error {
|
||||
if gw == nil {
|
||||
return gatewayIneligible(gw, "gateway instance is required")
|
||||
}
|
||||
if !gw.IsEnabled {
|
||||
return gatewayIneligible(gw, "gateway instance is disabled")
|
||||
}
|
||||
if gw.Rail != rail {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("rail mismatch: want %s got %s", rail, gw.Rail))
|
||||
}
|
||||
if network != "" && gw.Network != "" && !strings.EqualFold(gw.Network, network) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("network mismatch: want %s got %s", network, gw.Network))
|
||||
}
|
||||
if currency != "" && len(gw.Currencies) > 0 {
|
||||
found := false
|
||||
for _, c := range gw.Currencies {
|
||||
if strings.EqualFold(c, currency) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return gatewayIneligible(gw, "currency not supported: "+currency)
|
||||
}
|
||||
}
|
||||
|
||||
if !capabilityAllowsAction(gw.Capabilities, action, dir) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir)))
|
||||
}
|
||||
|
||||
if currency != "" {
|
||||
if err := amountWithinLimits(gw, gw.Limits, currency, amount, action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool {
|
||||
switch action {
|
||||
case model.RailOperationSend:
|
||||
switch dir {
|
||||
case sendDirectionOut:
|
||||
return cap.CanPayOut
|
||||
case sendDirectionIn:
|
||||
return cap.CanPayIn
|
||||
default:
|
||||
return cap.CanPayIn || cap.CanPayOut
|
||||
}
|
||||
case model.RailOperationFee:
|
||||
return cap.CanSendFee
|
||||
case model.RailOperationObserveConfirm:
|
||||
return cap.RequiresObserveConfirm
|
||||
case model.RailOperationBlock:
|
||||
return cap.CanBlock
|
||||
case model.RailOperationRelease:
|
||||
return cap.CanRelease
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) error {
|
||||
min := firstLimitValue(limits.MinAmount, "")
|
||||
max := firstLimitValue(limits.MaxAmount, "")
|
||||
perTxMin := firstLimitValue(limits.PerTxMinAmount, "")
|
||||
perTxMax := firstLimitValue(limits.PerTxMaxAmount, "")
|
||||
maxFee := firstLimitValue(limits.PerTxMaxFee, "")
|
||||
|
||||
if override, ok := limits.CurrencyLimits[currency]; ok {
|
||||
min = firstLimitValue(override.MinAmount, min)
|
||||
max = firstLimitValue(override.MaxAmount, max)
|
||||
if action == model.RailOperationFee {
|
||||
maxFee = firstLimitValue(override.MaxFee, maxFee)
|
||||
}
|
||||
}
|
||||
|
||||
if min != "" {
|
||||
if val, err := decimal.NewFromString(min); err == nil && amount.LessThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below min limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
if perTxMin != "" {
|
||||
if val, err := decimal.NewFromString(perTxMin); err == nil && amount.LessThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below per-tx min limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
if max != "" {
|
||||
if val, err := decimal.NewFromString(max); err == nil && amount.GreaterThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds max limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
if perTxMax != "" {
|
||||
if val, err := decimal.NewFromString(perTxMax); err == nil && amount.GreaterThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds per-tx max limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
if action == model.RailOperationFee && maxFee != "" {
|
||||
if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("fee amount %s %s exceeds max fee limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstLimitValue(primary, fallback string) string {
|
||||
val := strings.TrimSpace(primary)
|
||||
if val != "" {
|
||||
return val
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package batch_quote_processor_v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -48,7 +49,7 @@ func (p *BatchQuoteProcessorV2) Process(ctx context.Context, in ProcessInput) (*
|
||||
Item: *item,
|
||||
})
|
||||
if processErr != nil {
|
||||
return nil, fmt.Errorf("intents[%d]: %w", item.Index, processErr)
|
||||
return nil, wrapIndexedIntentError(item.Index, processErr)
|
||||
}
|
||||
if res == nil || res.Quote == nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("intents[%d]: quote is required", item.Index))
|
||||
@@ -109,3 +110,11 @@ func buildBatchItems(ctx BatchContext, intents []*transfer_intent_hydrator.Quote
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func wrapIndexedIntentError(index int, err error) error {
|
||||
msg := fmt.Sprintf("intents[%d]", index)
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
return merrors.InvalidArgumentWrap(err, msg)
|
||||
}
|
||||
return merrors.InternalWrap(err, msg)
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type paymentEngine interface {
|
||||
EnsureRepository(ctx context.Context) error
|
||||
BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error)
|
||||
BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error)
|
||||
Repository() storage.Repository
|
||||
}
|
||||
|
||||
type defaultPaymentEngine struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error {
|
||||
return e.svc.ensureRepository(ctx)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
|
||||
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
|
||||
return e.svc.buildPaymentPlan(ctx, orgID, intent, idempotencyKey, quote)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) {
|
||||
return e.svc.resolvePaymentQuote(ctx, in)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) Repository() storage.Repository {
|
||||
return e.svc.storage
|
||||
}
|
||||
|
||||
type paymentCommandFactory struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory {
|
||||
return &paymentCommandFactory{
|
||||
engine: engine,
|
||||
logger: logger.Named("commands"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
|
||||
return "ePaymentCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("quote.payment"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
|
||||
return "ePaymentsCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("quote.payments"),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package quotation
|
||||
|
||||
const (
|
||||
providerSettlementMetaPaymentIntentID = "payment_ref"
|
||||
providerSettlementMetaOutgoingLeg = "outgoing_leg"
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type compositeGatewayRegistry struct {
|
||||
logger mlogger.Logger
|
||||
registries []GatewayRegistry
|
||||
}
|
||||
|
||||
func NewCompositeGatewayRegistry(logger mlogger.Logger, registries ...GatewayRegistry) GatewayRegistry {
|
||||
items := make([]GatewayRegistry, 0, len(registries))
|
||||
for _, registry := range registries {
|
||||
if registry != nil {
|
||||
items = append(items, registry)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("gateway_registry")
|
||||
}
|
||||
return &compositeGatewayRegistry{
|
||||
logger: logger,
|
||||
registries: items,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
if r == nil || len(r.registries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
items := map[string]*model.GatewayInstanceDescriptor{}
|
||||
for _, registry := range r.registries {
|
||||
list, err := registry.List(ctx)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("Failed to list gateway registry", zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, entry := range list {
|
||||
key := model.GatewayDescriptorIdentityKey(entry)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
items[key] = entry
|
||||
}
|
||||
}
|
||||
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
|
||||
for _, entry := range items {
|
||||
result = append(result, entry)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return model.LessGatewayDescriptor(result[i], result[j])
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
@@ -2,20 +2,16 @@ package quotation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
chainasset "github.com/tech/sendico/pkg/chain"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
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"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func intentFromProto(src *sharedv1.PaymentIntent) model.PaymentIntent {
|
||||
@@ -106,23 +102,6 @@ func fxIntentFromProto(src *sharedv1.FXIntent) *model.FXIntent {
|
||||
}
|
||||
}
|
||||
|
||||
func quoteSnapshotToModel(src *sharedv1.PaymentQuote) *model.PaymentQuoteSnapshot {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: moneyFromProto(src.GetDebitAmount()),
|
||||
DebitSettlementAmount: moneyFromProto(src.GetDebitSettlementAmount()),
|
||||
ExpectedSettlementAmount: moneyFromProto(src.GetExpectedSettlementAmount()),
|
||||
ExpectedFeeTotal: moneyFromProto(src.GetExpectedFeeTotal()),
|
||||
FeeLines: feeLinesFromProto(src.GetFeeLines()),
|
||||
FeeRules: feeRulesFromProto(src.GetFeeRules()),
|
||||
FXQuote: fxQuoteFromProto(src.GetFxQuote()),
|
||||
NetworkFee: networkFeeFromProto(src.GetNetworkFee()),
|
||||
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
|
||||
}
|
||||
}
|
||||
|
||||
func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent {
|
||||
intent := &sharedv1.PaymentIntent{
|
||||
Ref: src.Ref,
|
||||
@@ -251,23 +230,6 @@ func protoFXIntentFromModel(src *model.FXIntent) *sharedv1.FXIntent {
|
||||
}
|
||||
}
|
||||
|
||||
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &sharedv1.PaymentQuote{
|
||||
DebitAmount: protoMoney(src.DebitAmount),
|
||||
DebitSettlementAmount: protoMoney(src.DebitSettlementAmount),
|
||||
ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount),
|
||||
ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal),
|
||||
FeeLines: feeLinesToProto(src.FeeLines),
|
||||
FeeRules: feeRulesToProto(src.FeeRules),
|
||||
FxQuote: fxQuoteToProto(src.FXQuote),
|
||||
NetworkFee: networkFeeToProto(src.NetworkFee),
|
||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||
}
|
||||
}
|
||||
|
||||
func protoKindFromModel(kind model.PaymentKind) sharedv1.PaymentKind {
|
||||
switch kind {
|
||||
case model.PaymentKindPayout:
|
||||
@@ -422,66 +384,6 @@ func fxSideToProto(side paymenttypes.FXSide) fxv1.Side {
|
||||
}
|
||||
}
|
||||
|
||||
func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
pricedAtUnixMs := int64(0)
|
||||
if ts := quote.GetPricedAt(); ts != nil {
|
||||
pricedAtUnixMs = ts.AsTime().UnixMilli()
|
||||
}
|
||||
return &paymenttypes.FXQuote{
|
||||
QuoteRef: strings.TrimSpace(quote.GetQuoteRef()),
|
||||
Pair: pairFromProto(quote.GetPair()),
|
||||
Side: fxSideFromProto(quote.GetSide()),
|
||||
Price: decimalFromProto(quote.GetPrice()),
|
||||
BaseAmount: moneyFromProto(quote.GetBaseAmount()),
|
||||
QuoteAmount: moneyFromProto(quote.GetQuoteAmount()),
|
||||
ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(),
|
||||
PricedAtUnixMs: pricedAtUnixMs,
|
||||
Provider: strings.TrimSpace(quote.GetProvider()),
|
||||
RateRef: strings.TrimSpace(quote.GetRateRef()),
|
||||
Firm: quote.GetFirm(),
|
||||
}
|
||||
}
|
||||
|
||||
func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
var pricedAt *timestamppb.Timestamp
|
||||
if quote.PricedAtUnixMs > 0 {
|
||||
pricedAt = timestamppb.New(time.UnixMilli(quote.PricedAtUnixMs).UTC())
|
||||
}
|
||||
return &oraclev1.Quote{
|
||||
QuoteRef: strings.TrimSpace(quote.QuoteRef),
|
||||
Pair: pairToProto(quote.Pair),
|
||||
Side: fxSideToProto(quote.Side),
|
||||
Price: decimalToProto(quote.Price),
|
||||
BaseAmount: protoMoney(quote.BaseAmount),
|
||||
QuoteAmount: protoMoney(quote.QuoteAmount),
|
||||
ExpiresAtUnixMs: quote.ExpiresAtUnixMs,
|
||||
PricedAt: pricedAt,
|
||||
Provider: strings.TrimSpace(quote.Provider),
|
||||
RateRef: strings.TrimSpace(quote.RateRef),
|
||||
Firm: quote.Firm,
|
||||
}
|
||||
}
|
||||
|
||||
func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Decimal{Value: value.GetValue()}
|
||||
}
|
||||
|
||||
func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Decimal{Value: value.GetValue()}
|
||||
}
|
||||
|
||||
func assetFromProto(asset *chainv1.Asset) *paymenttypes.Asset {
|
||||
if asset == nil {
|
||||
return nil
|
||||
@@ -503,197 +405,3 @@ func assetToProto(asset *paymenttypes.Asset) *chainv1.Asset {
|
||||
ContractAddress: asset.ContractAddress,
|
||||
}
|
||||
}
|
||||
|
||||
func networkFeeFromProto(resp *chainv1.EstimateTransferFeeResponse) *paymenttypes.NetworkFeeEstimate {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.NetworkFeeEstimate{
|
||||
NetworkFee: moneyFromProto(resp.GetNetworkFee()),
|
||||
EstimationContext: strings.TrimSpace(resp.GetEstimationContext()),
|
||||
}
|
||||
}
|
||||
|
||||
func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
return &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: protoMoney(resp.NetworkFee),
|
||||
EstimationContext: strings.TrimSpace(resp.EstimationContext),
|
||||
}
|
||||
}
|
||||
|
||||
func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*paymenttypes.FeeLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &paymenttypes.FeeLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
|
||||
Money: moneyFromProto(line.GetMoney()),
|
||||
LineType: postingLineTypeFromProto(line.GetLineType()),
|
||||
Side: entrySideFromProto(line.GetSide()),
|
||||
Meta: cloneMetadata(line.GetMeta()),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*feesv1.DerivedPostingLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &feesv1.DerivedPostingLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
|
||||
Money: protoMoney(line.Money),
|
||||
LineType: postingLineTypeToProto(line.LineType),
|
||||
Side: entrySideToProto(line.Side),
|
||||
Meta: cloneMetadata(line.Meta),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func feeRulesFromProto(rules []*feesv1.AppliedRule) []*paymenttypes.AppliedRule {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*paymenttypes.AppliedRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &paymenttypes.AppliedRule{
|
||||
RuleID: strings.TrimSpace(rule.GetRuleId()),
|
||||
RuleVersion: strings.TrimSpace(rule.GetRuleVersion()),
|
||||
Formula: strings.TrimSpace(rule.GetFormula()),
|
||||
Rounding: roundingModeFromProto(rule.GetRounding()),
|
||||
TaxCode: strings.TrimSpace(rule.GetTaxCode()),
|
||||
TaxRate: strings.TrimSpace(rule.GetTaxRate()),
|
||||
Parameters: cloneMetadata(rule.GetParameters()),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &feesv1.AppliedRule{
|
||||
RuleId: strings.TrimSpace(rule.RuleID),
|
||||
RuleVersion: strings.TrimSpace(rule.RuleVersion),
|
||||
Formula: strings.TrimSpace(rule.Formula),
|
||||
Rounding: roundingModeToProto(rule.Rounding),
|
||||
TaxCode: strings.TrimSpace(rule.TaxCode),
|
||||
TaxRate: strings.TrimSpace(rule.TaxRate),
|
||||
Parameters: cloneMetadata(rule.Parameters),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide {
|
||||
switch side {
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
|
||||
return paymenttypes.EntrySideDebit
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
|
||||
return paymenttypes.EntrySideCredit
|
||||
default:
|
||||
return paymenttypes.EntrySideUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
|
||||
switch side {
|
||||
case paymenttypes.EntrySideDebit:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
case paymenttypes.EntrySideCredit:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
default:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType {
|
||||
switch lineType {
|
||||
case accountingv1.PostingLineType_POSTING_LINE_FEE:
|
||||
return paymenttypes.PostingLineTypeFee
|
||||
case accountingv1.PostingLineType_POSTING_LINE_TAX:
|
||||
return paymenttypes.PostingLineTypeTax
|
||||
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
|
||||
return paymenttypes.PostingLineTypeSpread
|
||||
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
|
||||
return paymenttypes.PostingLineTypeReversal
|
||||
default:
|
||||
return paymenttypes.PostingLineTypeUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
|
||||
switch lineType {
|
||||
case paymenttypes.PostingLineTypeFee:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||
case paymenttypes.PostingLineTypeTax:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||
case paymenttypes.PostingLineTypeSpread:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||
case paymenttypes.PostingLineTypeReversal:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||
default:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func roundingModeFromProto(mode moneyv1.RoundingMode) paymenttypes.RoundingMode {
|
||||
switch mode {
|
||||
case moneyv1.RoundingMode_ROUND_HALF_EVEN:
|
||||
return paymenttypes.RoundingModeHalfEven
|
||||
case moneyv1.RoundingMode_ROUND_HALF_UP:
|
||||
return paymenttypes.RoundingModeHalfUp
|
||||
case moneyv1.RoundingMode_ROUND_DOWN:
|
||||
return paymenttypes.RoundingModeDown
|
||||
default:
|
||||
return paymenttypes.RoundingModeUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode {
|
||||
switch mode {
|
||||
case paymenttypes.RoundingModeHalfEven:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||
case paymenttypes.RoundingModeHalfUp:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||
case paymenttypes.RoundingModeDown:
|
||||
return moneyv1.RoundingMode_ROUND_DOWN
|
||||
default:
|
||||
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
type discoveryGatewayRegistry struct {
|
||||
logger mlogger.Logger
|
||||
registry *discovery.Registry
|
||||
}
|
||||
|
||||
func NewDiscoveryGatewayRegistry(logger mlogger.Logger, registry *discovery.Registry) GatewayRegistry {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("discovery_gateway_registry")
|
||||
}
|
||||
return &discoveryGatewayRegistry{
|
||||
logger: logger,
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
if r == nil || r.registry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
entries := r.registry.List(time.Now(), true)
|
||||
items := make([]*model.GatewayInstanceDescriptor, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Rail == "" {
|
||||
continue
|
||||
}
|
||||
rail := railFromDiscovery(entry.Rail)
|
||||
if rail == model.RailUnspecified {
|
||||
continue
|
||||
}
|
||||
items = append(items, &model.GatewayInstanceDescriptor{
|
||||
ID: entry.ID,
|
||||
InstanceID: entry.InstanceID,
|
||||
Rail: rail,
|
||||
Network: entry.Network,
|
||||
InvokeURI: strings.TrimSpace(entry.InvokeURI),
|
||||
Currencies: normalizeCurrencies(entry.Currencies),
|
||||
Capabilities: capabilitiesFromOps(entry.Operations),
|
||||
Limits: limitsFromDiscovery(entry.Limits, entry.CurrencyMeta),
|
||||
Version: entry.Version,
|
||||
IsEnabled: entry.Healthy,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return model.LessGatewayDescriptor(items[i], items[j])
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func railFromDiscovery(value string) model.Rail {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case string(model.RailCrypto):
|
||||
return model.RailCrypto
|
||||
case string(model.RailProviderSettlement):
|
||||
return model.RailProviderSettlement
|
||||
case string(model.RailLedger):
|
||||
return model.RailLedger
|
||||
case string(model.RailCardPayout):
|
||||
return model.RailCardPayout
|
||||
case string(model.RailFiatOnRamp):
|
||||
return model.RailFiatOnRamp
|
||||
default:
|
||||
return model.RailUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func capabilitiesFromOps(ops []string) model.RailCapabilities {
|
||||
var cap model.RailCapabilities
|
||||
for _, op := range ops {
|
||||
switch strings.ToLower(strings.TrimSpace(op)) {
|
||||
case "payin.crypto", "payin.card", "payin.fiat":
|
||||
cap.CanPayIn = true
|
||||
case "payout.crypto", "payout.card", "payout.fiat":
|
||||
cap.CanPayOut = true
|
||||
case "balance.read":
|
||||
cap.CanReadBalance = true
|
||||
case "fee.send":
|
||||
cap.CanSendFee = true
|
||||
case "observe.confirm", "observe.confirmation":
|
||||
cap.RequiresObserveConfirm = true
|
||||
case "block", "funds.block", "balance.block", "ledger.block":
|
||||
cap.CanBlock = true
|
||||
case "release", "funds.release", "balance.release", "ledger.release":
|
||||
cap.CanRelease = true
|
||||
}
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
func limitsFromDiscovery(src *discovery.Limits, currencies []discovery.CurrencyAnnouncement) model.Limits {
|
||||
limits := model.Limits{
|
||||
VolumeLimit: map[string]string{},
|
||||
VelocityLimit: map[string]int{},
|
||||
CurrencyLimits: map[string]model.LimitsOverride{},
|
||||
}
|
||||
if src != nil {
|
||||
limits.MinAmount = strings.TrimSpace(src.MinAmount)
|
||||
limits.MaxAmount = strings.TrimSpace(src.MaxAmount)
|
||||
for key, value := range src.VolumeLimit {
|
||||
k := strings.TrimSpace(key)
|
||||
v := strings.TrimSpace(value)
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
limits.VolumeLimit[k] = v
|
||||
}
|
||||
for key, value := range src.VelocityLimit {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
limits.VelocityLimit[k] = value
|
||||
}
|
||||
}
|
||||
applyCurrencyTransferLimits(&limits, currencies)
|
||||
if len(limits.VolumeLimit) == 0 {
|
||||
limits.VolumeLimit = nil
|
||||
}
|
||||
if len(limits.VelocityLimit) == 0 {
|
||||
limits.VelocityLimit = nil
|
||||
}
|
||||
if len(limits.CurrencyLimits) == 0 {
|
||||
limits.CurrencyLimits = nil
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
func applyCurrencyTransferLimits(dst *model.Limits, currencies []discovery.CurrencyAnnouncement) {
|
||||
if dst == nil || len(currencies) == 0 {
|
||||
return
|
||||
}
|
||||
var (
|
||||
commonMin string
|
||||
commonMax string
|
||||
commonMinInit bool
|
||||
commonMaxInit bool
|
||||
commonMinConsistent = true
|
||||
commonMaxConsistent = true
|
||||
)
|
||||
|
||||
for _, currency := range currencies {
|
||||
code := strings.ToUpper(strings.TrimSpace(currency.Currency))
|
||||
if code == "" || currency.Limits == nil || currency.Limits.Amount == nil {
|
||||
commonMinConsistent = false
|
||||
commonMaxConsistent = false
|
||||
continue
|
||||
}
|
||||
min := strings.TrimSpace(currency.Limits.Amount.Min)
|
||||
max := strings.TrimSpace(currency.Limits.Amount.Max)
|
||||
|
||||
if min != "" || max != "" {
|
||||
override := dst.CurrencyLimits[code]
|
||||
if min != "" {
|
||||
override.MinAmount = min
|
||||
}
|
||||
if max != "" {
|
||||
override.MaxAmount = max
|
||||
}
|
||||
if override.MinAmount != "" || override.MaxAmount != "" || override.MaxFee != "" || override.MaxOps > 0 || override.MaxVolume != "" {
|
||||
dst.CurrencyLimits[code] = override
|
||||
}
|
||||
}
|
||||
|
||||
if min == "" {
|
||||
commonMinConsistent = false
|
||||
} else if !commonMinInit {
|
||||
commonMin = min
|
||||
commonMinInit = true
|
||||
} else if commonMin != min {
|
||||
commonMinConsistent = false
|
||||
}
|
||||
|
||||
if max == "" {
|
||||
commonMaxConsistent = false
|
||||
} else if !commonMaxInit {
|
||||
commonMax = max
|
||||
commonMaxInit = true
|
||||
} else if commonMax != max {
|
||||
commonMaxConsistent = false
|
||||
}
|
||||
}
|
||||
|
||||
if commonMinInit && commonMinConsistent {
|
||||
dst.PerTxMinAmount = firstLimitValue(dst.PerTxMinAmount, commonMin)
|
||||
}
|
||||
if commonMaxInit && commonMaxConsistent {
|
||||
dst.PerTxMaxAmount = firstLimitValue(dst.PerTxMaxAmount, commonMax)
|
||||
}
|
||||
}
|
||||
|
||||
func firstLimitValue(primary, fallback string) string {
|
||||
primary = strings.TrimSpace(primary)
|
||||
if primary != "" {
|
||||
return primary
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
)
|
||||
|
||||
func TestLimitsFromDiscovery_MapsPerTxMinimumFromCurrencyMeta(t *testing.T) {
|
||||
limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{
|
||||
{
|
||||
Currency: "RUB",
|
||||
Limits: &discovery.CurrencyLimits{
|
||||
Amount: &discovery.CurrencyAmount{
|
||||
Min: "100.00",
|
||||
Max: "10000.00",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if limits.PerTxMinAmount != "100.00" {
|
||||
t.Fatalf("expected per tx min 100.00, got %q", limits.PerTxMinAmount)
|
||||
}
|
||||
if limits.PerTxMaxAmount != "10000.00" {
|
||||
t.Fatalf("expected per tx max 10000.00, got %q", limits.PerTxMaxAmount)
|
||||
}
|
||||
override, ok := limits.CurrencyLimits["RUB"]
|
||||
if !ok {
|
||||
t.Fatalf("expected RUB currency override")
|
||||
}
|
||||
if override.MinAmount != "100.00" {
|
||||
t.Fatalf("expected RUB min override 100.00, got %q", override.MinAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitsFromDiscovery_DropsCommonPerTxMinimumWhenCurrenciesDiffer(t *testing.T) {
|
||||
limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{
|
||||
{
|
||||
Currency: "USD",
|
||||
Limits: &discovery.CurrencyLimits{
|
||||
Amount: &discovery.CurrencyAmount{Min: "10.00"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Currency: "EUR",
|
||||
Limits: &discovery.CurrencyLimits{
|
||||
Amount: &discovery.CurrencyAmount{Min: "20.00"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if limits.PerTxMinAmount != "" {
|
||||
t.Fatalf("expected empty common per tx min, got %q", limits.PerTxMinAmount)
|
||||
}
|
||||
if limits.CurrencyLimits["USD"].MinAmount != "10.00" {
|
||||
t.Fatalf("expected USD min override 10.00, got %q", limits.CurrencyLimits["USD"].MinAmount)
|
||||
}
|
||||
if limits.CurrencyLimits["EUR"].MinAmount != "20.00" {
|
||||
t.Fatalf("expected EUR min override 20.00, got %q", limits.CurrencyLimits["EUR"].MinAmount)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package quotation
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, consumer := range s.gatewayConsumers {
|
||||
if consumer != nil {
|
||||
consumer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,9 +217,6 @@ func (r *StaticFundingProfileResolver) gatewayKey(req FundingProfileRequest) str
|
||||
if key := normalizeGatewayKey(req.Attributes["gateway"]); key != "" {
|
||||
return key
|
||||
}
|
||||
if req.Destination != nil && req.Destination.Card != nil {
|
||||
return r.defaultCardGateway
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func TestStaticFundingProfileResolver_DefaultCardRoute(t *testing.T) {
|
||||
func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) {
|
||||
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{
|
||||
DefaultMode: model.FundingModeNone,
|
||||
CardRoutes: map[string]CardGatewayFundingRoute{
|
||||
@@ -47,6 +47,7 @@ func TestStaticFundingProfileResolver_DefaultCardRoute(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Attributes: map[string]string{
|
||||
"gateway": "monetix",
|
||||
"initiator_ref": "usr-1",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
type gatewayRegistry struct {
|
||||
logger mlogger.Logger
|
||||
static []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
// NewGatewayRegistry aggregates static gateway descriptors.
|
||||
func NewGatewayRegistry(logger mlogger.Logger, static []*model.GatewayInstanceDescriptor) GatewayRegistry {
|
||||
if len(static) == 0 {
|
||||
return nil
|
||||
}
|
||||
if logger != nil {
|
||||
logger = logger.Named("gateway_registry")
|
||||
}
|
||||
return &gatewayRegistry{
|
||||
logger: logger,
|
||||
static: cloneGatewayDescriptors(static),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
items := map[string]*model.GatewayInstanceDescriptor{}
|
||||
for _, gw := range r.static {
|
||||
key := model.GatewayDescriptorIdentityKey(gw)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
items[key] = cloneGatewayDescriptor(gw)
|
||||
}
|
||||
|
||||
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
|
||||
for _, gw := range items {
|
||||
result = append(result, gw)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return model.LessGatewayDescriptor(result[i], result[j])
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeCurrencies(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
clean := strings.ToUpper(strings.TrimSpace(value))
|
||||
if clean == "" || seen[clean] {
|
||||
continue
|
||||
}
|
||||
seen[clean] = true
|
||||
result = append(result, clean)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneGatewayDescriptors(src []*model.GatewayInstanceDescriptor) []*model.GatewayInstanceDescriptor {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*model.GatewayInstanceDescriptor, 0, len(src))
|
||||
for _, item := range src {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if cloned := cloneGatewayDescriptor(item); cloned != nil {
|
||||
result = append(result, cloned)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneGatewayDescriptor(src *model.GatewayInstanceDescriptor) *model.GatewayInstanceDescriptor {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := *src
|
||||
if src.Currencies != nil {
|
||||
dst.Currencies = append([]string(nil), src.Currencies...)
|
||||
}
|
||||
dst.Limits = cloneLimits(src.Limits)
|
||||
return &dst
|
||||
}
|
||||
|
||||
func cloneLimits(src model.Limits) model.Limits {
|
||||
dst := src
|
||||
if src.VolumeLimit != nil {
|
||||
dst.VolumeLimit = map[string]string{}
|
||||
for key, value := range src.VolumeLimit {
|
||||
dst.VolumeLimit[key] = value
|
||||
}
|
||||
}
|
||||
if src.VelocityLimit != nil {
|
||||
dst.VelocityLimit = map[string]int{}
|
||||
for key, value := range src.VelocityLimit {
|
||||
dst.VelocityLimit[key] = value
|
||||
}
|
||||
}
|
||||
if src.CurrencyLimits != nil {
|
||||
dst.CurrencyLimits = map[string]model.LimitsOverride{}
|
||||
for key, value := range src.CurrencyLimits {
|
||||
dst.CurrencyLimits[key] = value
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
type identityGatewayRegistryStub struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
func (s identityGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func TestGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) {
|
||||
registry := NewGatewayRegistry(nil, []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"},
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"},
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a-new"},
|
||||
})
|
||||
if registry == nil {
|
||||
t.Fatalf("expected registry to be created")
|
||||
}
|
||||
|
||||
items, err := registry.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := len(items), 2; got != want {
|
||||
t.Fatalf("unexpected items count: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := items[0].InstanceID, "inst-a"; got != want {
|
||||
t.Fatalf("unexpected first instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[0].InvokeURI, "grpc://a-new"; got != want {
|
||||
t.Fatalf("expected latest duplicate to win for same gateway+instance: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[1].InstanceID, "inst-b"; got != want {
|
||||
t.Fatalf("unexpected second instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) {
|
||||
registry := NewCompositeGatewayRegistry(nil,
|
||||
identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"},
|
||||
}},
|
||||
identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"},
|
||||
}},
|
||||
)
|
||||
if registry == nil {
|
||||
t.Fatalf("expected registry to be created")
|
||||
}
|
||||
|
||||
items, err := registry.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := len(items), 2; got != want {
|
||||
t.Fatalf("unexpected items count: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := items[0].InstanceID, "inst-a"; got != want {
|
||||
t.Fatalf("unexpected first instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[1].InstanceID, "inst-b"; got != want {
|
||||
t.Fatalf("unexpected second instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
|
||||
network = strings.ToUpper(strings.TrimSpace(network))
|
||||
|
||||
eligible := make([]*model.GatewayInstanceDescriptor, 0)
|
||||
var lastErr error
|
||||
for _, entry := range all {
|
||||
if entry == nil || !entry.IsEnabled {
|
||||
continue
|
||||
@@ -94,7 +93,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
|
||||
ok := true
|
||||
for _, action := range actions {
|
||||
if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil {
|
||||
lastErr = err
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
@@ -106,10 +104,11 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
|
||||
}
|
||||
|
||||
if len(eligible) == 0 {
|
||||
if lastErr != nil {
|
||||
return nil, merrors.NoData("no eligible gateway instance found: " + lastErr.Error())
|
||||
action := model.RailOperationUnspecified
|
||||
if len(actions) > 0 {
|
||||
action = actions[0]
|
||||
}
|
||||
return nil, merrors.NoData("no eligible gateway instance found")
|
||||
return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir)))
|
||||
}
|
||||
sort.Slice(eligible, func(i, j int) bool {
|
||||
return eligible[i].ID < eligible[j].ID
|
||||
@@ -124,6 +123,17 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
|
||||
return eligible[0], nil
|
||||
}
|
||||
|
||||
func toGatewayDirection(dir plan.SendDirection) model.GatewayDirection {
|
||||
switch dir {
|
||||
case plan.SendDirectionOut:
|
||||
return model.GatewayDirectionOut
|
||||
case plan.SendDirectionIn:
|
||||
return model.GatewayDirectionIn
|
||||
default:
|
||||
return model.GatewayDirectionAny
|
||||
}
|
||||
}
|
||||
|
||||
func railActionNames(actions []model.RailOperation) []string {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -1,640 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type quotePaymentCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
errIdempotencyRequired = errors.New("idempotency key is required")
|
||||
errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
|
||||
errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
)
|
||||
|
||||
type quoteCtx struct {
|
||||
orgID string
|
||||
orgRef bson.ObjectID
|
||||
intent *sharedv1.PaymentIntent
|
||||
previewOnly bool
|
||||
idempotencyKey string
|
||||
hash string
|
||||
}
|
||||
|
||||
type quotePaymentResult struct {
|
||||
quote *sharedv1.PaymentQuote
|
||||
executionNote string
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) Execute(
|
||||
ctx context.Context,
|
||||
req *quotationv1.QuotePaymentRequest,
|
||||
) gsresponse.Responder[quotationv1.QuotePaymentResponse] {
|
||||
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
qc, err := h.prepareQuoteCtx(req)
|
||||
if err != nil {
|
||||
return h.mapQuoteErr(err)
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
result, err := h.quotePayment(ctx, quotesStore, qc, req)
|
||||
if err != nil {
|
||||
return h.mapQuoteErr(err)
|
||||
}
|
||||
|
||||
return gsresponse.Success("ationv1.QuotePaymentResponse{
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Quote: result.quote,
|
||||
ExecutionNote: result.executionNote,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) prepareQuoteCtx(req *quotationv1.QuotePaymentRequest) (*quoteCtx, error) {
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireNonNilIntent(req.GetIntent()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intent := req.GetIntent()
|
||||
preview := req.GetPreviewOnly()
|
||||
idem := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
|
||||
if preview && idem != "" {
|
||||
return nil, errPreviewWithIdempotency
|
||||
}
|
||||
if !preview && idem == "" {
|
||||
return nil, errIdempotencyRequired
|
||||
}
|
||||
|
||||
return "eCtx{
|
||||
orgID: orgRef,
|
||||
orgRef: orgID,
|
||||
intent: intent,
|
||||
previewOnly: preview,
|
||||
idempotencyKey: idem,
|
||||
hash: hashQuoteRequest(req),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) quotePayment(
|
||||
ctx context.Context,
|
||||
quotesStore quotestorage.QuotesStore,
|
||||
qc *quoteCtx,
|
||||
req *quotationv1.QuotePaymentRequest,
|
||||
) (*quotePaymentResult, error) {
|
||||
|
||||
if qc.previewOnly {
|
||||
quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
|
||||
if err != nil {
|
||||
h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID))
|
||||
return nil, err
|
||||
}
|
||||
quote.QuoteRef = bson.NewObjectID().Hex()
|
||||
return "ePaymentResult{quote: quote}, nil
|
||||
}
|
||||
|
||||
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil && !errors.Is(err, quotestorage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) {
|
||||
h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Idempotent quote reused",
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("quote_ref", existing.QuoteRef),
|
||||
)
|
||||
return "ePaymentResult{
|
||||
quote: modelQuoteToProto(existing.Quote),
|
||||
executionNote: strings.TrimSpace(existing.ExecutionNote),
|
||||
}, nil
|
||||
}
|
||||
|
||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment quote",
|
||||
zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quoteRef := bson.NewObjectID().Hex()
|
||||
quote.QuoteRef = quoteRef
|
||||
|
||||
executionNote := ""
|
||||
plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
executionNote = quoteNonExecutableNote(err)
|
||||
h.logger.Info(
|
||||
"Payment quote marked as non-executable",
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.String("execution_note", executionNote),
|
||||
)
|
||||
} else {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment plan",
|
||||
zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: qc.idempotencyKey,
|
||||
Hash: qc.hash,
|
||||
Intent: intentFromProto(qc.intent),
|
||||
Quote: quoteSnapshotToModel(quote),
|
||||
Plan: cloneStoredPaymentPlan(plan),
|
||||
ExecutionNote: executionNote,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(qc.orgRef)
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, quotestorage.ErrDuplicateQuote) {
|
||||
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if getErr == nil && existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
}
|
||||
return "ePaymentResult{
|
||||
quote: modelQuoteToProto(existing.Quote),
|
||||
executionNote: strings.TrimSpace(existing.ExecutionNote),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Stored payment quote",
|
||||
zap.String("quote_ref", quoteRef),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("kind", qc.intent.GetKind().String()),
|
||||
)
|
||||
|
||||
return "ePaymentResult{
|
||||
quote: quote,
|
||||
executionNote: executionNote,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotationv1.QuotePaymentResponse] {
|
||||
if errors.Is(err, errIdempotencyRequired) ||
|
||||
errors.Is(err, errPreviewWithIdempotency) ||
|
||||
errors.Is(err, errIdempotencyParamMismatch) {
|
||||
return gsresponse.InvalidArgument[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
func quoteNonExecutableNote(err error) string {
|
||||
reason := strings.TrimSpace(err.Error())
|
||||
reason = strings.TrimPrefix(reason, merrors.ErrInvalidArg.Error()+":")
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return "quote will not be executed"
|
||||
}
|
||||
return "quote will not be executed: " + reason
|
||||
}
|
||||
|
||||
// TODO: temprorarary hashing function, replace with a proper solution later
|
||||
func hashQuoteRequest(req *quotationv1.QuotePaymentRequest) string {
|
||||
cloned := proto.Clone(req).(*quotationv1.QuotePaymentRequest)
|
||||
cloned.Meta = nil
|
||||
cloned.IdempotencyKey = ""
|
||||
cloned.PreviewOnly = false
|
||||
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned)
|
||||
if err != nil {
|
||||
sum := sha256.Sum256([]byte("marshal_error"))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type quotePaymentsCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
errBatchIdempotencyRequired = errors.New("idempotency key is required")
|
||||
errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
|
||||
errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape")
|
||||
)
|
||||
|
||||
type quotePaymentsCtx struct {
|
||||
orgID string
|
||||
orgRef bson.ObjectID
|
||||
previewOnly bool
|
||||
idempotencyKey string
|
||||
hash string
|
||||
intentCount int
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) Execute(
|
||||
ctx context.Context,
|
||||
req *quotationv1.QuotePaymentsRequest,
|
||||
) gsresponse.Responder[quotationv1.QuotePaymentsResponse] {
|
||||
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
qc, intents, err := h.prepare(req)
|
||||
if err != nil {
|
||||
return h.mapErr(err)
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if qc.previewOnly {
|
||||
quotes, _, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, true)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
_ = expiresAt
|
||||
return gsresponse.Success("ationv1.QuotePaymentsResponse{
|
||||
QuoteRef: "",
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
})
|
||||
}
|
||||
|
||||
if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil {
|
||||
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
} else if ok {
|
||||
return gsresponse.Success(h.responseFromRecord(rec))
|
||||
}
|
||||
|
||||
quotes, plans, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, false)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteRef := bson.NewObjectID().Hex()
|
||||
for _, q := range quotes {
|
||||
if q != nil {
|
||||
q.QuoteRef = quoteRef
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, plans, expiresAt)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if rec != nil {
|
||||
return gsresponse.Success(h.responseFromRecord(rec))
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Stored payment quotes",
|
||||
h.logFields(qc, quoteRef, expiresAt, len(quotes))...,
|
||||
)
|
||||
|
||||
return gsresponse.Success("ationv1.QuotePaymentsResponse{
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
QuoteRef: quoteRef,
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) prepare(req *quotationv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*sharedv1.PaymentIntent, error) {
|
||||
orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
intents := req.GetIntents()
|
||||
if len(intents) == 0 {
|
||||
return nil, nil, merrors.InvalidArgument("intents are required")
|
||||
}
|
||||
for _, intent := range intents {
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
preview := req.GetPreviewOnly()
|
||||
idem := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
|
||||
if preview && idem != "" {
|
||||
return nil, nil, errBatchPreviewWithIdempotency
|
||||
}
|
||||
if !preview && idem == "" {
|
||||
return nil, nil, errBatchIdempotencyRequired
|
||||
}
|
||||
|
||||
hash, err := hashQuotePaymentsIntents(intents)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return "ePaymentsCtx{
|
||||
orgID: orgRefStr,
|
||||
orgRef: orgID,
|
||||
previewOnly: preview,
|
||||
idempotencyKey: idem,
|
||||
hash: hash,
|
||||
intentCount: len(intents),
|
||||
}, intents, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) tryReuse(
|
||||
ctx context.Context,
|
||||
quotesStore quotestorage.QuotesStore,
|
||||
qc *quotePaymentsCtx,
|
||||
) (*model.PaymentQuoteRecord, bool, error) {
|
||||
|
||||
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, quotestorage.ErrQuoteNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
h.logger.Warn(
|
||||
"Failed to lookup payment quotes by idempotency key",
|
||||
h.logFields(qc, "", time.Time{}, 0)...,
|
||||
)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(rec.Quotes) == 0 {
|
||||
return nil, false, errBatchIdempotencyShapeMismatch
|
||||
}
|
||||
if rec.Hash != qc.hash {
|
||||
return nil, false, errBatchIdempotencyParamMismatch
|
||||
}
|
||||
|
||||
h.logger.Debug(
|
||||
"Idempotent payment quotes reused",
|
||||
h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))...,
|
||||
)
|
||||
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) buildQuotes(
|
||||
ctx context.Context,
|
||||
meta *sharedv1.RequestMeta,
|
||||
orgRef bson.ObjectID,
|
||||
baseKey string,
|
||||
intents []*sharedv1.PaymentIntent,
|
||||
preview bool,
|
||||
) ([]*sharedv1.PaymentQuote, []*model.PaymentPlan, []time.Time, error) {
|
||||
|
||||
quotes := make([]*sharedv1.PaymentQuote, 0, len(intents))
|
||||
plans := make([]*model.PaymentPlan, 0, len(intents))
|
||||
expires := make([]time.Time, 0, len(intents))
|
||||
|
||||
for i, intent := range intents {
|
||||
perKey := perIntentIdempotencyKey(baseKey, i, len(intents))
|
||||
req := "ationv1.QuotePaymentRequest{
|
||||
Meta: meta,
|
||||
IdempotencyKey: perKey,
|
||||
Intent: intent,
|
||||
PreviewOnly: preview,
|
||||
}
|
||||
q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment quote (batch item)",
|
||||
zap.Int("idx", i),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if !preview {
|
||||
plan, err := h.engine.BuildPaymentPlan(ctx, orgRef, intent, perKey, q)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment plan (batch item)",
|
||||
zap.Int("idx", i),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
plans = append(plans, cloneStoredPaymentPlan(plan))
|
||||
}
|
||||
quotes = append(quotes, q)
|
||||
expires = append(expires, exp)
|
||||
}
|
||||
|
||||
return quotes, plans, expires, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) aggregate(
|
||||
quotes []*sharedv1.PaymentQuote,
|
||||
expires []time.Time,
|
||||
) (*sharedv1.PaymentQuoteAggregate, time.Time, error) {
|
||||
|
||||
agg, err := aggregatePaymentQuotes(quotes)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed")
|
||||
}
|
||||
|
||||
expiresAt, ok := minQuoteExpiry(expires)
|
||||
if !ok {
|
||||
return nil, time.Time{}, merrors.Internal("quote expiry missing")
|
||||
}
|
||||
|
||||
return agg, expiresAt, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) storeBatch(
|
||||
ctx context.Context,
|
||||
quotesStore quotestorage.QuotesStore,
|
||||
qc *quotePaymentsCtx,
|
||||
quoteRef string,
|
||||
intents []*sharedv1.PaymentIntent,
|
||||
quotes []*sharedv1.PaymentQuote,
|
||||
plans []*model.PaymentPlan,
|
||||
expiresAt time.Time,
|
||||
) (*model.PaymentQuoteRecord, error) {
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: qc.idempotencyKey,
|
||||
Hash: qc.hash,
|
||||
Intents: intentsFromProto(intents),
|
||||
Quotes: quoteSnapshotsFromProto(quotes),
|
||||
Plans: cloneStoredPaymentPlans(plans),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(qc.orgRef)
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, quotestorage.ErrDuplicateQuote) {
|
||||
rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc)
|
||||
if reuseErr != nil {
|
||||
return nil, reuseErr
|
||||
}
|
||||
if ok {
|
||||
return rec, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *quotationv1.QuotePaymentsResponse {
|
||||
quotes := modelQuotesToProto(rec.Quotes)
|
||||
for _, q := range quotes {
|
||||
if q != nil {
|
||||
q.QuoteRef = rec.QuoteRef
|
||||
}
|
||||
}
|
||||
aggregate, _ := aggregatePaymentQuotes(quotes)
|
||||
|
||||
return "ationv1.QuotePaymentsResponse{
|
||||
QuoteRef: rec.QuoteRef,
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field {
|
||||
fields := []zap.Field{
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("org_ref_str", qc.orgID),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("hash", qc.hash),
|
||||
zap.Bool("preview_only", qc.previewOnly),
|
||||
zap.Int("intent_count", qc.intentCount),
|
||||
}
|
||||
if quoteRef != "" {
|
||||
fields = append(fields, zap.String("quote_ref", quoteRef))
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
fields = append(fields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if quoteCount > 0 {
|
||||
fields = append(fields, zap.Int("quote_count", quoteCount))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[quotationv1.QuotePaymentsResponse] {
|
||||
if errors.Is(err, errBatchIdempotencyRequired) ||
|
||||
errors.Is(err, errBatchPreviewWithIdempotency) ||
|
||||
errors.Is(err, errBatchIdempotencyParamMismatch) ||
|
||||
errors.Is(err, errBatchIdempotencyShapeMismatch) {
|
||||
return gsresponse.InvalidArgument[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*sharedv1.PaymentQuote {
|
||||
if len(snaps) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*sharedv1.PaymentQuote, 0, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out = append(out, modelQuoteToProto(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hashQuotePaymentsIntents(intents []*sharedv1.PaymentIntent) (string, error) {
|
||||
type item struct {
|
||||
Idx int
|
||||
H [32]byte
|
||||
}
|
||||
items := make([]item, 0, len(intents))
|
||||
|
||||
for i, intent := range intents {
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items = append(items, item{Idx: i, H: sha256.Sum256(b)})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx })
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte("quote-payments-fp/v1"))
|
||||
h.Write([]byte{0})
|
||||
for _, it := range items {
|
||||
h.Write(it.H[:])
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestQuotePaymentStoresNonExecutableQuoteWhenPlanInvalid(t *testing.T) {
|
||||
org := bson.NewObjectID()
|
||||
req := "ationv1.QuotePaymentRequest{
|
||||
Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
IdempotencyKey: "idem-1",
|
||||
Intent: &sharedv1.PaymentIntent{
|
||||
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
quotesStore := "eCommandTestQuotesStore{
|
||||
byID: make(map[string]*model.PaymentQuoteRecord),
|
||||
}
|
||||
engine := "eCommandTestEngine{
|
||||
repo: quoteCommandTestRepo{quotes: quotesStore},
|
||||
buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
|
||||
return &sharedv1.PaymentQuote{
|
||||
DebitAmount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
}, time.Now().Add(time.Hour), nil
|
||||
},
|
||||
buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
|
||||
return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: gateway mntx eligibility check error: amount 1 USD below per-tx min limit 10")
|
||||
},
|
||||
}
|
||||
cmd := "ePaymentCommand{
|
||||
engine: engine,
|
||||
logger: mloggerfactory.NewLogger(false),
|
||||
}
|
||||
|
||||
resp, err := cmd.Execute(context.Background(), req)(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.GetQuote() == nil {
|
||||
t.Fatalf("expected quote response, got %#v", resp)
|
||||
}
|
||||
if note := resp.GetExecutionNote(); !strings.Contains(note, "quote will not be executed") {
|
||||
t.Fatalf("expected non-executable note, got %q", note)
|
||||
}
|
||||
|
||||
stored := quotesStore.byID[req.GetIdempotencyKey()]
|
||||
if stored == nil {
|
||||
t.Fatalf("expected stored quote record")
|
||||
}
|
||||
if stored.Plan != nil {
|
||||
t.Fatalf("expected no stored payment plan for non-executable quote")
|
||||
}
|
||||
if stored.ExecutionNote != resp.GetExecutionNote() {
|
||||
t.Fatalf("expected stored execution note %q, got %q", resp.GetExecutionNote(), stored.ExecutionNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotePaymentReuseReturnsStoredExecutionNote(t *testing.T) {
|
||||
org := bson.NewObjectID()
|
||||
req := "ationv1.QuotePaymentRequest{
|
||||
Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
IdempotencyKey: "idem-1",
|
||||
Intent: &sharedv1.PaymentIntent{
|
||||
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
existing := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Hash: hashQuoteRequest(req),
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
ExecutionNote: "quote will not be executed: amount 1 USD below per-tx min limit 10",
|
||||
}
|
||||
quotesStore := "eCommandTestQuotesStore{
|
||||
byID: map[string]*model.PaymentQuoteRecord{
|
||||
req.GetIdempotencyKey(): existing,
|
||||
},
|
||||
}
|
||||
engine := "eCommandTestEngine{
|
||||
repo: quoteCommandTestRepo{quotes: quotesStore},
|
||||
buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
|
||||
t.Fatalf("build quote should not be called on idempotent reuse")
|
||||
return nil, time.Time{}, nil
|
||||
},
|
||||
buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
|
||||
t.Fatalf("build plan should not be called on idempotent reuse")
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
cmd := "ePaymentCommand{
|
||||
engine: engine,
|
||||
logger: mloggerfactory.NewLogger(false),
|
||||
}
|
||||
|
||||
resp, err := cmd.Execute(context.Background(), req)(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatalf("expected response")
|
||||
}
|
||||
if got, want := resp.GetExecutionNote(), existing.ExecutionNote; got != want {
|
||||
t.Fatalf("expected execution note %q, got %q", want, got)
|
||||
}
|
||||
if resp.GetQuote().GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected quote_ref q1, got %q", resp.GetQuote().GetQuoteRef())
|
||||
}
|
||||
}
|
||||
|
||||
type quoteCommandTestEngine struct {
|
||||
repo storage.Repository
|
||||
ensureErr error
|
||||
buildQuoteFn func(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error)
|
||||
buildPlanFn func(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error)
|
||||
}
|
||||
|
||||
func (e *quoteCommandTestEngine) EnsureRepository(context.Context) error { return e.ensureErr }
|
||||
|
||||
func (e *quoteCommandTestEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
|
||||
if e.buildQuoteFn == nil {
|
||||
return nil, time.Time{}, nil
|
||||
}
|
||||
return e.buildQuoteFn(ctx, orgRef, req)
|
||||
}
|
||||
|
||||
func (e *quoteCommandTestEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
|
||||
if e.buildPlanFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.buildPlanFn(ctx, orgID, intent, idempotencyKey, quote)
|
||||
}
|
||||
|
||||
func (e *quoteCommandTestEngine) ResolvePaymentQuote(context.Context, quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
func (e *quoteCommandTestEngine) Repository() storage.Repository { return e.repo }
|
||||
|
||||
type quoteCommandTestRepo struct {
|
||||
quotes quotestorage.QuotesStore
|
||||
}
|
||||
|
||||
func (r quoteCommandTestRepo) Ping(context.Context) error { return nil }
|
||||
func (r quoteCommandTestRepo) Payments() storage.PaymentsStore { return nil }
|
||||
func (r quoteCommandTestRepo) PaymentMethods() storage.PaymentMethodsStore { return nil }
|
||||
func (r quoteCommandTestRepo) Quotes() quotestorage.QuotesStore { return r.quotes }
|
||||
func (r quoteCommandTestRepo) Routes() storage.RoutesStore { return nil }
|
||||
func (r quoteCommandTestRepo) PlanTemplates() storage.PlanTemplatesStore { return nil }
|
||||
|
||||
type quoteCommandTestQuotesStore struct {
|
||||
byID map[string]*model.PaymentQuoteRecord
|
||||
}
|
||||
|
||||
func (s *quoteCommandTestQuotesStore) Create(_ context.Context, rec *model.PaymentQuoteRecord) error {
|
||||
if s.byID == nil {
|
||||
s.byID = make(map[string]*model.PaymentQuoteRecord)
|
||||
}
|
||||
s.byID[rec.IdempotencyKey] = rec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *quoteCommandTestQuotesStore) GetByRef(_ context.Context, _ bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||
for _, rec := range s.byID {
|
||||
if rec != nil && rec.QuoteRef == quoteRef {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
|
||||
func (s *quoteCommandTestQuotesStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
|
||||
if rec, ok := s.byID[idempotencyKey]; ok {
|
||||
return rec, nil
|
||||
}
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
@@ -51,24 +51,6 @@ func cloneMetadata(input map[string]string) map[string]string {
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneStringList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, clean)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -5,20 +5,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
if s.storage == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
return s.storage.Ping(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
if d <= 0 {
|
||||
return context.WithCancel(ctx)
|
||||
@@ -26,13 +17,6 @@ func (s *Service) withTimeout(ctx context.Context, d time.Duration) (context.Con
|
||||
return context.WithTimeout(ctx, d)
|
||||
}
|
||||
|
||||
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||
start := svc.clock.Now()
|
||||
resp, err := gsresponse.Unary(svc.logger, mservice.PaymentOrchestrator, handler)(ctx, req)
|
||||
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func triggerFromKind(kind sharedv1.PaymentKind, requiresFX bool) feesv1.Trigger {
|
||||
switch kind {
|
||||
case sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT:
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsOnce sync.Once
|
||||
|
||||
rpcLatency *prometheus.HistogramVec
|
||||
rpcStatus *prometheus.CounterVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricsOnce.Do(func() {
|
||||
rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "payment_orchestrator",
|
||||
Name: "rpc_latency_seconds",
|
||||
Help: "Latency distribution for payment orchestrator RPC handlers.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"method"})
|
||||
|
||||
rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "payment_orchestrator",
|
||||
Name: "rpc_requests_total",
|
||||
Help: "Total number of RPC invocations grouped by method and status.",
|
||||
}, []string{"method", "status"})
|
||||
})
|
||||
}
|
||||
|
||||
func observeRPC(method string, err error, duration time.Duration) {
|
||||
if rpcLatency != nil {
|
||||
rpcLatency.WithLabelValues(method).Observe(duration.Seconds())
|
||||
}
|
||||
if rpcStatus != nil {
|
||||
rpcStatus.WithLabelValues(method, statusLabel(err)).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
func statusLabel(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "ok"
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return "invalid_argument"
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return "not_found"
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return "conflict"
|
||||
case errors.Is(err, merrors.ErrAccessDenied):
|
||||
return "denied"
|
||||
case errors.Is(err, merrors.ErrInternal):
|
||||
return "internal"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
|
||||
func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Currency: input.GetCurrency(),
|
||||
Amount: input.GetAmount(),
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,8 +10,7 @@ import (
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
@@ -31,6 +29,18 @@ type ChainGatewayResolver interface {
|
||||
Resolve(ctx context.Context, network string) (chainclient.Client, error)
|
||||
}
|
||||
|
||||
// GatewayRegistry exposes gateway instances for capability-based selection.
|
||||
type GatewayRegistry interface {
|
||||
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
|
||||
}
|
||||
|
||||
// CardGatewayRoute maps a gateway to its funding and fee destinations.
|
||||
type CardGatewayRoute struct {
|
||||
FundingAddress string
|
||||
FeeAddress string
|
||||
FeeWalletRef string
|
||||
}
|
||||
|
||||
type feesDependency struct {
|
||||
client feesv1.FeeEngineClient
|
||||
timeout time.Duration
|
||||
@@ -46,24 +56,10 @@ func (f feesDependency) available() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type ledgerDependency struct {
|
||||
client ledgerclient.Client
|
||||
internal rail.InternalLedger
|
||||
}
|
||||
|
||||
type gatewayDependency struct {
|
||||
resolver ChainGatewayResolver
|
||||
}
|
||||
|
||||
type railGatewayDependency struct {
|
||||
byID map[string]rail.RailGateway
|
||||
byRail map[model.Rail][]rail.RailGateway
|
||||
registry GatewayRegistry
|
||||
chainResolver GatewayInvokeResolver
|
||||
providerResolver GatewayInvokeResolver
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
type oracleDependency struct {
|
||||
client oracleclient.Client
|
||||
}
|
||||
@@ -78,53 +74,25 @@ func (o oracleDependency) available() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type providerGatewayDependency struct {
|
||||
resolver ChainGatewayResolver
|
||||
}
|
||||
|
||||
type staticChainGatewayResolver struct {
|
||||
client chainclient.Client
|
||||
}
|
||||
|
||||
func (r staticChainGatewayResolver) Resolve(ctx context.Context, _ string) (chainclient.Client, error) {
|
||||
if r.client == nil {
|
||||
return nil, merrors.InvalidArgument("chain gateway client is required")
|
||||
}
|
||||
func (r staticChainGatewayResolver) Resolve(context.Context, string) (chainclient.Client, error) {
|
||||
return r.client, nil
|
||||
}
|
||||
|
||||
// CardGatewayRoute maps a gateway to its funding and fee destinations.
|
||||
type CardGatewayRoute struct {
|
||||
FundingAddress string
|
||||
FeeAddress string
|
||||
FeeWalletRef string
|
||||
}
|
||||
|
||||
// WithFeeEngine wires the fee engine client.
|
||||
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
s.deps.fees = feesDependency{
|
||||
client: client,
|
||||
timeout: timeout,
|
||||
}
|
||||
s.deps.fees = feesDependency{client: client, timeout: timeout}
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaymentGatewayBroker(broker mb.Broker) Option {
|
||||
// WithOracleClient wires the FX oracle client.
|
||||
func WithOracleClient(client oracleclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
if broker != nil {
|
||||
s.gatewayBroker = broker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLedgerClient wires the ledger client.
|
||||
func WithLedgerClient(client ledgerclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.deps.ledger = ledgerDependency{
|
||||
client: client,
|
||||
internal: client,
|
||||
}
|
||||
s.deps.oracle = oracleDependency{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,48 +112,21 @@ func WithChainGatewayResolver(resolver ChainGatewayResolver) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithProviderSettlementGatewayClient wires the provider settlement gateway client.
|
||||
func WithProviderSettlementGatewayClient(client chainclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.deps.providerGateway = providerGatewayDependency{resolver: staticChainGatewayResolver{client: client}}
|
||||
}
|
||||
}
|
||||
|
||||
// WithProviderSettlementGatewayResolver wires a resolver for provider settlement gateway clients.
|
||||
func WithProviderSettlementGatewayResolver(resolver ChainGatewayResolver) Option {
|
||||
return func(s *Service) {
|
||||
if resolver != nil {
|
||||
s.deps.providerGateway = providerGatewayDependency{resolver: resolver}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithGatewayInvokeResolver wires a resolver for gateway invoke URIs.
|
||||
// WithGatewayInvokeResolver wires a resolver for invoke URIs.
|
||||
func WithGatewayInvokeResolver(resolver GatewayInvokeResolver) Option {
|
||||
return func(s *Service) {
|
||||
if resolver == nil {
|
||||
return
|
||||
if resolver != nil {
|
||||
s.deps.gatewayInvokeResolver = resolver
|
||||
}
|
||||
s.deps.gatewayInvokeResolver = resolver
|
||||
s.deps.railGateways.chainResolver = resolver
|
||||
s.deps.railGateways.providerResolver = resolver
|
||||
}
|
||||
}
|
||||
|
||||
// WithRailGateways wires rail gateway adapters by instance ID.
|
||||
func WithRailGateways(gateways map[string]rail.RailGateway) Option {
|
||||
// WithGatewayRegistry wires gateway descriptors used by quote computation/gateway selection.
|
||||
func WithGatewayRegistry(registry GatewayRegistry) Option {
|
||||
return func(s *Service) {
|
||||
if len(gateways) == 0 {
|
||||
return
|
||||
if registry != nil {
|
||||
s.deps.gatewayRegistry = registry
|
||||
}
|
||||
s.deps.railGateways = buildRailGatewayDependency(gateways, s.deps.gatewayRegistry, s.deps.gatewayInvokeResolver, s.deps.gatewayInvokeResolver, s.logger)
|
||||
}
|
||||
}
|
||||
|
||||
// WithOracleClient wires the FX oracle client.
|
||||
func WithOracleClient(client oracleclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.deps.oracle = oracleDependency{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,51 +137,30 @@ func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
|
||||
return
|
||||
}
|
||||
s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes))
|
||||
for k, v := range routes {
|
||||
s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees.
|
||||
func WithFeeLedgerAccounts(routes map[string]string) Option {
|
||||
return func(s *Service) {
|
||||
if len(routes) == 0 {
|
||||
return
|
||||
}
|
||||
s.deps.feeLedgerAccounts = make(map[string]string, len(routes))
|
||||
for k, v := range routes {
|
||||
key := strings.ToLower(strings.TrimSpace(k))
|
||||
val := strings.TrimSpace(v)
|
||||
if key == "" || val == "" {
|
||||
for key, route := range routes {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
s.deps.feeLedgerAccounts[key] = val
|
||||
s.deps.cardRoutes[normalized] = route
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithPlanBuilder wires a payment plan builder implementation.
|
||||
func WithPlanBuilder(builder PlanBuilder) Option {
|
||||
// WithFeeLedgerAccounts maps gateway IDs to fee ledger accounts.
|
||||
func WithFeeLedgerAccounts(accounts map[string]string) Option {
|
||||
return func(s *Service) {
|
||||
if builder != nil {
|
||||
s.deps.planBuilder = builder
|
||||
if len(accounts) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithGatewayRegistry wires a registry of gateway instances for routing.
|
||||
func WithGatewayRegistry(registry GatewayRegistry) Option {
|
||||
return func(s *Service) {
|
||||
if registry != nil {
|
||||
s.deps.gatewayRegistry = registry
|
||||
s.deps.railGateways.registry = registry
|
||||
s.deps.railGateways.chainResolver = s.deps.gatewayInvokeResolver
|
||||
s.deps.railGateways.providerResolver = s.deps.gatewayInvokeResolver
|
||||
s.deps.railGateways.logger = s.logger.Named("rail_gateways")
|
||||
if s.deps.planBuilder == nil {
|
||||
s.deps.planBuilder = newDefaultPlanBuilder(s.logger)
|
||||
s.deps.feeLedgerAccounts = make(map[string]string, len(accounts))
|
||||
for key, account := range accounts {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
value := strings.TrimSpace(account)
|
||||
if normalized == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
s.deps.feeLedgerAccounts[normalized] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,46 +174,139 @@ func WithClock(clock clockpkg.Clock) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainResolver GatewayInvokeResolver, providerResolver GatewayInvokeResolver, logger mlogger.Logger) railGatewayDependency {
|
||||
result := railGatewayDependency{
|
||||
byID: map[string]rail.RailGateway{},
|
||||
byRail: map[model.Rail][]rail.RailGateway{},
|
||||
registry: registry,
|
||||
chainResolver: chainResolver,
|
||||
providerResolver: providerResolver,
|
||||
logger: logger,
|
||||
// WithLedgerClient is retained for backward compatibility and is currently a no-op.
|
||||
func WithLedgerClient(_ ledgerclient.Client) Option {
|
||||
return func(*Service) {}
|
||||
}
|
||||
|
||||
// WithProviderSettlementGatewayClient is retained for backward compatibility and is currently a no-op.
|
||||
func WithProviderSettlementGatewayClient(_ chainclient.Client) Option {
|
||||
return func(*Service) {}
|
||||
}
|
||||
|
||||
// WithProviderSettlementGatewayResolver is retained for backward compatibility and is currently a no-op.
|
||||
func WithProviderSettlementGatewayResolver(_ ChainGatewayResolver) Option {
|
||||
return func(*Service) {}
|
||||
}
|
||||
|
||||
// WithRailGateways is retained for backward compatibility and is currently a no-op.
|
||||
func WithRailGateways(_ map[string]rail.RailGateway) Option {
|
||||
return func(*Service) {}
|
||||
}
|
||||
|
||||
type discoveryGatewayRegistry struct {
|
||||
registry *discovery.Registry
|
||||
}
|
||||
|
||||
// NewDiscoveryGatewayRegistry adapts discovery entries into gateway descriptors.
|
||||
func NewDiscoveryGatewayRegistry(_ mlogger.Logger, registry *discovery.Registry) GatewayRegistry {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
if len(gateways) == 0 {
|
||||
return result
|
||||
return &discoveryGatewayRegistry{registry: registry}
|
||||
}
|
||||
|
||||
func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
if r == nil || r.registry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type item struct {
|
||||
id string
|
||||
gw rail.RailGateway
|
||||
}
|
||||
itemsByRail := map[model.Rail][]item{}
|
||||
|
||||
for id, gw := range gateways {
|
||||
cleanID := strings.TrimSpace(id)
|
||||
if cleanID == "" || gw == nil {
|
||||
continue
|
||||
}
|
||||
result.byID[cleanID] = gw
|
||||
railID := parseRailValue(gw.Rail())
|
||||
entries := r.registry.List(time.Now(), true)
|
||||
items := make([]*model.GatewayInstanceDescriptor, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
railID := railFromDiscovery(entry.Rail)
|
||||
if railID == model.RailUnspecified {
|
||||
continue
|
||||
}
|
||||
itemsByRail[railID] = append(itemsByRail[railID], item{id: cleanID, gw: gw})
|
||||
operations := operationsFromDiscovery(entry.Operations)
|
||||
items = append(items, &model.GatewayInstanceDescriptor{
|
||||
ID: strings.TrimSpace(entry.ID),
|
||||
InstanceID: strings.TrimSpace(entry.InstanceID),
|
||||
Rail: railID,
|
||||
Network: strings.ToUpper(strings.TrimSpace(entry.Network)),
|
||||
InvokeURI: strings.TrimSpace(entry.InvokeURI),
|
||||
Currencies: currenciesFromDiscovery(entry.Currencies),
|
||||
Operations: operations,
|
||||
Capabilities: model.RailCapabilitiesFromOperations(operations),
|
||||
Limits: limitsFromDiscovery(entry.Limits),
|
||||
IsEnabled: entry.Healthy,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func railFromDiscovery(value string) model.Rail {
|
||||
switch discovery.NormalizeRail(value) {
|
||||
case discovery.RailCrypto:
|
||||
return model.RailCrypto
|
||||
case discovery.RailProviderSettlement:
|
||||
return model.RailProviderSettlement
|
||||
case discovery.RailLedger:
|
||||
return model.RailLedger
|
||||
case discovery.RailCardPayout:
|
||||
return model.RailCardPayout
|
||||
case discovery.RailFiatOnRamp:
|
||||
return model.RailFiatOnRamp
|
||||
default:
|
||||
return model.RailUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func operationsFromDiscovery(values []string) []model.RailOperation {
|
||||
return model.NormalizeRailOperationStrings(discovery.NormalizeRailOperations(values))
|
||||
}
|
||||
|
||||
func currenciesFromDiscovery(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
currency := strings.ToUpper(strings.TrimSpace(value))
|
||||
if currency == "" || seen[currency] {
|
||||
continue
|
||||
}
|
||||
seen[currency] = true
|
||||
result = append(result, currency)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func limitsFromDiscovery(src *discovery.Limits) model.Limits {
|
||||
limits := model.Limits{}
|
||||
if src == nil {
|
||||
return limits
|
||||
}
|
||||
|
||||
for railID, items := range itemsByRail {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].id < items[j].id
|
||||
})
|
||||
for _, entry := range items {
|
||||
result.byRail[railID] = append(result.byRail[railID], entry.gw)
|
||||
limits.MinAmount = strings.TrimSpace(src.MinAmount)
|
||||
limits.MaxAmount = strings.TrimSpace(src.MaxAmount)
|
||||
|
||||
if len(src.VolumeLimit) > 0 {
|
||||
limits.VolumeLimit = map[string]string{}
|
||||
for bucket, value := range src.VolumeLimit {
|
||||
key := strings.TrimSpace(bucket)
|
||||
amount := strings.TrimSpace(value)
|
||||
if key == "" || amount == "" {
|
||||
continue
|
||||
}
|
||||
limits.VolumeLimit[key] = amount
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
if len(src.VelocityLimit) > 0 {
|
||||
limits.VelocityLimit = map[string]int{}
|
||||
for bucket, value := range src.VelocityLimit {
|
||||
key := strings.TrimSpace(bucket)
|
||||
if key == "" || value <= 0 {
|
||||
continue
|
||||
}
|
||||
limits.VelocityLimit[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return limits
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/shared"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (s *Service) buildPaymentPlan(
|
||||
ctx context.Context,
|
||||
orgID bson.ObjectID,
|
||||
intent *sharedv1.PaymentIntent,
|
||||
idempotencyKey string,
|
||||
quote *sharedv1.PaymentQuote,
|
||||
) (*model.PaymentPlan, error) {
|
||||
if s == nil || s.storage == nil {
|
||||
return nil, errStorageUnavailable
|
||||
}
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routeStore := s.storage.Routes()
|
||||
if routeStore == nil {
|
||||
return nil, merrors.InvalidArgument("routes store is required")
|
||||
}
|
||||
planTemplates := s.storage.PlanTemplates()
|
||||
if planTemplates == nil {
|
||||
return nil, merrors.InvalidArgument("plan templates store is required")
|
||||
}
|
||||
|
||||
builder := s.deps.planBuilder
|
||||
if builder == nil {
|
||||
builder = newDefaultPlanBuilder(s.logger.Named("plan_builder"))
|
||||
}
|
||||
|
||||
planQuote := quote
|
||||
if planQuote == nil {
|
||||
planQuote = &sharedv1.PaymentQuote{}
|
||||
}
|
||||
payment := newPayment(orgID, intent, strings.TrimSpace(idempotencyKey), nil, planQuote)
|
||||
if ref := strings.TrimSpace(planQuote.GetQuoteRef()); ref != "" {
|
||||
payment.PaymentRef = ref
|
||||
}
|
||||
|
||||
plan, err := builder.Build(ctx, payment, planQuote, routeStore, planTemplates, s.deps.gatewayRegistry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return nil, merrors.InvalidArgument("payment plan is required")
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func cloneStoredPaymentPlans(plans []*model.PaymentPlan) []*model.PaymentPlan {
|
||||
if len(plans) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*model.PaymentPlan, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
if p == nil {
|
||||
out = append(out, nil)
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneStoredPaymentPlan(p))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &model.PaymentPlan{
|
||||
ID: strings.TrimSpace(src.ID),
|
||||
IdempotencyKey: strings.TrimSpace(src.IdempotencyKey),
|
||||
CreatedAt: src.CreatedAt,
|
||||
FXQuote: cloneStoredFXQuote(src.FXQuote),
|
||||
Fees: cloneStoredFeeLines(src.Fees),
|
||||
}
|
||||
if len(src.Steps) > 0 {
|
||||
clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps))
|
||||
for _, step := range src.Steps {
|
||||
if step == nil {
|
||||
clone.Steps = append(clone.Steps, nil)
|
||||
continue
|
||||
}
|
||||
stepClone := &model.PaymentStep{
|
||||
StepID: strings.TrimSpace(step.StepID),
|
||||
Rail: step.Rail,
|
||||
GatewayID: strings.TrimSpace(step.GatewayID),
|
||||
InstanceID: strings.TrimSpace(step.InstanceID),
|
||||
GatewayInvokeURI: strings.TrimSpace(step.GatewayInvokeURI),
|
||||
Action: step.Action,
|
||||
DependsOn: cloneStringList(step.DependsOn),
|
||||
CommitPolicy: step.CommitPolicy,
|
||||
CommitAfter: cloneStringList(step.CommitAfter),
|
||||
Amount: cloneMoney(step.Amount),
|
||||
FromRole: shared.CloneAccountRole(step.FromRole),
|
||||
ToRole: shared.CloneAccountRole(step.ToRole),
|
||||
}
|
||||
clone.Steps = append(clone.Steps, stepClone)
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
result := &paymenttypes.FXQuote{
|
||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||
Side: src.Side,
|
||||
ExpiresAtUnixMs: src.ExpiresAtUnixMs,
|
||||
PricedAtUnixMs: src.PricedAtUnixMs,
|
||||
Provider: strings.TrimSpace(src.Provider),
|
||||
RateRef: strings.TrimSpace(src.RateRef),
|
||||
Firm: src.Firm,
|
||||
BaseAmount: cloneMoney(src.BaseAmount),
|
||||
QuoteAmount: cloneMoney(src.QuoteAmount),
|
||||
}
|
||||
if src.Pair != nil {
|
||||
result.Pair = &paymenttypes.CurrencyPair{
|
||||
Base: strings.TrimSpace(src.Pair.Base),
|
||||
Quote: strings.TrimSpace(src.Pair.Quote),
|
||||
}
|
||||
}
|
||||
if src.Price != nil {
|
||||
result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*paymenttypes.FeeLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
result = append(result, nil)
|
||||
continue
|
||||
}
|
||||
result = append(result, &paymenttypes.FeeLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
|
||||
Money: cloneMoney(line.Money),
|
||||
LineType: line.LineType,
|
||||
Side: line.Side,
|
||||
Meta: cloneMetadata(line.Meta),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
// RouteStore exposes routing definitions for plan construction.
|
||||
type RouteStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
||||
}
|
||||
|
||||
// PlanTemplateStore exposes orchestration plan templates for plan construction.
|
||||
type PlanTemplateStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
|
||||
}
|
||||
|
||||
// GatewayRegistry exposes gateway instances for capability-based selection.
|
||||
type GatewayRegistry interface {
|
||||
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
|
||||
}
|
||||
|
||||
// PlanBuilder constructs ordered payment plans from intents, quotes, and routing policy.
|
||||
type PlanBuilder interface {
|
||||
Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
|
||||
}
|
||||
@@ -6,21 +6,8 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/plan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
type defaultPlanBuilder struct {
|
||||
inner plan.Builder
|
||||
}
|
||||
|
||||
func newDefaultPlanBuilder(logger mlogger.Logger) PlanBuilder {
|
||||
return &defaultPlanBuilder{inner: plan.NewDefaultBuilder(logger)}
|
||||
}
|
||||
|
||||
func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
return b.inner.Build(ctx, payment, quote, routes, templates, gateways)
|
||||
}
|
||||
|
||||
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
|
||||
return plan.RailFromEndpoint(endpoint, attrs, isSource)
|
||||
}
|
||||
@@ -29,6 +16,13 @@ func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork str
|
||||
return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork)
|
||||
}
|
||||
|
||||
func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
|
||||
func selectPlanTemplate(
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
templates plan.PlanTemplateStore,
|
||||
sourceRail model.Rail,
|
||||
destRail model.Rail,
|
||||
network string,
|
||||
) (*model.PaymentPlanTemplate, error) {
|
||||
return plan.SelectTemplate(ctx, logger, templates, sourceRail, destRail, network)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,3 @@ func sendDirectionForRail(rail model.Rail) plan.SendDirection {
|
||||
func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir plan.SendDirection, amount decimal.Decimal) error {
|
||||
return plan.IsGatewayEligible(gw, rail, network, currency, action, dir, amount)
|
||||
}
|
||||
|
||||
func parseRailValue(value string) model.Rail {
|
||||
return plan.ParseRailValue(value)
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
)
|
||||
|
||||
type providerSettlementGateway struct {
|
||||
client chainclient.Client
|
||||
rail string
|
||||
network string
|
||||
capabilities rail.RailCapabilities
|
||||
}
|
||||
|
||||
func NewProviderSettlementGateway(client chainclient.Client, cfg chainclient.RailGatewayConfig) rail.RailGateway {
|
||||
railName := strings.ToUpper(strings.TrimSpace(cfg.Rail))
|
||||
if railName == "" {
|
||||
railName = "PROVIDER_SETTLEMENT"
|
||||
}
|
||||
return &providerSettlementGateway{
|
||||
client: client,
|
||||
rail: railName,
|
||||
network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
|
||||
capabilities: cfg.Capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *providerSettlementGateway) Rail() string {
|
||||
return g.rail
|
||||
}
|
||||
|
||||
func (g *providerSettlementGateway) Network() string {
|
||||
return g.network
|
||||
}
|
||||
|
||||
func (g *providerSettlementGateway) Capabilities() rail.RailCapabilities {
|
||||
return g.capabilities
|
||||
}
|
||||
|
||||
func (g *providerSettlementGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) {
|
||||
if g.client == nil {
|
||||
return rail.RailResult{}, merrors.Internal("provider settlement gateway: client is required")
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: idempotency_key is required")
|
||||
}
|
||||
currency := strings.TrimSpace(req.Currency)
|
||||
amount := strings.TrimSpace(req.Amount)
|
||||
if currency == "" || amount == "" {
|
||||
return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: amount is required")
|
||||
}
|
||||
metadata := cloneMetadata(req.Metadata)
|
||||
if metadata == nil {
|
||||
metadata = map[string]string{}
|
||||
}
|
||||
if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" {
|
||||
if ref := strings.TrimSpace(req.PaymentRef); ref != "" {
|
||||
metadata[providerSettlementMetaPaymentIntentID] = ref
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" {
|
||||
return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: payment_intent_id is required")
|
||||
}
|
||||
if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" && g.rail != "" {
|
||||
metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(g.rail))
|
||||
}
|
||||
submitReq := &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
OrganizationRef: strings.TrimSpace(req.OrganizationRef),
|
||||
SourceWalletRef: strings.TrimSpace(req.FromAccountID),
|
||||
Amount: &moneyv1.Money{
|
||||
Currency: currency,
|
||||
Amount: amount,
|
||||
},
|
||||
Metadata: metadata,
|
||||
PaymentRef: strings.TrimSpace(req.PaymentRef),
|
||||
IntentRef: req.IntentRef,
|
||||
OperationRef: req.OperationRef,
|
||||
}
|
||||
if dest := buildProviderSettlementDestination(req); dest != nil {
|
||||
submitReq.Destination = dest
|
||||
}
|
||||
resp, err := g.client.SubmitTransfer(ctx, submitReq)
|
||||
if err != nil {
|
||||
return rail.RailResult{}, err
|
||||
}
|
||||
if resp == nil || resp.GetTransfer() == nil {
|
||||
return rail.RailResult{}, merrors.Internal("provider settlement gateway: missing transfer response")
|
||||
}
|
||||
transfer := resp.GetTransfer()
|
||||
return rail.RailResult{
|
||||
ReferenceID: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
Status: providerSettlementStatusFromTransfer(transfer.GetStatus()),
|
||||
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *providerSettlementGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) {
|
||||
if g.client == nil {
|
||||
return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: client is required")
|
||||
}
|
||||
ref := strings.TrimSpace(referenceID)
|
||||
if ref == "" {
|
||||
return rail.ObserveResult{}, merrors.InvalidArgument("provider settlement gateway: reference_id is required")
|
||||
}
|
||||
resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref})
|
||||
if err != nil {
|
||||
return rail.ObserveResult{}, err
|
||||
}
|
||||
if resp == nil || resp.GetTransfer() == nil {
|
||||
return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: missing transfer response")
|
||||
}
|
||||
transfer := resp.GetTransfer()
|
||||
return rail.ObserveResult{
|
||||
ReferenceID: ref,
|
||||
Status: providerSettlementStatusFromTransfer(transfer.GetStatus()),
|
||||
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *providerSettlementGateway) Block(ctx context.Context, req rail.BlockRequest) (rail.RailResult, error) {
|
||||
return rail.RailResult{}, merrors.NotImplemented("provider settlement gateway: block not supported")
|
||||
}
|
||||
|
||||
func (g *providerSettlementGateway) Release(ctx context.Context, req rail.ReleaseRequest) (rail.RailResult, error) {
|
||||
return rail.RailResult{}, merrors.NotImplemented("provider settlement gateway: release not supported")
|
||||
}
|
||||
|
||||
func buildProviderSettlementDestination(req rail.TransferRequest) *chainv1.TransferDestination {
|
||||
destRef := strings.TrimSpace(req.ToAccountID)
|
||||
memo := strings.TrimSpace(req.DestinationMemo)
|
||||
if destRef == "" && memo == "" {
|
||||
return nil
|
||||
}
|
||||
return &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef},
|
||||
Memo: memo,
|
||||
}
|
||||
}
|
||||
|
||||
func providerSettlementStatusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus {
|
||||
switch status {
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_SUCCESS:
|
||||
return rail.TransferStatusSuccess
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||
return rail.TransferStatusFailed
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
// our cancellation, not from provider
|
||||
return rail.TransferStatusFailed
|
||||
|
||||
default:
|
||||
// CREATED, PROCESSING, WAITING
|
||||
return rail.TransferStatusWaiting
|
||||
}
|
||||
}
|
||||
|
||||
func railMoneyFromProto(src *moneyv1.Money) *rail.Money {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
currency := strings.TrimSpace(src.GetCurrency())
|
||||
amount := strings.TrimSpace(src.GetAmount())
|
||||
if currency == "" || amount == "" {
|
||||
return nil
|
||||
}
|
||||
return &rail.Money{
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,36 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quotation_service_v2"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// QuotationService exposes only quotation RPCs as a standalone gRPC service.
|
||||
// QuotationService exposes quotation-v2 RPCs as a standalone gRPC service.
|
||||
type QuotationService struct {
|
||||
core *Service
|
||||
quote *quotationService
|
||||
core *Service
|
||||
v2 *quotation_service_v2.QuotationServiceV2
|
||||
}
|
||||
|
||||
// NewQuotationService constructs a standalone quotation service.
|
||||
func NewQuotationService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *QuotationService {
|
||||
core := NewService(logger, repo, opts...)
|
||||
return &QuotationService{
|
||||
core: core,
|
||||
quote: newQuotationService(core),
|
||||
core: core,
|
||||
v2: newQuotationServiceV2(core),
|
||||
}
|
||||
}
|
||||
|
||||
// Register attaches only the quotation service to the supplied gRPC router.
|
||||
func (s *QuotationService) Register(router routers.GRPC) error {
|
||||
if s == nil || s.quote == nil {
|
||||
if s == nil || s.v2 == nil {
|
||||
return nil
|
||||
}
|
||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||
quotationv1.RegisterQuotationServiceServer(reg, s.quote)
|
||||
quotationv2.RegisterQuotationServiceServer(reg, s.v2)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func TestQuotationService_RegisterV2Only(t *testing.T) {
|
||||
svc := NewQuotationService(zap.NewNop(), nil)
|
||||
router := newGRPCCaptureRouter()
|
||||
|
||||
if err := svc.Register(router); err != nil {
|
||||
t.Fatalf("Register returned error: %v", err)
|
||||
}
|
||||
|
||||
services := router.server.GetServiceInfo()
|
||||
if _, ok := services[quotationv2.QuotationService_ServiceDesc.ServiceName]; !ok {
|
||||
t.Fatalf("expected %q service to be registered", quotationv2.QuotationService_ServiceDesc.ServiceName)
|
||||
}
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("expected exactly one registered service, got %d", len(services))
|
||||
}
|
||||
}
|
||||
|
||||
type grpcCaptureRouter struct {
|
||||
server *grpc.Server
|
||||
done chan error
|
||||
}
|
||||
|
||||
func newGRPCCaptureRouter() *grpcCaptureRouter {
|
||||
return &grpcCaptureRouter{
|
||||
server: grpc.NewServer(),
|
||||
done: make(chan error),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *grpcCaptureRouter) Register(registration routers.GRPCServiceRegistration) error {
|
||||
registration(r.server)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *grpcCaptureRouter) Start(context.Context) error { return nil }
|
||||
|
||||
func (r *grpcCaptureRouter) Finish(context.Context) error { return nil }
|
||||
|
||||
func (r *grpcCaptureRouter) Addr() net.Addr { return nil }
|
||||
|
||||
func (r *grpcCaptureRouter) Done() <-chan error { return r.done }
|
||||
|
||||
var _ routers.GRPC = (*grpcCaptureRouter)(nil)
|
||||
@@ -1,24 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
)
|
||||
|
||||
type quotationService struct {
|
||||
svc *Service
|
||||
quotationv1.UnimplementedQuotationServiceServer
|
||||
}
|
||||
|
||||
func newQuotationService(svc *Service) *quotationService {
|
||||
return "ationService{svc: svc}
|
||||
}
|
||||
|
||||
func (s *quotationService) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) {
|
||||
return s.svc.QuotePayment(ctx, req)
|
||||
}
|
||||
|
||||
func (s *quotationService) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) {
|
||||
return s.svc.QuotePayments(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package quotation_service_v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type endpointLogSummary struct {
|
||||
ResolutionType string `json:"resolutionType"`
|
||||
MethodRef string `json:"methodRef,omitempty"`
|
||||
MethodType string `json:"methodType,omitempty"`
|
||||
RecipientRef string `json:"recipientRef,omitempty"`
|
||||
PayeeRef string `json:"payeeRef,omitempty"`
|
||||
DataBytes int `json:"dataBytes,omitempty"`
|
||||
}
|
||||
|
||||
type quoteIntentLogSummary struct {
|
||||
Source endpointLogSummary `json:"source"`
|
||||
Destination endpointLogSummary `json:"destination"`
|
||||
Amount string `json:"amount,omitempty"`
|
||||
SettlementMode string `json:"settlementMode,omitempty"`
|
||||
FeeTreatment string `json:"feeTreatment,omitempty"`
|
||||
SettlementCurrency string `json:"settlementCurrency,omitempty"`
|
||||
HasComment bool `json:"hasComment"`
|
||||
}
|
||||
|
||||
func (s *QuotationServiceV2) quotePaymentLogger(req *quotationv2.QuotePaymentRequest) mlogger.Logger {
|
||||
return s.logger.With(
|
||||
zap.String("flow_ref", bson.NewObjectID().Hex()),
|
||||
zap.String("rpc_method", "QuotePayment"),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetMeta().GetOrganizationRef())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.Bool("preview_only", req.GetPreviewOnly()),
|
||||
zap.String("initiator_ref", strings.TrimSpace(req.GetInitiatorRef())),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *QuotationServiceV2) quotePaymentsLogger(req *quotationv2.QuotePaymentsRequest) mlogger.Logger {
|
||||
return s.logger.With(
|
||||
zap.String("flow_ref", bson.NewObjectID().Hex()),
|
||||
zap.String("rpc_method", "QuotePayments"),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetMeta().GetOrganizationRef())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.Bool("preview_only", req.GetPreviewOnly()),
|
||||
zap.String("initiator_ref", strings.TrimSpace(req.GetInitiatorRef())),
|
||||
zap.Int("intent_count", len(req.GetIntents())),
|
||||
)
|
||||
}
|
||||
|
||||
func summarizeQuoteIntent(intent *quotationv2.QuoteIntent) *quoteIntentLogSummary {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
return "eIntentLogSummary{
|
||||
Source: summarizeEndpoint(intent.GetSource()),
|
||||
Destination: summarizeEndpoint(intent.GetDestination()),
|
||||
Amount: moneyLogValue(intent.GetAmount()),
|
||||
SettlementMode: enumLogValue(intent.GetSettlementMode().String()),
|
||||
FeeTreatment: enumLogValue(intent.GetFeeTreatment().String()),
|
||||
SettlementCurrency: strings.ToUpper(strings.TrimSpace(intent.GetSettlementCurrency())),
|
||||
HasComment: strings.TrimSpace(intent.GetComment()) != "",
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeQuoteIntentList(intents []*quotationv2.QuoteIntent, limit int) ([]quoteIntentLogSummary, int, int) {
|
||||
total := len(intents)
|
||||
if total == 0 || limit <= 0 {
|
||||
return nil, total, 0
|
||||
}
|
||||
if limit > total {
|
||||
limit = total
|
||||
}
|
||||
|
||||
items := make([]quoteIntentLogSummary, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
if summary := summarizeQuoteIntent(intents[i]); summary != nil {
|
||||
items = append(items, *summary)
|
||||
continue
|
||||
}
|
||||
items = append(items, quoteIntentLogSummary{})
|
||||
}
|
||||
return items, total, total - limit
|
||||
}
|
||||
|
||||
func summarizeEndpoint(endpoint *endpointv1.PaymentEndpoint) endpointLogSummary {
|
||||
if endpoint == nil {
|
||||
return endpointLogSummary{ResolutionType: "unspecified"}
|
||||
}
|
||||
switch source := endpoint.GetSource().(type) {
|
||||
case *endpointv1.PaymentEndpoint_PaymentMethodRef:
|
||||
return endpointLogSummary{
|
||||
ResolutionType: "payment_method_ref",
|
||||
MethodRef: strings.TrimSpace(source.PaymentMethodRef),
|
||||
}
|
||||
case *endpointv1.PaymentEndpoint_PaymentMethod:
|
||||
method := source.PaymentMethod
|
||||
if method == nil {
|
||||
return endpointLogSummary{ResolutionType: "payment_method"}
|
||||
}
|
||||
return endpointLogSummary{
|
||||
ResolutionType: "payment_method",
|
||||
MethodType: enumLogValue(method.GetType().String()),
|
||||
RecipientRef: strings.TrimSpace(method.GetRecipientRef()),
|
||||
DataBytes: len(method.GetData()),
|
||||
}
|
||||
case *endpointv1.PaymentEndpoint_PayeeRef:
|
||||
return endpointLogSummary{
|
||||
ResolutionType: "payee_ref",
|
||||
PayeeRef: strings.TrimSpace(source.PayeeRef),
|
||||
}
|
||||
default:
|
||||
return endpointLogSummary{ResolutionType: "unspecified"}
|
||||
}
|
||||
}
|
||||
|
||||
func moneyLogValue(m *moneyv1.Money) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
amount := strings.TrimSpace(m.GetAmount())
|
||||
currency := strings.ToUpper(strings.TrimSpace(m.GetCurrency()))
|
||||
switch {
|
||||
case amount == "" && currency == "":
|
||||
return ""
|
||||
case amount == "":
|
||||
return currency
|
||||
case currency == "":
|
||||
return amount
|
||||
default:
|
||||
return fmt.Sprintf("%s %s", amount, currency)
|
||||
}
|
||||
}
|
||||
|
||||
func enumLogValue(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
@@ -13,24 +13,45 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
ctx context.Context,
|
||||
req *quotationv2.QuotePaymentsRequest,
|
||||
) (*QuotePaymentsResult, error) {
|
||||
logger := s.quotePaymentsLogger(req).Named("processor").With(zap.String("mode", "batch"))
|
||||
startedAt := time.Now()
|
||||
logger.Info("QuotePayments request received")
|
||||
summaries, totalIntents, truncatedIntents := summarizeQuoteIntentList(req.GetIntents(), 10)
|
||||
logger.Debug("QuotePayments request payload",
|
||||
zap.Int("intent_count", totalIntents),
|
||||
zap.Int("intent_count_truncated", truncatedIntents),
|
||||
zap.Any("intents", summaries),
|
||||
)
|
||||
logger.Debug("ProcessQuotePayments started")
|
||||
|
||||
if err := s.validateDependencies(); err != nil {
|
||||
logger.Warn("ProcessQuotePayments failed on dependency validation", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("ProcessQuotePayments dependencies validated")
|
||||
|
||||
requestCtx, err := s.deps.Validator.ValidateQuotePayments(req)
|
||||
if err != nil {
|
||||
logger.Debug("ProcessQuotePayments failed on request validation", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("ProcessQuotePayments request validated",
|
||||
zap.String("organization_ref", requestCtx.OrganizationRef),
|
||||
zap.Bool("preview_only", requestCtx.PreviewOnly),
|
||||
zap.Int("intent_count", requestCtx.IntentCount),
|
||||
)
|
||||
|
||||
fingerprint := ""
|
||||
if !requestCtx.PreviewOnly {
|
||||
fingerprint = s.deps.Idempotency.FingerprintQuotePayments(req)
|
||||
logger.Debug("ProcessQuotePayments checking idempotency reuse")
|
||||
reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{
|
||||
OrganizationID: requestCtx.OrganizationID,
|
||||
IdempotencyKey: requestCtx.IdempotencyKey,
|
||||
@@ -38,11 +59,29 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
Shape: quote_idempotency_service.QuoteShapeBatch,
|
||||
})
|
||||
if reuseErr != nil {
|
||||
logger.Warn("ProcessQuotePayments idempotency reuse check failed", zap.Error(reuseErr))
|
||||
return nil, reuseErr
|
||||
}
|
||||
if reused {
|
||||
return s.batchResultFromRecord(reusedRecord)
|
||||
logger.Info("ProcessQuotePayments served from idempotency reuse",
|
||||
zap.String("quote_ref", strings.TrimSpace(reusedRecord.QuoteRef)),
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
)
|
||||
reusedResult, mapErr := s.batchResultFromRecord(reusedRecord)
|
||||
if mapErr != nil {
|
||||
logger.Warn("ProcessQuotePayments failed to map reused record", zap.Error(mapErr))
|
||||
return nil, mapErr
|
||||
}
|
||||
reusedResponse := reusedResult.Response
|
||||
logger.Info("QuotePayments response ready",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(reusedResponse.GetQuoteRef())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())),
|
||||
zap.Int("quotes_count", len(reusedResponse.GetQuotes())),
|
||||
)
|
||||
return reusedResult, nil
|
||||
}
|
||||
logger.Debug("ProcessQuotePayments idempotency reuse miss")
|
||||
}
|
||||
|
||||
hydrated, err := s.deps.Hydrator.HydrateMany(ctx, transfer_intent_hydrator.HydrateManyInput{
|
||||
@@ -51,13 +90,16 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
Intents: req.GetIntents(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("ProcessQuotePayments failed during intent hydration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("ProcessQuotePayments intents hydrated", zap.Int("intent_count", len(hydrated)))
|
||||
|
||||
quoteRef := ""
|
||||
if !requestCtx.PreviewOnly {
|
||||
quoteRef = normalizeQuoteRef(s.deps.NewRef())
|
||||
if quoteRef == "" {
|
||||
logger.Warn("ProcessQuotePayments generated empty quote_ref")
|
||||
return nil, merrors.InvalidArgument("quote_ref is required")
|
||||
}
|
||||
}
|
||||
@@ -70,6 +112,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
quoteRef,
|
||||
s.deps.Now().UTC(),
|
||||
collector,
|
||||
logger,
|
||||
)
|
||||
batch := batch_quote_processor_v2.New(single)
|
||||
|
||||
@@ -78,22 +121,27 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
Intents: hydrated,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayments failed during computation pipeline", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if batchOut == nil || len(batchOut.Items) != len(hydrated) {
|
||||
logger.Warn("ProcessQuotePayments returned invalid batch output")
|
||||
return nil, merrors.InvalidArgument("batch quote output is invalid")
|
||||
}
|
||||
|
||||
quotes := make([]*quotationv2.PaymentQuote, 0, len(batchOut.Items))
|
||||
for _, item := range batchOut.Items {
|
||||
if item == nil || item.Quote == nil {
|
||||
logger.Warn("ProcessQuotePayments contains empty quote item")
|
||||
return nil, merrors.InvalidArgument("batch item quote is required")
|
||||
}
|
||||
quotes = append(quotes, item.Quote)
|
||||
}
|
||||
logger.Debug("ProcessQuotePayments computation completed", zap.Int("quotes_count", len(quotes)))
|
||||
|
||||
details := collector.Ordered(len(batchOut.Items))
|
||||
if len(details) != len(batchOut.Items) {
|
||||
logger.Warn("ProcessQuotePayments missing item details", zap.Int("details_count", len(details)), zap.Int("items_count", len(batchOut.Items)))
|
||||
return nil, merrors.InvalidArgument("batch processing details are incomplete")
|
||||
}
|
||||
|
||||
@@ -106,6 +154,17 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
Response: response,
|
||||
}
|
||||
if requestCtx.PreviewOnly {
|
||||
logger.Info("ProcessQuotePayments preview completed",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())),
|
||||
zap.Int("quotes_count", len(response.GetQuotes())),
|
||||
)
|
||||
logger.Info("QuotePayments response ready",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())),
|
||||
zap.Int("quotes_count", len(response.GetQuotes())),
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -115,6 +174,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
statuses := make([]*quote_persistence_service.StatusInput, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil || detail.Intent.Amount == nil || detail.Quote == nil {
|
||||
logger.Warn("ProcessQuotePayments contains incomplete detail")
|
||||
return nil, merrors.InvalidArgument("batch processing detail is incomplete")
|
||||
}
|
||||
expires = append(expires, detail.ExpiresAt)
|
||||
@@ -125,6 +185,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
|
||||
expiresAt, ok := minExpiry(expires)
|
||||
if !ok {
|
||||
logger.Warn("ProcessQuotePayments produced empty expires_at")
|
||||
return nil, merrors.InvalidArgument("expires_at is required")
|
||||
}
|
||||
|
||||
@@ -139,6 +200,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
Statuses: statuses,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayments failed to build persistence record", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -152,6 +214,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayments failed while storing idempotent record", zap.Error(err))
|
||||
if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) ||
|
||||
errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) {
|
||||
return nil, merrors.InvalidArgument(err.Error())
|
||||
@@ -159,9 +222,37 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
return nil, err
|
||||
}
|
||||
if reused {
|
||||
return s.batchResultFromRecord(stored)
|
||||
logger.Info("ProcessQuotePayments reused concurrent idempotent record",
|
||||
zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)),
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
)
|
||||
reusedResult, mapErr := s.batchResultFromRecord(stored)
|
||||
if mapErr != nil {
|
||||
logger.Warn("ProcessQuotePayments failed to map reused concurrent record", zap.Error(mapErr))
|
||||
return nil, mapErr
|
||||
}
|
||||
reusedResponse := reusedResult.Response
|
||||
logger.Info("QuotePayments response ready",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(reusedResponse.GetQuoteRef())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())),
|
||||
zap.Int("quotes_count", len(reusedResponse.GetQuotes())),
|
||||
)
|
||||
return reusedResult, nil
|
||||
}
|
||||
|
||||
result.Record = stored
|
||||
logger.Info("ProcessQuotePayments persisted quote batch",
|
||||
zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)),
|
||||
zap.Int("quotes_count", len(stored.Quotes)),
|
||||
zap.Time("expires_at", stored.ExpiresAt),
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
)
|
||||
logger.Info("QuotePayments response ready",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())),
|
||||
zap.Int("quotes_count", len(response.GetQuotes())),
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service"
|
||||
@@ -12,24 +13,40 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
ctx context.Context,
|
||||
req *quotationv2.QuotePaymentRequest,
|
||||
) (*QuotePaymentResult, error) {
|
||||
logger := s.quotePaymentLogger(req).Named("processor").With(zap.String("mode", "single"))
|
||||
startedAt := time.Now()
|
||||
logger.Info("QuotePayment request received")
|
||||
logger.Debug("QuotePayment request payload", zap.Any("intent", summarizeQuoteIntent(req.GetIntent())))
|
||||
logger.Debug("ProcessQuotePayment started")
|
||||
|
||||
if err := s.validateDependencies(); err != nil {
|
||||
logger.Warn("ProcessQuotePayment failed on dependency validation", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("ProcessQuotePayment dependencies validated")
|
||||
|
||||
requestCtx, err := s.deps.Validator.ValidateQuotePayment(req)
|
||||
if err != nil {
|
||||
logger.Debug("ProcessQuotePayment failed on request validation", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("ProcessQuotePayment request validated",
|
||||
zap.String("organization_ref", requestCtx.OrganizationRef),
|
||||
zap.Bool("preview_only", requestCtx.PreviewOnly),
|
||||
zap.Int("intent_count", requestCtx.IntentCount),
|
||||
)
|
||||
|
||||
fingerprint := ""
|
||||
if !requestCtx.PreviewOnly {
|
||||
fingerprint = s.deps.Idempotency.FingerprintQuotePayment(req)
|
||||
logger.Debug("ProcessQuotePayment checking idempotency reuse")
|
||||
reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{
|
||||
OrganizationID: requestCtx.OrganizationID,
|
||||
IdempotencyKey: requestCtx.IdempotencyKey,
|
||||
@@ -37,11 +54,32 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
Shape: quote_idempotency_service.QuoteShapeSingle,
|
||||
})
|
||||
if reuseErr != nil {
|
||||
logger.Warn("ProcessQuotePayment idempotency reuse check failed", zap.Error(reuseErr))
|
||||
return nil, reuseErr
|
||||
}
|
||||
if reused {
|
||||
return s.singleResultFromRecord(reusedRecord)
|
||||
logger.Info("ProcessQuotePayment served from idempotency reuse",
|
||||
zap.String("quote_ref", strings.TrimSpace(reusedRecord.QuoteRef)),
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
)
|
||||
reusedResult, mapErr := s.singleResultFromRecord(reusedRecord)
|
||||
if mapErr != nil {
|
||||
logger.Warn("ProcessQuotePayment failed to map reused record", zap.Error(mapErr))
|
||||
return nil, mapErr
|
||||
}
|
||||
reusedResponse := reusedResult.Response
|
||||
reusedQuote := reusedResponse.GetQuote()
|
||||
logger.Info("QuotePayment response ready",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(reusedQuote.GetQuoteRef())),
|
||||
zap.String("intent_ref", strings.TrimSpace(reusedQuote.GetIntentRef())),
|
||||
zap.String("quote_state", strings.TrimSpace(reusedQuote.GetState().String())),
|
||||
zap.String("block_reason", strings.TrimSpace(reusedQuote.GetBlockReason().String())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())),
|
||||
)
|
||||
return reusedResult, nil
|
||||
}
|
||||
logger.Debug("ProcessQuotePayment idempotency reuse miss")
|
||||
}
|
||||
|
||||
hydrated, err := s.deps.Hydrator.HydrateOne(ctx, transfer_intent_hydrator.HydrateOneInput{
|
||||
@@ -50,13 +88,19 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
Intent: req.GetIntent(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("ProcessQuotePayment failed during intent hydration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("ProcessQuotePayment intent hydrated",
|
||||
zap.String("intent_ref", strings.TrimSpace(hydrated.Ref)),
|
||||
zap.String("kind", strings.TrimSpace(string(hydrated.Kind))),
|
||||
)
|
||||
|
||||
quoteRef := ""
|
||||
if !requestCtx.PreviewOnly {
|
||||
quoteRef = normalizeQuoteRef(s.deps.NewRef())
|
||||
if quoteRef == "" {
|
||||
logger.Warn("ProcessQuotePayment generated empty quote_ref")
|
||||
return nil, merrors.InvalidArgument("quote_ref is required")
|
||||
}
|
||||
}
|
||||
@@ -69,6 +113,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
quoteRef,
|
||||
s.deps.Now().UTC(),
|
||||
collector,
|
||||
logger,
|
||||
)
|
||||
batch := batch_quote_processor_v2.New(single)
|
||||
|
||||
@@ -77,11 +122,20 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{hydrated},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayment failed during computation pipeline", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if batchOut == nil || len(batchOut.Items) != 1 || batchOut.Items[0] == nil || batchOut.Items[0].Quote == nil {
|
||||
logger.Warn("ProcessQuotePayment returned invalid single output")
|
||||
return nil, merrors.InvalidArgument("single quote output is invalid")
|
||||
}
|
||||
computedQuote := batchOut.Items[0].Quote
|
||||
logger.Debug("ProcessQuotePayment computation completed",
|
||||
zap.String("quote_ref", strings.TrimSpace(computedQuote.GetQuoteRef())),
|
||||
zap.String("intent_ref", strings.TrimSpace(computedQuote.GetIntentRef())),
|
||||
zap.String("quote_state", strings.TrimSpace(computedQuote.GetState().String())),
|
||||
zap.String("block_reason", strings.TrimSpace(computedQuote.GetBlockReason().String())),
|
||||
)
|
||||
|
||||
response := "ationv2.QuotePaymentResponse{
|
||||
Quote: batchOut.Items[0].Quote,
|
||||
@@ -97,11 +151,27 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
Response: response,
|
||||
}
|
||||
if requestCtx.PreviewOnly {
|
||||
previewQuote := response.GetQuote()
|
||||
logger.Info("ProcessQuotePayment preview completed",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(previewQuote.GetQuoteRef())),
|
||||
zap.String("intent_ref", strings.TrimSpace(previewQuote.GetIntentRef())),
|
||||
zap.String("quote_state", strings.TrimSpace(previewQuote.GetState().String())),
|
||||
)
|
||||
logger.Info("QuotePayment response ready",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(previewQuote.GetQuoteRef())),
|
||||
zap.String("intent_ref", strings.TrimSpace(previewQuote.GetIntentRef())),
|
||||
zap.String("quote_state", strings.TrimSpace(previewQuote.GetState().String())),
|
||||
zap.String("block_reason", strings.TrimSpace(previewQuote.GetBlockReason().String())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())),
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
expiresAt := detail.ExpiresAt
|
||||
if expiresAt.IsZero() {
|
||||
logger.Warn("ProcessQuotePayment produced empty expires_at")
|
||||
return nil, merrors.InvalidArgument("expires_at is required")
|
||||
}
|
||||
|
||||
@@ -116,6 +186,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
Status: statusInputFromStatus(detail.Status),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayment failed to build persistence record", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -129,6 +200,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayment failed while storing idempotent record", zap.Error(err))
|
||||
if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) ||
|
||||
errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) {
|
||||
return nil, merrors.InvalidArgument(err.Error())
|
||||
@@ -137,9 +209,41 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
}
|
||||
|
||||
if reused {
|
||||
return s.singleResultFromRecord(stored)
|
||||
logger.Info("ProcessQuotePayment reused concurrent idempotent record",
|
||||
zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)),
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
)
|
||||
reusedResult, mapErr := s.singleResultFromRecord(stored)
|
||||
if mapErr != nil {
|
||||
logger.Warn("ProcessQuotePayment failed to map reused concurrent record", zap.Error(mapErr))
|
||||
return nil, mapErr
|
||||
}
|
||||
reusedResponse := reusedResult.Response
|
||||
reusedQuote := reusedResponse.GetQuote()
|
||||
logger.Info("QuotePayment response ready",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(reusedQuote.GetQuoteRef())),
|
||||
zap.String("intent_ref", strings.TrimSpace(reusedQuote.GetIntentRef())),
|
||||
zap.String("quote_state", strings.TrimSpace(reusedQuote.GetState().String())),
|
||||
zap.String("block_reason", strings.TrimSpace(reusedQuote.GetBlockReason().String())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())),
|
||||
)
|
||||
return reusedResult, nil
|
||||
}
|
||||
result.Record = stored
|
||||
logger.Info("ProcessQuotePayment persisted quote",
|
||||
zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)),
|
||||
zap.Time("expires_at", stored.ExpiresAt),
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
)
|
||||
logger.Info("QuotePayment response ready",
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
zap.String("quote_ref", strings.TrimSpace(response.GetQuote().GetQuoteRef())),
|
||||
zap.String("intent_ref", strings.TrimSpace(response.GetQuote().GetIntentRef())),
|
||||
zap.String("quote_state", strings.TrimSpace(response.GetQuote().GetState().String())),
|
||||
zap.String("block_reason", strings.TrimSpace(response.GetQuote().GetBlockReason().String())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())),
|
||||
)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
QuotesStore quotestorage.QuotesStore
|
||||
Validator *quote_request_validator_v2.QuoteRequestValidatorV2
|
||||
Hydrator *transfer_intent_hydrator.TransferIntentHydrator
|
||||
@@ -33,7 +35,8 @@ type Dependencies struct {
|
||||
}
|
||||
|
||||
type QuotationServiceV2 struct {
|
||||
deps Dependencies
|
||||
deps Dependencies
|
||||
logger mlogger.Logger
|
||||
quotationv2.UnimplementedQuotationServiceServer
|
||||
}
|
||||
|
||||
@@ -59,7 +62,13 @@ func New(deps Dependencies) *QuotationServiceV2 {
|
||||
if deps.NewRef == nil {
|
||||
deps.NewRef = func() string { return bson.NewObjectID().Hex() }
|
||||
}
|
||||
return &QuotationServiceV2{deps: deps}
|
||||
if deps.Logger == nil {
|
||||
panic("quotation_service_v2: logger is required")
|
||||
}
|
||||
return &QuotationServiceV2{
|
||||
deps: deps,
|
||||
logger: deps.Logger.Named("v2"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *QuotationServiceV2) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
@@ -36,6 +37,7 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
|
||||
store := newInMemoryQuotesStore()
|
||||
core := &fakeQuoteCore{now: now}
|
||||
svc := New(Dependencies{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
QuotesStore: store,
|
||||
Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
|
||||
return "q-intent-single"
|
||||
@@ -88,7 +90,7 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
|
||||
if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want {
|
||||
t.Fatalf("unexpected destination currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "101.8"; got != want {
|
||||
if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "102.4"; got != want {
|
||||
t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want {
|
||||
@@ -197,6 +199,7 @@ func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) {
|
||||
fxTTL: 5 * time.Minute,
|
||||
}
|
||||
svc := New(Dependencies{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
QuotesStore: store,
|
||||
Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
|
||||
return "q-intent-single"
|
||||
@@ -254,6 +257,7 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
|
||||
store := newInMemoryQuotesStore()
|
||||
core := &fakeQuoteCore{now: now}
|
||||
svc := New(Dependencies{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
QuotesStore: store,
|
||||
Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
|
||||
return fmt.Sprintf("q-intent-%d", time.Now().UnixNano())
|
||||
@@ -380,6 +384,7 @@ func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T)
|
||||
store := newInMemoryQuotesStore()
|
||||
core := &fakeQuoteCore{now: now}
|
||||
svc := New(Dependencies{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
QuotesStore: store,
|
||||
Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
|
||||
return "q-intent-topology"
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type itemProcessDetail struct {
|
||||
@@ -70,6 +72,7 @@ type singleIntentProcessorV2 struct {
|
||||
quoteRef string
|
||||
pricedAt time.Time
|
||||
collector *itemCollector
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func newSingleIntentProcessorV2(
|
||||
@@ -79,6 +82,7 @@ func newSingleIntentProcessorV2(
|
||||
quoteRef string,
|
||||
pricedAt time.Time,
|
||||
collector *itemCollector,
|
||||
logger mlogger.Logger,
|
||||
) *singleIntentProcessorV2 {
|
||||
return &singleIntentProcessorV2{
|
||||
computation: computation,
|
||||
@@ -87,6 +91,7 @@ func newSingleIntentProcessorV2(
|
||||
quoteRef: quoteRef,
|
||||
pricedAt: pricedAt,
|
||||
collector: collector,
|
||||
logger: logger.Named("single"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,17 +99,29 @@ func (p *singleIntentProcessorV2) Process(
|
||||
ctx context.Context,
|
||||
in batch_quote_processor_v2.SingleProcessInput,
|
||||
) (*batch_quote_processor_v2.SingleProcessOutput, error) {
|
||||
if p == nil {
|
||||
return nil, merrors.InvalidArgument("single processor is required")
|
||||
}
|
||||
logger := p.logger.With(
|
||||
zap.Int("intent_index", in.Item.Index),
|
||||
zap.Int("intent_count", in.Item.Count),
|
||||
)
|
||||
logger.Debug("Single intent processing started")
|
||||
|
||||
if p == nil || p.computation == nil {
|
||||
if p.computation == nil {
|
||||
logger.Warn("Single intent processing failed: missing computation service")
|
||||
return nil, merrors.InvalidArgument("quote computation service is required")
|
||||
}
|
||||
if p.classifier == nil {
|
||||
logger.Warn("Single intent processing failed: missing classifier")
|
||||
return nil, merrors.InvalidArgument("quote executability classifier is required")
|
||||
}
|
||||
if p.mapper == nil {
|
||||
logger.Warn("Single intent processing failed: missing mapper")
|
||||
return nil, merrors.InvalidArgument("quote response mapper is required")
|
||||
}
|
||||
if in.Item.Intent == nil {
|
||||
logger.Debug("Single intent processing failed: empty intent")
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
|
||||
@@ -116,17 +133,24 @@ func (p *singleIntentProcessorV2) Process(
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{in.Item.Intent},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("Single intent computation failed", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if computed == nil || computed.Plan == nil || len(computed.Results) != 1 || len(computed.Plan.Items) != 1 {
|
||||
logger.Warn("Single intent computation returned invalid shape")
|
||||
return nil, merrors.InvalidArgument("invalid computation output for single item")
|
||||
}
|
||||
|
||||
result := computed.Results[0]
|
||||
planItem := computed.Plan.Items[0]
|
||||
if result == nil || planItem == nil || result.Quote == nil || planItem.Intent.Amount == nil {
|
||||
logger.Warn("Single intent computation returned incomplete payload")
|
||||
return nil, merrors.InvalidArgument("incomplete computation output")
|
||||
}
|
||||
logger.Debug("Single intent computation completed",
|
||||
zap.Int("steps_count", len(planItem.Steps)),
|
||||
zap.String("intent_ref", strings.TrimSpace(planItem.Intent.Ref)),
|
||||
)
|
||||
|
||||
state := p.classifier.BuildState(in.Context.PreviewOnly, result.BlockReason)
|
||||
status := quote_response_mapper_v2.QuoteStatus{
|
||||
@@ -161,9 +185,11 @@ func (p *singleIntentProcessorV2) Process(
|
||||
Status: status,
|
||||
})
|
||||
if mapErr != nil {
|
||||
logger.Warn("Single intent response mapping failed", zap.Error(mapErr))
|
||||
return nil, mapErr
|
||||
}
|
||||
if mapped == nil || mapped.Quote == nil {
|
||||
logger.Warn("Single intent response mapping returned empty quote")
|
||||
return nil, merrors.InvalidArgument("mapped quote is required")
|
||||
}
|
||||
mapped.Quote.IntentRef = firstNonEmpty(
|
||||
@@ -182,6 +208,13 @@ func (p *singleIntentProcessorV2) Process(
|
||||
ExpiresAt: expiresAt,
|
||||
Status: status,
|
||||
})
|
||||
mappedQuote := mapped.Quote
|
||||
logger.Debug("Single intent processing completed",
|
||||
zap.String("quote_ref", strings.TrimSpace(mappedQuote.GetQuoteRef())),
|
||||
zap.String("intent_ref", strings.TrimSpace(mappedQuote.GetIntentRef())),
|
||||
zap.String("quote_state", strings.TrimSpace(mappedQuote.GetState().String())),
|
||||
zap.String("block_reason", strings.TrimSpace(mappedQuote.GetBlockReason().String())),
|
||||
)
|
||||
|
||||
return &batch_quote_processor_v2.SingleProcessOutput{
|
||||
Quote: mapped.Quote,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quotation_service_v2"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func newQuotationServiceV2(core *Service) *quotation_service_v2.QuotationServiceV2 {
|
||||
if core == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return quotation_service_v2.New(quotation_service_v2.Dependencies{
|
||||
Logger: core.logger,
|
||||
QuotesStore: quoteStore(core),
|
||||
Hydrator: transfer_intent_hydrator.New(nil),
|
||||
Computation: newQuoteComputationService(core),
|
||||
})
|
||||
}
|
||||
|
||||
func quoteStore(core *Service) quotestorage.QuotesStore {
|
||||
if core == nil || core.storage == nil {
|
||||
return nil
|
||||
}
|
||||
return core.storage.Quotes()
|
||||
}
|
||||
|
||||
func newQuoteComputationService(core *Service) *quote_computation_service.QuoteComputationService {
|
||||
opts := make([]quote_computation_service.Option, 0, 3)
|
||||
if core != nil && core.storage != nil {
|
||||
if routes := core.storage.Routes(); routes != nil {
|
||||
opts = append(
|
||||
opts,
|
||||
quote_computation_service.WithRouteStore(routes),
|
||||
quote_computation_service.WithLogger(core.logger),
|
||||
)
|
||||
}
|
||||
}
|
||||
if core != nil && core.deps.gatewayRegistry != nil {
|
||||
opts = append(opts, quote_computation_service.WithGatewayRegistry(core.deps.gatewayRegistry))
|
||||
}
|
||||
if resolver := fundingProfileResolver(core); resolver != nil {
|
||||
opts = append(opts, quote_computation_service.WithFundingProfileResolver(resolver))
|
||||
}
|
||||
return quote_computation_service.New(legacyQuoteComputationCore{core: core}, opts...)
|
||||
}
|
||||
|
||||
func fundingProfileResolver(core *Service) gateway_funding_profile.FundingProfileResolver {
|
||||
if core == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cardRoutes := make(map[string]gateway_funding_profile.CardGatewayFundingRoute, len(core.deps.cardRoutes))
|
||||
for key, route := range core.deps.cardRoutes {
|
||||
routeKey := strings.TrimSpace(key)
|
||||
if routeKey == "" {
|
||||
continue
|
||||
}
|
||||
cardRoutes[routeKey] = gateway_funding_profile.CardGatewayFundingRoute{
|
||||
FundingAddress: strings.TrimSpace(route.FundingAddress),
|
||||
FeeAddress: strings.TrimSpace(route.FeeAddress),
|
||||
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
|
||||
}
|
||||
}
|
||||
|
||||
feeLedgerAccounts := make(map[string]string, len(core.deps.feeLedgerAccounts))
|
||||
for key, accountRef := range core.deps.feeLedgerAccounts {
|
||||
accountKey := strings.TrimSpace(key)
|
||||
account := strings.TrimSpace(accountRef)
|
||||
if accountKey == "" || account == "" {
|
||||
continue
|
||||
}
|
||||
feeLedgerAccounts[accountKey] = account
|
||||
}
|
||||
|
||||
if len(cardRoutes) == 0 && len(feeLedgerAccounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
|
||||
DefaultCardGateway: defaultCardGateway,
|
||||
DefaultMode: model.FundingModeNone,
|
||||
CardRoutes: cardRoutes,
|
||||
FeeLedgerAccounts: feeLedgerAccounts,
|
||||
})
|
||||
}
|
||||
|
||||
type legacyQuoteComputationCore struct {
|
||||
core *Service
|
||||
}
|
||||
|
||||
func (c legacyQuoteComputationCore) BuildQuote(ctx context.Context, in quote_computation_service.BuildQuoteInput) (*quote_computation_service.ComputedQuote, time.Time, error) {
|
||||
if c.core == nil {
|
||||
return nil, time.Time{}, errStorageUnavailable
|
||||
}
|
||||
|
||||
request := "eRequest{
|
||||
Meta: &sharedv1.RequestMeta{
|
||||
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
|
||||
},
|
||||
IdempotencyKey: strings.TrimSpace(in.IdempotencyKey),
|
||||
Intent: protoIntentFromModel(in.Intent),
|
||||
}
|
||||
|
||||
legacyQuote, expiresAt, err := c.core.buildPaymentQuote(ctx, strings.TrimSpace(in.OrganizationRef), request)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
return mapLegacyQuote(in, legacyQuote), expiresAt, nil
|
||||
}
|
||||
|
||||
func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1.PaymentQuote) *quote_computation_service.ComputedQuote {
|
||||
if src == nil {
|
||||
return "e_computation_service.ComputedQuote{}
|
||||
}
|
||||
resolvedSettlementMode := settlementModeToProto(in.Intent.SettlementMode)
|
||||
if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED {
|
||||
resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||
}
|
||||
return "e_computation_service.ComputedQuote{
|
||||
DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()),
|
||||
CreditAmount: cloneProtoMoney(src.GetExpectedSettlementAmount()),
|
||||
TotalCost: cloneProtoMoney(src.GetDebitAmount()),
|
||||
FeeLines: cloneFeeLines(src.GetFeeLines()),
|
||||
FeeRules: cloneFeeRules(src.GetFeeRules()),
|
||||
FXQuote: cloneFXQuote(src.GetFxQuote()),
|
||||
Route: cloneRouteSpecification(in.Route),
|
||||
ExecutionConditions: cloneExecutionConditions(in.ExecutionConditions),
|
||||
ResolvedSettlementMode: resolvedSettlementMode,
|
||||
ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode),
|
||||
}
|
||||
}
|
||||
|
||||
func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment {
|
||||
switch mode {
|
||||
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
|
||||
default:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
cloned, ok := proto.Clone(src).(*quotationv2.RouteSpecification)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
cloned, ok := proto.Clone(src).(*quotationv2.ExecutionConditions)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneFXQuote(src *oraclev1.Quote) *oraclev1.Quote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
cloned, ok := proto.Clone(src).(*oraclev1.Quote)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
func perIntentIdempotencyKey(base string, index int, total int) string {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if total <= 1 {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", base, index+1)
|
||||
}
|
||||
|
||||
func minQuoteExpiry(expires []time.Time) (time.Time, bool) {
|
||||
var min time.Time
|
||||
for _, exp := range expires {
|
||||
if exp.IsZero() {
|
||||
continue
|
||||
}
|
||||
if min.IsZero() || exp.Before(min) {
|
||||
min = exp
|
||||
}
|
||||
}
|
||||
if min.IsZero() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return min, true
|
||||
}
|
||||
|
||||
func aggregatePaymentQuotes(quotes []*sharedv1.PaymentQuote) (*sharedv1.PaymentQuoteAggregate, error) {
|
||||
if len(quotes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
debitTotals := map[string]decimal.Decimal{}
|
||||
settlementTotals := map[string]decimal.Decimal{}
|
||||
feeTotals := map[string]decimal.Decimal{}
|
||||
networkTotals := map[string]decimal.Decimal{}
|
||||
|
||||
for _, quote := range quotes {
|
||||
if quote == nil {
|
||||
continue
|
||||
}
|
||||
if err := accumulateMoney(debitTotals, quote.GetDebitAmount()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := accumulateMoney(settlementTotals, quote.GetExpectedSettlementAmount()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := accumulateMoney(feeTotals, quote.GetExpectedFeeTotal()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nf := quote.GetNetworkFee(); nf != nil {
|
||||
if err := accumulateMoney(networkTotals, nf.GetNetworkFee()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &sharedv1.PaymentQuoteAggregate{
|
||||
DebitAmounts: totalsToMoney(debitTotals),
|
||||
ExpectedSettlementAmounts: totalsToMoney(settlementTotals),
|
||||
ExpectedFeeTotals: totalsToMoney(feeTotals),
|
||||
NetworkFeeTotals: totalsToMoney(networkTotals),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func accumulateMoney(totals map[string]decimal.Decimal, money *moneyv1.Money) error {
|
||||
if money == nil {
|
||||
return nil
|
||||
}
|
||||
currency := strings.TrimSpace(money.GetCurrency())
|
||||
if currency == "" {
|
||||
return nil
|
||||
}
|
||||
amount, err := decimal.NewFromString(money.GetAmount())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current, ok := totals[currency]; ok {
|
||||
totals[currency] = current.Add(amount)
|
||||
return nil
|
||||
}
|
||||
totals[currency] = amount
|
||||
return nil
|
||||
}
|
||||
|
||||
func totalsToMoney(totals map[string]decimal.Decimal) []*moneyv1.Money {
|
||||
if len(totals) == 0 {
|
||||
return nil
|
||||
}
|
||||
currencies := make([]string, 0, len(totals))
|
||||
for currency := range totals {
|
||||
currencies = append(currencies, currency)
|
||||
}
|
||||
sort.Strings(currencies)
|
||||
|
||||
result := make([]*moneyv1.Money, 0, len(currencies))
|
||||
for _, currency := range currencies {
|
||||
amount := totals[currency]
|
||||
result = append(result, &moneyv1.Money{
|
||||
Amount: amount.String(),
|
||||
Currency: currency,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func intentsFromProto(intents []*sharedv1.PaymentIntent) []model.PaymentIntent {
|
||||
if len(intents) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]model.PaymentIntent, 0, len(intents))
|
||||
for _, intent := range intents {
|
||||
result = append(result, intentFromProto(intent))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func quoteSnapshotsFromProto(quotes []*sharedv1.PaymentQuote) []*model.PaymentQuoteSnapshot {
|
||||
if len(quotes) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*model.PaymentQuoteSnapshot, 0, len(quotes))
|
||||
for _, quote := range quotes {
|
||||
if quote == nil {
|
||||
continue
|
||||
}
|
||||
if snapshot := quoteSnapshotToModel(quote); snapshot != nil {
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package quote_computation_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) (*ComputeOutput, error) {
|
||||
@@ -12,8 +12,19 @@ func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput)
|
||||
return nil, merrors.InvalidArgument("quote computation core is required")
|
||||
}
|
||||
|
||||
s.logger.Debug("Computing quotes",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.Int("intent_count", len(in.Intents)),
|
||||
zap.Bool("preview_only", in.PreviewOnly),
|
||||
)
|
||||
|
||||
planModel, err := s.BuildPlan(ctx, in)
|
||||
if err != nil {
|
||||
s.logger.Warn("Quote plan build failed",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,11 +35,26 @@ func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput)
|
||||
if item == nil {
|
||||
return nil, computeErr
|
||||
}
|
||||
return nil, fmt.Errorf("Item %d: %w", item.Index, computeErr)
|
||||
|
||||
s.logger.Warn("Quote item computation failed",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.Int("item_index", item.Index),
|
||||
zap.String("intent_ref", item.IntentRef),
|
||||
zap.Error(computeErr),
|
||||
)
|
||||
|
||||
return nil, wrapIndexedError(computeErr, "Item %d", item.Index)
|
||||
}
|
||||
|
||||
results = append(results, computed)
|
||||
}
|
||||
|
||||
s.logger.Debug("Quote computation completed",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.String("plan_mode", string(planModel.Mode)),
|
||||
zap.Int("item_count", len(results)),
|
||||
)
|
||||
|
||||
return &ComputeOutput{
|
||||
Plan: planModel,
|
||||
Results: results,
|
||||
@@ -45,18 +71,38 @@ func (s *QuoteComputationService) computePlanItem(
|
||||
|
||||
quote, expiresAt, err := s.core.BuildQuote(ctx, item.QuoteInput)
|
||||
if err != nil {
|
||||
s.logger.Warn("Quote build failed",
|
||||
zap.Int("item_index", item.Index),
|
||||
zap.String("intent_ref", item.IntentRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enrichedQuote := ensureComputedQuote(quote, item)
|
||||
if bindErr := validateQuoteRouteBinding(enrichedQuote, item.QuoteInput); bindErr != nil {
|
||||
s.logger.Warn("Quote route binding validation failed",
|
||||
zap.Int("item_index", item.Index),
|
||||
zap.String("intent_ref", item.IntentRef),
|
||||
zap.Error(bindErr),
|
||||
)
|
||||
|
||||
return nil, bindErr
|
||||
}
|
||||
|
||||
result := &QuoteComputationResult{
|
||||
s.logger.Debug("Quote item computed",
|
||||
zap.Int("item_index", item.Index),
|
||||
zap.String("intent_ref", item.IntentRef),
|
||||
zap.String("quote_ref", enrichedQuote.QuoteRef),
|
||||
zap.Time("expires_at", expiresAt),
|
||||
zap.String("block_reason", item.BlockReason.String()),
|
||||
)
|
||||
|
||||
return &QuoteComputationResult{
|
||||
ItemIndex: item.Index,
|
||||
Quote: enrichedQuote,
|
||||
ExpiresAt: expiresAt,
|
||||
BlockReason: item.BlockReason,
|
||||
}
|
||||
return result, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
@@ -19,17 +17,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedSettlementModeFromRouteModelValue(value string) paymentv1.SettlementMode {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED":
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
|
||||
case "FIX_SOURCE", "SETTLEMENT_FIX_SOURCE", "SETTLEMENT_MODE_FIX_SOURCE":
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||
default:
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment {
|
||||
switch mode {
|
||||
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func wrapIndexedError(err error, format string, args ...any) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
return merrors.InvalidArgumentWrap(err, msg)
|
||||
}
|
||||
return merrors.InternalWrap(err, msg)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package quote_computation_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -11,6 +10,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *QuoteComputationService) resolveStepGateways(
|
||||
@@ -19,14 +19,28 @@ func (s *QuoteComputationService) resolveStepGateways(
|
||||
routeNetwork string,
|
||||
) error {
|
||||
if s == nil || s.gatewayRegistry == nil {
|
||||
s.logger.Debug("Step gateway resolution skipped: no gateway registry configured")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Debug("Loading gateway registry",
|
||||
zap.Int("step_count", len(steps)),
|
||||
zap.String("route_network", routeNetwork),
|
||||
)
|
||||
|
||||
gateways, err := s.gatewayRegistry.List(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("Step gateway resolution failed: gateway registry list error",
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if len(gateways) == 0 {
|
||||
s.logger.Warn("Step gateway resolution failed: gateway registry has no entries")
|
||||
|
||||
return merrors.InvalidArgument("gateway registry has no entries")
|
||||
}
|
||||
|
||||
@@ -40,29 +54,54 @@ func (s *QuoteComputationService) resolveStepGateways(
|
||||
return model.LessGatewayDescriptor(sorted[i], sorted[j])
|
||||
})
|
||||
|
||||
s.logger.Debug("Gateway registry loaded", zap.Int("gateway_count", len(sorted)))
|
||||
|
||||
for idx, step := range steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if step.Rail == model.RailLedger {
|
||||
step.GatewayID = "internal"
|
||||
step.GatewayInvokeURI = ""
|
||||
|
||||
s.logger.Debug("Step gateway assigned: ledger rail uses internal gateway",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.Int("step_index", idx),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
selected, selectErr := selectGatewayForStep(sorted, step, routeNetwork)
|
||||
selected, selectErr := s.selectGatewayForStep(sorted, step, routeNetwork)
|
||||
if selectErr != nil {
|
||||
return fmt.Errorf("Step[%d] %s: %w", idx, strings.TrimSpace(step.StepID), selectErr)
|
||||
s.logger.Warn("Step gateway resolution failed: no eligible gateway for step",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.Int("step_index", idx),
|
||||
zap.String("rail", string(step.Rail)),
|
||||
zap.String("route_network", routeNetwork),
|
||||
zap.Error(selectErr),
|
||||
)
|
||||
|
||||
return selectErr
|
||||
}
|
||||
|
||||
step.GatewayID = strings.TrimSpace(selected.ID)
|
||||
step.InstanceID = strings.TrimSpace(selected.InstanceID)
|
||||
step.GatewayInvokeURI = strings.TrimSpace(selected.InvokeURI)
|
||||
|
||||
s.logger.Debug("Gateway selected for step",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("rail", string(step.Rail)),
|
||||
zap.String("gateway_id", step.GatewayID),
|
||||
zap.String("instance_id", step.InstanceID),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectGatewayForStep(
|
||||
func (s *QuoteComputationService) selectGatewayForStep(
|
||||
gateways []*model.GatewayInstanceDescriptor,
|
||||
step *QuoteComputationStep,
|
||||
routeNetwork string,
|
||||
@@ -82,39 +121,67 @@ func selectGatewayForStep(
|
||||
amount = parsed
|
||||
}
|
||||
}
|
||||
action := gatewayEligibilityOperation(step.Operation)
|
||||
action := step.Operation
|
||||
direction := plan.SendDirectionForRail(step.Rail)
|
||||
network := networkForGatewaySelection(step.Rail, routeNetwork)
|
||||
|
||||
s.logger.Debug("Selecting gateway for step",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("rail", string(step.Rail)),
|
||||
zap.String("network", network),
|
||||
zap.String("currency", currency),
|
||||
zap.String("action", string(action)),
|
||||
zap.String("preferred_gateway", step.GatewayID),
|
||||
)
|
||||
|
||||
eligible := make([]*model.GatewayInstanceDescriptor, 0, len(gateways))
|
||||
var lastErr error
|
||||
for _, gw := range gateways {
|
||||
for i, gw := range gateways {
|
||||
if gw == nil {
|
||||
s.logger.Warn("Nil gateway found", zap.Int("gateway_index", i))
|
||||
continue
|
||||
}
|
||||
if err := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount); err != nil {
|
||||
lastErr = err
|
||||
eligErr := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount)
|
||||
s.logger.Debug("Gateway eligibility check",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("gateway_id", strings.TrimSpace(gw.ID)),
|
||||
zap.String("instance_id", strings.TrimSpace(gw.InstanceID)),
|
||||
zap.Bool("eligible", eligErr == nil),
|
||||
zap.Error(eligErr),
|
||||
)
|
||||
if eligErr != nil {
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, gw)
|
||||
}
|
||||
|
||||
if selected, _ := model.SelectGatewayByPreference(
|
||||
eligible,
|
||||
step.GatewayID,
|
||||
step.InstanceID,
|
||||
step.GatewayInvokeURI,
|
||||
); selected != nil {
|
||||
s.logger.Debug("Gateway eligibility evaluated",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.Int("eligible_count", len(eligible)),
|
||||
zap.Int("total_count", len(gateways)),
|
||||
)
|
||||
|
||||
selected, _ := model.SelectGatewayByPreference(eligible, step.GatewayID, step.InstanceID, step.GatewayInvokeURI)
|
||||
if selected == nil && len(eligible) > 0 {
|
||||
selected = eligible[0]
|
||||
}
|
||||
if selected != nil {
|
||||
s.logger.Debug("Gateway selected",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("gateway_id", strings.TrimSpace(selected.ID)),
|
||||
zap.String("instance_id", strings.TrimSpace(selected.InstanceID)),
|
||||
)
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
if len(eligible) > 0 {
|
||||
return eligible[0], nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, merrors.InvalidArgument("no eligible gateway: " + lastErr.Error())
|
||||
}
|
||||
return nil, merrors.InvalidArgument("no eligible gateway")
|
||||
s.logger.Warn("No eligible gateway found for step",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("rail", string(step.Rail)),
|
||||
zap.String("network", network),
|
||||
zap.String("currency", currency),
|
||||
)
|
||||
|
||||
return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(direction)))
|
||||
}
|
||||
|
||||
func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) {
|
||||
@@ -132,15 +199,6 @@ func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func gatewayEligibilityOperation(op model.RailOperation) model.RailOperation {
|
||||
switch op {
|
||||
case model.RailOperationExternalDebit, model.RailOperationExternalCredit:
|
||||
return model.RailOperationSend
|
||||
default:
|
||||
return op
|
||||
}
|
||||
}
|
||||
|
||||
func networkForGatewaySelection(rail model.Rail, routeNetwork string) string {
|
||||
switch rail {
|
||||
case model.RailCrypto, model.RailProviderSettlement, model.RailFiatOnRamp:
|
||||
@@ -150,23 +208,15 @@ func networkForGatewaySelection(rail model.Rail, routeNetwork string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func hasExplicitDestinationGateway(attrs map[string]string) bool {
|
||||
return strings.TrimSpace(firstNonEmpty(
|
||||
lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"),
|
||||
lookupAttr(attrs, "destination_gateway", "destinationGateway"),
|
||||
)) != ""
|
||||
}
|
||||
|
||||
func clearImplicitDestinationGateway(steps []*QuoteComputationStep) {
|
||||
if len(steps) == 0 {
|
||||
return
|
||||
func toGatewayDirection(dir plan.SendDirection) model.GatewayDirection {
|
||||
switch dir {
|
||||
case plan.SendDirectionOut:
|
||||
return model.GatewayDirectionOut
|
||||
case plan.SendDirectionIn:
|
||||
return model.GatewayDirectionIn
|
||||
default:
|
||||
return model.GatewayDirectionAny
|
||||
}
|
||||
last := steps[len(steps)-1]
|
||||
if last == nil {
|
||||
return
|
||||
}
|
||||
last.GatewayID = ""
|
||||
last.GatewayInvokeURI = ""
|
||||
}
|
||||
|
||||
func destinationGatewayFromSteps(steps []*QuoteComputationStep) string {
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
const defaultCardGateway = "monetix"
|
||||
|
||||
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput) (*QuoteComputationPlan, error) {
|
||||
@@ -33,6 +34,14 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput
|
||||
if len(in.Intents) > 1 {
|
||||
mode = PlanModeBatch
|
||||
}
|
||||
|
||||
s.logger.Debug("Building computation plan",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.String("plan_mode", string(mode)),
|
||||
zap.Int("intent_count", len(in.Intents)),
|
||||
zap.Bool("preview_only", in.PreviewOnly),
|
||||
)
|
||||
|
||||
planModel := &QuoteComputationPlan{
|
||||
Mode: mode,
|
||||
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
|
||||
@@ -45,11 +54,24 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput
|
||||
for i, intent := range in.Intents {
|
||||
item, err := s.buildPlanItem(ctx, in, i, intent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("intents[%d]: %w", i, err)
|
||||
s.logger.Warn("Computation plan item build failed",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.Int("intent_index", i),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, wrapIndexedError(err, "intents[%d]", i)
|
||||
}
|
||||
|
||||
planModel.Items = append(planModel.Items, item)
|
||||
}
|
||||
|
||||
s.logger.Debug("Computation plan built",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.String("plan_mode", string(planModel.Mode)),
|
||||
zap.Int("item_count", len(planModel.Items)),
|
||||
)
|
||||
|
||||
return planModel, nil
|
||||
}
|
||||
|
||||
@@ -60,56 +82,117 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
intent *transfer_intent_hydrator.QuoteIntent,
|
||||
) (*QuoteComputationPlanItem, error) {
|
||||
if intent == nil {
|
||||
s.logger.Warn("Plan item build failed: intent is nil", zap.Int("index", index))
|
||||
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
|
||||
modelIntent := modelIntentFromQuoteIntent(intent)
|
||||
if modelIntent.Amount == nil {
|
||||
s.logger.Warn("Plan item build failed: intent amount is nil", zap.Int("index", index))
|
||||
|
||||
return nil, merrors.InvalidArgument("intent.amount is required")
|
||||
}
|
||||
|
||||
if modelIntent.Source.Type == model.EndpointTypeUnspecified {
|
||||
s.logger.Warn("Plan item build failed: intent source is unspecified", zap.Int("index", index))
|
||||
|
||||
return nil, merrors.InvalidArgument("intent.source is required")
|
||||
}
|
||||
|
||||
if modelIntent.Destination.Type == model.EndpointTypeUnspecified {
|
||||
s.logger.Warn("Plan item build failed: intent destination is unspecified", zap.Int("index", index))
|
||||
|
||||
return nil, merrors.InvalidArgument("intent.destination is required")
|
||||
}
|
||||
|
||||
s.logger.Debug("Plan item intent validated",
|
||||
zap.Int("index", index),
|
||||
zap.String("source_type", string(modelIntent.Source.Type)),
|
||||
zap.String("dest_type", string(modelIntent.Destination.Type)),
|
||||
zap.String("amount_currency", modelIntent.Amount.GetCurrency()),
|
||||
)
|
||||
|
||||
itemIdempotencyKey := deriveItemIdempotencyKey(strings.TrimSpace(in.BaseIdempotencyKey), len(in.Intents), index)
|
||||
|
||||
source := clonePaymentEndpoint(modelIntent.Source)
|
||||
destination := clonePaymentEndpoint(modelIntent.Destination)
|
||||
|
||||
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
|
||||
if err != nil {
|
||||
s.logger.Warn("Plan item build failed: source rail resolution error",
|
||||
zap.Int("index", index),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
destRail, destNetwork, err := plan.RailFromEndpoint(destination, modelIntent.Attributes, false)
|
||||
if err != nil {
|
||||
s.logger.Warn("Plan item build failed: destination rail resolution error",
|
||||
zap.Int("index", index),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routeNetwork, err := plan.ResolveRouteNetwork(modelIntent.Attributes, sourceNetwork, destNetwork)
|
||||
if err != nil {
|
||||
s.logger.Warn("Plan item build failed: route network resolution error",
|
||||
zap.Int("index", index),
|
||||
zap.String("source_network", sourceNetwork),
|
||||
zap.String("dest_network", destNetwork),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("Plan item rails resolved",
|
||||
zap.Int("index", index),
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destRail)),
|
||||
zap.String("route_network", firstNonEmpty(routeNetwork, destNetwork, sourceNetwork)),
|
||||
)
|
||||
|
||||
routeRails, err := s.resolveRouteRails(ctx, sourceRail, destRail, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork))
|
||||
if err != nil {
|
||||
s.logger.Warn("Plan item build failed: route rails resolution error",
|
||||
zap.Int("index", index),
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destRail)),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("Plan item route rails resolved",
|
||||
zap.Int("index", index),
|
||||
zap.Int("route_rails_count", len(routeRails)),
|
||||
)
|
||||
|
||||
steps := buildComputationSteps(index, modelIntent, destination, routeRails)
|
||||
if modelIntent.Destination.Type == model.EndpointTypeCard &&
|
||||
s.gatewayRegistry != nil &&
|
||||
!hasExplicitDestinationGateway(modelIntent.Attributes) {
|
||||
// Avoid sticky default provider when registry-driven selection is available.
|
||||
clearImplicitDestinationGateway(steps)
|
||||
}
|
||||
|
||||
s.logger.Debug("Plan item steps built", zap.Int("index", index), zap.Int("step_count", len(steps)))
|
||||
|
||||
if err := s.resolveStepGateways(
|
||||
ctx,
|
||||
steps,
|
||||
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
|
||||
); err != nil {
|
||||
s.logger.Warn("Plan item build failed: step gateway resolution error",
|
||||
zap.Int("index", index),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("Plan item step gateways resolved", zap.Int("index", index))
|
||||
|
||||
provider := firstNonEmpty(
|
||||
destinationGatewayFromSteps(steps),
|
||||
gatewayKeyForFunding(modelIntent.Attributes, destination),
|
||||
@@ -117,6 +200,7 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
if provider == "" && destRail == model.RailLedger {
|
||||
provider = "internal"
|
||||
}
|
||||
|
||||
funding, err := s.resolveFundingGate(ctx, resolveFundingGateInput{
|
||||
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
|
||||
Rail: destRail,
|
||||
@@ -133,17 +217,36 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
InstanceID: instanceIDForFunding(modelIntent.Attributes),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Plan item build failed: funding gate resolution error",
|
||||
zap.Int("index", index),
|
||||
zap.String("provider", provider),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("Plan item funding gate resolved",
|
||||
zap.Int("index", index),
|
||||
zap.String("provider", provider),
|
||||
zap.Bool("has_funding", funding != nil),
|
||||
)
|
||||
|
||||
route := buildRouteSpecification(
|
||||
modelIntent,
|
||||
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
|
||||
steps,
|
||||
)
|
||||
conditions, blockReason := buildExecutionConditions(in.PreviewOnly, steps, funding)
|
||||
|
||||
if route == nil || len(route.GetHops()) == 0 {
|
||||
blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
|
||||
|
||||
s.logger.Debug("Plan item route unavailable, item will be blocked",
|
||||
zap.Int("index", index),
|
||||
)
|
||||
}
|
||||
|
||||
quoteInput := BuildQuoteInput{
|
||||
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
|
||||
IdempotencyKey: itemIdempotencyKey,
|
||||
@@ -160,6 +263,15 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
intentRef = fmt.Sprintf("intent-%d", index)
|
||||
}
|
||||
|
||||
s.logger.Debug("Computation plan item built",
|
||||
zap.Int("index", index),
|
||||
zap.String("intent_ref", intentRef),
|
||||
zap.Int("step_count", len(steps)),
|
||||
zap.String("block_reason", blockReason.String()),
|
||||
zap.Bool("has_funding", funding != nil),
|
||||
zap.Bool("preview_only", quoteInput.PreviewOnly),
|
||||
)
|
||||
|
||||
return &QuoteComputationPlanItem{
|
||||
Index: index,
|
||||
IdempotencyKey: itemIdempotencyKey,
|
||||
@@ -187,14 +299,11 @@ func deriveItemIdempotencyKey(base string, total, index int) string {
|
||||
return fmt.Sprintf("%s:%d", base, index+1)
|
||||
}
|
||||
|
||||
func gatewayKeyForFunding(attrs map[string]string, destination model.PaymentEndpoint) string {
|
||||
func gatewayKeyForFunding(attrs map[string]string, _ model.PaymentEndpoint) string {
|
||||
key := firstNonEmpty(
|
||||
lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"),
|
||||
lookupAttr(attrs, "destination_gateway", "destinationGateway"),
|
||||
)
|
||||
if key == "" && destination.Card != nil {
|
||||
return defaultCardGateway
|
||||
}
|
||||
return normalizeGatewayKey(key)
|
||||
}
|
||||
|
||||
@@ -225,9 +334,23 @@ func (s *QuoteComputationService) resolveFundingGate(
|
||||
in resolveFundingGateInput,
|
||||
) (*gateway_funding_profile.QuoteFundingGate, error) {
|
||||
if s == nil || s.fundingResolver == nil {
|
||||
s.logger.Debug("Funding gate resolution skipped: no funding resolver configured",
|
||||
zap.String("gateway_id", in.GatewayID),
|
||||
zap.String("rail", string(in.Rail)),
|
||||
)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s.logger.Debug("Resolving funding gate",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.String("gateway_id", in.GatewayID),
|
||||
zap.String("instance_id", in.InstanceID),
|
||||
zap.String("rail", string(in.Rail)),
|
||||
zap.String("network", in.Network),
|
||||
zap.String("currency", in.Currency),
|
||||
)
|
||||
|
||||
profile, err := s.fundingResolver.ResolveGatewayFundingProfile(ctx, gateway_funding_profile.FundingProfileRequest{
|
||||
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
|
||||
GatewayID: normalizeGatewayKey(in.GatewayID),
|
||||
@@ -241,10 +364,40 @@ func (s *QuoteComputationService) resolveFundingGate(
|
||||
Attributes: in.Attributes,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Funding gate resolution failed",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
zap.String("gateway_id", in.GatewayID),
|
||||
zap.String("rail", string(in.Rail)),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
s.logger.Debug("Funding gate resolution returned no profile",
|
||||
zap.String("gateway_id", in.GatewayID),
|
||||
zap.String("rail", string(in.Rail)),
|
||||
)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
return gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount)
|
||||
|
||||
gate, err := gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount)
|
||||
if err != nil {
|
||||
s.logger.Warn("Funding gate build from profile failed",
|
||||
zap.String("gateway_id", in.GatewayID),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("Funding gate resolved",
|
||||
zap.String("gateway_id", in.GatewayID),
|
||||
zap.String("rail", string(in.Rail)),
|
||||
zap.Bool("has_gate", gate != nil),
|
||||
)
|
||||
|
||||
return gate, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *QuoteComputationService) resolveRouteRails(
|
||||
@@ -15,26 +16,69 @@ func (s *QuoteComputationService) resolveRouteRails(
|
||||
destinationRail model.Rail,
|
||||
network string,
|
||||
) ([]model.Rail, error) {
|
||||
s.logger.Debug("Resolving route rails",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
zap.String("network", network),
|
||||
)
|
||||
|
||||
if sourceRail == model.RailUnspecified {
|
||||
s.logger.Warn("Route rails resolution failed: source rail is unspecified")
|
||||
|
||||
return nil, merrors.InvalidArgument("source rail is required")
|
||||
}
|
||||
|
||||
if destinationRail == model.RailUnspecified {
|
||||
s.logger.Warn("Route rails resolution failed: destination rail is unspecified")
|
||||
|
||||
return nil, merrors.InvalidArgument("destination rail is required")
|
||||
}
|
||||
|
||||
if sourceRail == destinationRail {
|
||||
s.logger.Debug("Route rails resolved: same rail, no path finding needed",
|
||||
zap.String("rail", string(sourceRail)),
|
||||
)
|
||||
|
||||
return []model.Rail{sourceRail}, nil
|
||||
}
|
||||
|
||||
strictGraph := s != nil && s.routeStore != nil
|
||||
|
||||
s.logger.Debug("Loading route graph edges",
|
||||
zap.Bool("strict_graph", strictGraph),
|
||||
)
|
||||
|
||||
edges, err := s.routeGraphEdges(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("Route rails resolution failed: route graph edges load error",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("Route graph edges loaded",
|
||||
zap.Int("edge_count", len(edges)),
|
||||
zap.Bool("strict_graph", strictGraph),
|
||||
)
|
||||
|
||||
if len(edges) == 0 {
|
||||
if strictGraph {
|
||||
s.logger.Warn("Route rails resolution failed: route graph has no edges",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
)
|
||||
|
||||
return nil, merrors.InvalidArgument("route graph has no edges")
|
||||
}
|
||||
|
||||
s.logger.Debug("Route graph has no edges, using fallback path",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
)
|
||||
|
||||
return fallbackRouteRails(sourceRail, destinationRail), nil
|
||||
}
|
||||
|
||||
@@ -43,6 +87,12 @@ func (s *QuoteComputationService) resolveRouteRails(
|
||||
pathFinder = graph_path_finder.New()
|
||||
}
|
||||
|
||||
s.logger.Debug("Finding route path",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
zap.String("network", network),
|
||||
)
|
||||
|
||||
path, findErr := pathFinder.Find(graph_path_finder.FindInput{
|
||||
SourceRail: sourceRail,
|
||||
DestinationRail: destinationRail,
|
||||
@@ -51,31 +101,75 @@ func (s *QuoteComputationService) resolveRouteRails(
|
||||
})
|
||||
if findErr != nil {
|
||||
if strictGraph {
|
||||
s.logger.Warn("Route rails resolution failed: path finding error",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
zap.String("network", network),
|
||||
zap.Error(findErr),
|
||||
)
|
||||
|
||||
return nil, findErr
|
||||
}
|
||||
|
||||
s.logger.Debug("Route path finding failed, using fallback path",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
zap.String("network", network),
|
||||
zap.Error(findErr),
|
||||
)
|
||||
|
||||
return fallbackRouteRails(sourceRail, destinationRail), nil
|
||||
}
|
||||
|
||||
if path == nil || len(path.Rails) == 0 {
|
||||
if strictGraph {
|
||||
s.logger.Warn("Route rails resolution failed: path is empty",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
zap.String("network", network),
|
||||
)
|
||||
|
||||
return nil, merrors.InvalidArgument("route path is empty")
|
||||
}
|
||||
|
||||
s.logger.Debug("Route path is empty, using fallback path",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
zap.String("network", network),
|
||||
)
|
||||
|
||||
return fallbackRouteRails(sourceRail, destinationRail), nil
|
||||
}
|
||||
|
||||
s.logger.Debug("Route rails resolved",
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("dest_rail", string(destinationRail)),
|
||||
zap.Int("rail_count", len(path.Rails)),
|
||||
)
|
||||
|
||||
return append([]model.Rail(nil), path.Rails...), nil
|
||||
}
|
||||
|
||||
func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_path_finder.Edge, error) {
|
||||
if s == nil || s.routeStore == nil {
|
||||
s.logger.Debug("Route graph edges skipped: no route store configured")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
enabled := true
|
||||
routes, err := s.routeStore.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled})
|
||||
if err != nil {
|
||||
s.logger.Warn("Route graph edges load failed",
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if routes == nil || len(routes.Items) == 0 {
|
||||
s.logger.Debug("Route graph edges: no routes found")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -84,17 +178,26 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_
|
||||
if route == nil || !route.IsEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail))))
|
||||
to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail))))
|
||||
|
||||
if from == model.RailUnspecified || to == model.RailUnspecified {
|
||||
continue
|
||||
}
|
||||
|
||||
edges = append(edges, graph_path_finder.Edge{
|
||||
FromRail: from,
|
||||
ToRail: to,
|
||||
Network: strings.ToUpper(strings.TrimSpace(route.Network)),
|
||||
})
|
||||
}
|
||||
|
||||
s.logger.Debug("Route graph edges built",
|
||||
zap.Int("route_count", len(routes.Items)),
|
||||
zap.Int("edge_count", len(edges)),
|
||||
)
|
||||
|
||||
return edges, nil
|
||||
}
|
||||
|
||||
@@ -102,8 +205,10 @@ func fallbackRouteRails(sourceRail, destinationRail model.Rail) []model.Rail {
|
||||
if sourceRail == destinationRail {
|
||||
return []model.Rail{sourceRail}
|
||||
}
|
||||
|
||||
if requiresTransitBridgeStep(sourceRail, destinationRail) {
|
||||
return []model.Rail{sourceRail, model.RailLedger, destinationRail}
|
||||
}
|
||||
|
||||
return []model.Rail{sourceRail, destinationRail}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,6 @@ func normalizeProvider(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func normalizePayoutMethod(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func normalizeAsset(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/plan"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Core interface {
|
||||
@@ -21,12 +23,14 @@ type QuoteComputationService struct {
|
||||
gatewayRegistry plan.GatewayRegistry
|
||||
routeStore plan.RouteStore
|
||||
pathFinder *graph_path_finder.GraphPathFinder
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(core Core, opts ...Option) *QuoteComputationService {
|
||||
svc := &QuoteComputationService{
|
||||
core: core,
|
||||
pathFinder: graph_path_finder.New(),
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
@@ -67,3 +71,11 @@ func WithPathFinder(pathFinder *graph_path_finder.GraphPathFinder) Option {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(logger mlogger.Logger) Option {
|
||||
return func(svc *QuoteComputationService) {
|
||||
if svc != nil && logger != nil {
|
||||
svc.logger = logger.Named("computation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,39 @@ import (
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
|
||||
type quoteRequest struct {
|
||||
Meta *sharedv1.RequestMeta
|
||||
IdempotencyKey string
|
||||
Intent *sharedv1.PaymentIntent
|
||||
}
|
||||
|
||||
func (r *quoteRequest) GetMeta() *sharedv1.RequestMeta {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.Meta
|
||||
}
|
||||
|
||||
func (r *quoteRequest) GetIdempotencyKey() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.IdempotencyKey
|
||||
}
|
||||
|
||||
func (r *quoteRequest) GetIntent() *sharedv1.PaymentIntent {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.Intent
|
||||
}
|
||||
|
||||
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quoteRequest) (*sharedv1.PaymentQuote, time.Time, error) {
|
||||
intent := req.GetIntent()
|
||||
amount := intent.GetAmount()
|
||||
fxSide := fxv1.Side_SIDE_UNSPECIFIED
|
||||
@@ -117,7 +143,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo
|
||||
return quote, expiresAt, nil
|
||||
}
|
||||
|
||||
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
if !s.deps.fees.available() {
|
||||
return &feesv1.PrecomputeFeesResponse{}, nil
|
||||
}
|
||||
@@ -153,7 +179,7 @@ func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quotationv1
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
if !s.deps.fees.available() {
|
||||
return &feesv1.PrecomputeFeesResponse{}, nil
|
||||
}
|
||||
@@ -454,7 +480,7 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.Payme
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
|
||||
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteRequest) (*oraclev1.Quote, error) {
|
||||
if !s.deps.oracle.available() {
|
||||
if req.GetIntent().GetRequiresFx() {
|
||||
return nil, merrors.Internal("fx_oracle_unavailable")
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"google.golang.org/grpc"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type serviceError string
|
||||
@@ -29,49 +22,37 @@ var (
|
||||
errStorageUnavailable = serviceError("payments.quotation: storage not initialised")
|
||||
)
|
||||
|
||||
// Service handles payment quotation and read models.
|
||||
// Service hosts quotation-v2 runtime dependencies.
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
clock clockpkg.Clock
|
||||
|
||||
deps serviceDependencies
|
||||
h handlerSet
|
||||
|
||||
gatewayBroker mb.Broker
|
||||
gatewayConsumers []msg.Consumer
|
||||
|
||||
orchestrationv1.UnimplementedPaymentExecutionServiceServer
|
||||
}
|
||||
|
||||
type serviceDependencies struct {
|
||||
fees feesDependency
|
||||
ledger ledgerDependency
|
||||
gateway gatewayDependency
|
||||
railGateways railGatewayDependency
|
||||
providerGateway providerGatewayDependency
|
||||
oracle oracleDependency
|
||||
gatewayRegistry GatewayRegistry
|
||||
gatewayInvokeResolver GatewayInvokeResolver
|
||||
cardRoutes map[string]CardGatewayRoute
|
||||
feeLedgerAccounts map[string]string
|
||||
planBuilder PlanBuilder
|
||||
}
|
||||
|
||||
type handlerSet struct {
|
||||
commands *paymentCommandFactory
|
||||
}
|
||||
|
||||
// NewService constructs the quotation service core.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
logger: logger.Named("payments.quotation"),
|
||||
logger: logger.Named("service"),
|
||||
storage: repo,
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
|
||||
initMetrics()
|
||||
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(svc)
|
||||
@@ -81,34 +62,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
|
||||
if svc.clock == nil {
|
||||
svc.clock = clockpkg.NewSystem()
|
||||
}
|
||||
|
||||
engine := defaultPaymentEngine{svc: svc}
|
||||
svc.h.commands = newPaymentCommandFactory(engine, svc.logger)
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *Service) ensureHandlers() {
|
||||
if s.h.commands == nil {
|
||||
s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger)
|
||||
}
|
||||
}
|
||||
|
||||
// Register attaches the service to the supplied gRPC router.
|
||||
func (s *Service) Register(router routers.GRPC) error {
|
||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||
orchestrationv1.RegisterPaymentExecutionServiceServer(reg, s)
|
||||
})
|
||||
}
|
||||
|
||||
// QuotePayment aggregates downstream quotes.
|
||||
func (s *Service) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req)
|
||||
}
|
||||
|
||||
// QuotePayments aggregates downstream quotes for multiple intents.
|
||||
func (s *Service) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req)
|
||||
}
|
||||
// Shutdown releases runtime resources. Quotation v2 has no background workers.
|
||||
func (s *Service) Shutdown() {}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func validateMetaAndOrgRef(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) {
|
||||
if meta == nil {
|
||||
return "", bson.NilObjectID, merrors.InvalidArgument("meta is required")
|
||||
}
|
||||
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
|
||||
if orgRef == "" {
|
||||
return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
orgID, err := bson.ObjectIDFromHex(orgRef)
|
||||
if err != nil {
|
||||
return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID")
|
||||
}
|
||||
return orgRef, orgID, nil
|
||||
}
|
||||
|
||||
func requireNonNilIntent(intent *sharedv1.PaymentIntent) error {
|
||||
if intent == nil {
|
||||
return merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
if intent.GetAmount() == nil {
|
||||
return merrors.InvalidArgument("intent.amount is required")
|
||||
}
|
||||
if strings.TrimSpace(intent.GetSettlementCurrency()) == "" {
|
||||
return merrors.InvalidArgument("intent.settlement_currency is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureQuotesStore(repo storage.Repository) (quotestorage.QuotesStore, error) {
|
||||
if repo == nil {
|
||||
return nil, errStorageUnavailable
|
||||
}
|
||||
store := repo.Quotes()
|
||||
if store == nil {
|
||||
return nil, errStorageUnavailable
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
type quoteResolutionInput struct {
|
||||
OrgRef string
|
||||
OrgID bson.ObjectID
|
||||
Meta *sharedv1.RequestMeta
|
||||
Intent *sharedv1.PaymentIntent
|
||||
QuoteRef string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type quoteResolutionError struct {
|
||||
code string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
||||
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) {
|
||||
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||
quotesStore, err := ensureQuotesStore(s.storage)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
||||
if err != nil {
|
||||
if errors.Is(err, quotestorage.ErrQuoteNotFound) {
|
||||
return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
}
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
||||
return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
}
|
||||
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
|
||||
return nil, nil, nil, quoteResolutionError{code: "quote_not_executable", err: merrors.InvalidArgument(note)}
|
||||
}
|
||||
intent, err := recordIntentFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
||||
return nil, nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
}
|
||||
quote, err := recordQuoteFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
quote.QuoteRef = ref
|
||||
plan, err := recordPlanFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return quote, intent, plan, nil
|
||||
}
|
||||
|
||||
if in.Intent == nil {
|
||||
return nil, nil, nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
req := "ationv1.QuotePaymentRequest{
|
||||
Meta: in.Meta,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
Intent: in.Intent,
|
||||
PreviewOnly: false,
|
||||
}
|
||||
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
plan, err := s.buildPaymentPlan(ctx, in.OrgID, in.Intent, in.IdempotencyKey, quote)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return quote, in.Intent, plan, nil
|
||||
}
|
||||
|
||||
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.PaymentIntent, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
if len(record.Intents) > 0 {
|
||||
if len(record.Intents) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intents[0]), nil
|
||||
}
|
||||
if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intent), nil
|
||||
}
|
||||
|
||||
func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.PaymentQuote, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
if record.Quote != nil {
|
||||
return modelQuoteToProto(record.Quote), nil
|
||||
}
|
||||
if len(record.Quotes) > 0 {
|
||||
if len(record.Quotes) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return modelQuoteToProto(record.Quotes[0]), nil
|
||||
}
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
|
||||
func recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
if len(record.Plans) > 0 {
|
||||
if len(record.Plans) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return cloneStoredPaymentPlan(record.Plans[0]), nil
|
||||
}
|
||||
if record.Plan != nil {
|
||||
return cloneStoredPaymentPlan(record.Plan), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newPayment(orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *sharedv1.PaymentQuote) *model.Payment {
|
||||
entity := &model.Payment{}
|
||||
entity.SetID(bson.NewObjectID())
|
||||
entity.SetOrganizationRef(orgID)
|
||||
entity.PaymentRef = entity.GetID().Hex()
|
||||
entity.IdempotencyKey = idempotencyKey
|
||||
entity.State = model.PaymentStateAccepted
|
||||
entity.Intent = intentFromProto(intent)
|
||||
entity.Metadata = cloneMetadata(metadata)
|
||||
entity.LastQuote = quoteSnapshotToModel(quote)
|
||||
entity.Normalize()
|
||||
return entity
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package transfer_intent_hydrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -157,13 +158,21 @@ func (h *TransferIntentHydrator) HydrateMany(ctx context.Context, in HydrateMany
|
||||
Intent: intent,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("intents[%d]: %w", i, err)
|
||||
return nil, wrapIndexedIntentError(i, err)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func wrapIndexedIntentError(index int, err error) error {
|
||||
msg := fmt.Sprintf("intents[%d]", index)
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
return merrors.InvalidArgumentWrap(err, msg)
|
||||
}
|
||||
return merrors.InternalWrap(err, msg)
|
||||
}
|
||||
|
||||
func resolveEconomics(
|
||||
mode paymentv1.SettlementMode,
|
||||
feeTreatment quotationv2.FeeTreatment,
|
||||
|
||||
Reference in New Issue
Block a user