payment quotation v2 + payment orchestration v2 draft

This commit is contained in:
Stephan D
2026-02-24 13:01:35 +01:00
parent 0646f55189
commit 6444813f38
289 changed files with 17005 additions and 16065 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &quotePaymentCommand{
engine: f.engine,
logger: f.logger.Named("quote.payment"),
}
}
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
return &quotePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("quote.payments"),
}
}

View File

@@ -1,6 +0,0 @@
package quotation
const (
providerSettlementMetaPaymentIntentID = "payment_ref"
providerSettlementMetaOutgoingLeg = "outgoing_leg"
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
package quotation
func (s *Service) Shutdown() {
if s == nil {
return
}
for _, consumer := range s.gatewayConsumers {
if consumer != nil {
consumer.Close()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&quotationv1.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 &quoteCtx{
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 &quotePaymentResult{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 &quotePaymentResult{
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 &quotePaymentResult{
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 &quotePaymentResult{
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(&quotationv1.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(&quotationv1.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 &quotePaymentsCtx{
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 := &quotationv1.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 &quotationv1.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
}

View File

@@ -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 := &quotationv1.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 := &quoteCommandTestQuotesStore{
byID: make(map[string]*model.PaymentQuoteRecord),
}
engine := &quoteCommandTestEngine{
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 := &quotePaymentCommand{
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 := &quotationv1.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 := &quoteCommandTestQuotesStore{
byID: map[string]*model.PaymentQuoteRecord{
req.GetIdempotencyKey(): existing,
},
}
engine := &quoteCommandTestEngine{
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 := &quotePaymentCommand{
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 := &quotationv2.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
}

View File

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

View File

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

View File

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

View File

@@ -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 := &quoteRequest{
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 &quote_computation_service.ComputedQuote{}
}
resolvedSettlementMode := settlementModeToProto(in.Intent.SettlementMode)
if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED {
resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
}
return &quote_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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 := &quotationv1.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
}

View File

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