Files
sendico/api/payments/quotation/internal/service/quotation/quote_engine.go
2026-02-24 18:33:12 +01:00

616 lines
18 KiB
Go

package quotation
import (
"context"
"errors"
"fmt"
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/payments/storage/model"
chainpkg "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
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"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
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
if fxIntent := fxIntentForQuote(intent); fxIntent != nil {
fxSide = fxIntent.GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("Fx quote attached to payment quote", zap.String("org_ref", orgRef))
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneProtoMoney(amount)
}
intentModel := intentFromProto(intent)
sourceRail, _, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true)
if err != nil {
return nil, time.Time{}, err
}
destRail, _, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false)
if err != nil {
return nil, time.Time{}, err
}
feeRequired := feesRequiredForRails(sourceRail, destRail)
feeQuote := &feesv1.PrecomputeFeesResponse{}
if feeRequired {
feeQuote, err = s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
}
conversionFeeQuote := &feesv1.PrecomputeFeesResponse{}
if s.shouldQuoteConversionFee(ctx, req.GetIntent()) {
conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeLines := cloneFeeLines(feeQuote.GetLines())
if conversionFeeQuote != nil {
feeLines = append(feeLines, cloneFeeLines(conversionFeeQuote.GetLines())...)
}
s.assignFeeLedgerAccounts(intent, feeLines)
feeTotal := extractFeeTotal(feeLines, feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("Network fee estimated", zap.String("org_ref", orgRef))
}
debitAmount, settlementAmount := computeAggregates(
payAmount,
settlementAmountBeforeFees,
feeTotal,
networkFee,
fxQuote,
resolvedFeeTreatmentForQuote(intent),
)
quote := &sharedv1.PaymentQuote{
DebitAmount: debitAmount,
DebitSettlementAmount: payAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: feeLines,
FeeRules: mergeFeeRules(feeQuote, conversionFeeQuote),
FxQuote: fxQuote,
NetworkFee: networkFee,
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
if conversionFeeQuote != nil {
convExpiry := quoteExpiry(s.clock.Now(), conversionFeeQuote, fxQuote)
if convExpiry.Before(expiresAt) {
expiresAt = convExpiry
}
}
return quote, expiresAt, nil
}
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
}
intent := req.GetIntent()
amount := cloneProtoMoney(baseAmount)
if amount == nil {
amount = cloneProtoMoney(intent.GetAmount())
}
attrs := ensureFeeAttributes(intent, amount, cloneMetadata(intent.GetAttributes()))
feeIntent := &feesv1.Intent{
Trigger: feeTriggerForIntent(intent),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: attrs,
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout)
defer cancel()
resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Warn("Fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
}
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
}
intent := req.GetIntent()
amount := cloneProtoMoney(baseAmount)
if amount == nil {
amount = cloneProtoMoney(intent.GetAmount())
}
attrs := ensureFeeAttributes(intent, amount, cloneMetadata(intent.GetAttributes()))
attrs["product"] = "wallet"
attrs["source_type"] = "managed_wallet"
attrs["destination_type"] = "ledger"
feeIntent := &feesv1.Intent{
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.conversion_quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: attrs,
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout)
defer cancel()
resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Warn("Conversion fee precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
setFeeLineTarget(resp.GetLines(), feeLineTargetWallet)
if src := intent.GetSource().GetManagedWallet(); src != nil {
setFeeLineWalletRef(resp.GetLines(), src.GetManagedWalletRef(), "managed_wallet")
}
return resp, nil
}
func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1.PaymentIntent) bool {
if intent == nil {
return false
}
if !isManagedWalletEndpoint(intent.GetSource()) {
return false
}
if isLedgerEndpoint(intent.GetDestination()) {
return false
}
if s.storage == nil {
return false
}
templates := s.storage.PlanTemplates()
if templates == nil {
return false
}
intentModel := intentFromProto(intent)
sourceRail, sourceNetwork, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true)
if err != nil {
return false
}
destRail, destNetwork, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false)
if err != nil {
return false
}
network, err := resolveRouteNetwork(intentModel.Attributes, sourceNetwork, destNetwork)
if err != nil {
return false
}
template, err := selectPlanTemplate(ctx, s.logger.Named("quote_payment"), templates, sourceRail, destRail, network)
if err != nil {
return false
}
return templateHasLedgerMove(template)
}
func templateHasLedgerMove(template *model.PaymentPlanTemplate) bool {
if template == nil {
return false
}
for _, step := range template.Steps {
if step.Rail != model.RailLedger {
continue
}
if strings.EqualFold(strings.TrimSpace(step.Operation), "ledger.move") {
return true
}
}
return false
}
func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule {
rules := cloneFeeRules(nil)
if primary != nil {
rules = append(rules, cloneFeeRules(primary.GetApplied())...)
}
if secondary != nil {
rules = append(rules, cloneFeeRules(secondary.GetApplied())...)
}
if len(rules) == 0 {
return nil
}
return rules
}
func ensureFeeAttributes(intent *sharedv1.PaymentIntent, baseAmount *moneyv1.Money, attrs map[string]string) map[string]string {
if attrs == nil {
attrs = map[string]string{}
}
if intent == nil {
return attrs
}
setFeeAttributeIfMissing(attrs, "product", "wallet")
if op := feeOperationFromKind(intent.GetKind()); op != "" {
setFeeAttributeIfMissing(attrs, "operation", op)
}
if currency := feeCurrencyFromAmount(baseAmount, intent.GetAmount()); currency != "" {
setFeeAttributeIfMissing(attrs, "currency", currency)
}
if srcType := endpointTypeFromProto(intent.GetSource()); srcType != "" {
setFeeAttributeIfMissing(attrs, "source_type", srcType)
}
if dstType := endpointTypeFromProto(intent.GetDestination()); dstType != "" {
setFeeAttributeIfMissing(attrs, "destination_type", dstType)
}
if asset := assetFromIntent(intent); asset != nil {
if token := strings.TrimSpace(asset.GetTokenSymbol()); token != "" {
setFeeAttributeIfMissing(attrs, "asset", token)
}
if chain := asset.GetChain(); chain != chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED {
if network := strings.TrimSpace(chainpkg.NetworkAlias(chain)); network != "" {
setFeeAttributeIfMissing(attrs, "network", network)
}
}
}
return attrs
}
func feeTriggerForIntent(intent *sharedv1.PaymentIntent) feesv1.Trigger {
if intent == nil {
return feesv1.Trigger_TRIGGER_UNSPECIFIED
}
trigger := triggerFromKind(intent.GetKind(), shouldRequestFX(intent))
if trigger != feesv1.Trigger_TRIGGER_FX_CONVERSION && isManagedWalletEndpoint(intent.GetSource()) && isLedgerEndpoint(intent.GetDestination()) {
return feesv1.Trigger_TRIGGER_CAPTURE
}
return trigger
}
func isManagedWalletEndpoint(endpoint *sharedv1.PaymentEndpoint) bool {
return endpoint != nil && endpoint.GetManagedWallet() != nil
}
func isLedgerEndpoint(endpoint *sharedv1.PaymentEndpoint) bool {
return endpoint != nil && endpoint.GetLedger() != nil
}
func setFeeAttributeIfMissing(attrs map[string]string, key, value string) {
if attrs == nil {
return
}
if strings.TrimSpace(key) == "" {
return
}
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, exists := attrs[key]; exists {
return
}
attrs[key] = value
}
func feeOperationFromKind(kind sharedv1.PaymentKind) string {
switch kind {
case sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT:
return "payout"
case sharedv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER:
return "internal_transfer"
case sharedv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION:
return "fx_conversion"
default:
return ""
}
}
func feeCurrencyFromAmount(baseAmount, intentAmount *moneyv1.Money) string {
if baseAmount != nil {
if currency := strings.TrimSpace(baseAmount.GetCurrency()); currency != "" {
return currency
}
}
if intentAmount != nil {
return strings.TrimSpace(intentAmount.GetCurrency())
}
return ""
}
func endpointTypeFromProto(endpoint *sharedv1.PaymentEndpoint) string {
if endpoint == nil {
return ""
}
switch {
case endpoint.GetLedger() != nil:
return "ledger"
case endpoint.GetManagedWallet() != nil:
return "managed_wallet"
case endpoint.GetExternalChain() != nil:
return "external_chain"
case endpoint.GetCard() != nil:
return "card"
default:
return ""
}
}
func assetFromIntent(intent *sharedv1.PaymentIntent) *chainv1.Asset {
if intent == nil {
return nil
}
if asset := assetFromEndpoint(intent.GetDestination()); asset != nil {
return asset
}
return assetFromEndpoint(intent.GetSource())
}
func assetFromEndpoint(endpoint *sharedv1.PaymentEndpoint) *chainv1.Asset {
if endpoint == nil {
return nil
}
if wallet := endpoint.GetManagedWallet(); wallet != nil {
return wallet.GetAsset()
}
if external := endpoint.GetExternalChain(); external != nil {
return external.GetAsset()
}
return nil
}
func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) {
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneProtoMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
network := ""
if req.Asset != nil {
network = chainpkg.NetworkName(req.Asset.GetChain())
}
instanceID := strings.TrimSpace(intent.GetSource().GetInstanceId())
if instanceID == "" {
instanceID = strings.TrimSpace(intent.GetDestination().GetInstanceId())
}
client, _, err := s.resolveChainGatewayClient(ctx, network, moneyFromProto(req.Amount), []model.RailOperation{model.RailOperationSend}, instanceID, "")
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
s.logger.Debug("Network fee estimation skipped: gateway unavailable", zap.Error(err))
return nil, nil
}
s.logger.Warn("Chain gateway resolution failed", zap.Error(err))
return nil, err
}
if client == nil {
return nil, nil
}
resp, err := client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Warn("Chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteRequest) (*oraclev1.Quote, error) {
intent := req.GetIntent()
fxRequired := shouldRequestFX(intent)
if !s.deps.oracle.available() {
if fxRequired {
return nil, merrors.Internal("fx_oracle_unavailable")
}
return nil, nil
}
meta := req.GetMeta()
fxIntent := fxIntentForQuote(intent)
if fxIntent == nil {
if fxRequired {
return nil, merrors.InvalidArgument("fx intent missing")
}
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneProtoMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneProtoMoney(amount)
default:
params.BaseAmount = cloneProtoMoney(amount)
}
} else {
params.BaseAmount = cloneProtoMoney(amount)
}
}
quote, err := s.deps.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Warn("Fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error()))
}
if quote == nil {
if fxRequired {
return nil, merrors.Internal("orchestrator: fx quote missing")
}
return nil, nil
}
return quoteToProto(quote), nil
}
func feesRequiredForRails(sourceRail, destRail model.Rail) bool {
if sourceRail == model.RailLedger && destRail == model.RailLedger {
return false
}
return true
}
func (s *Service) feeLedgerAccountForIntent(intent *sharedv1.PaymentIntent) string {
if intent == nil || len(s.deps.feeLedgerAccounts) == 0 {
return ""
}
key := s.gatewayKeyFromIntent(intent)
if key == "" {
return ""
}
return strings.TrimSpace(s.deps.feeLedgerAccounts[key])
}
func (s *Service) assignFeeLedgerAccounts(intent *sharedv1.PaymentIntent, lines []*feesv1.DerivedPostingLine) {
account := s.feeLedgerAccountForIntent(intent)
key := s.gatewayKeyFromIntent(intent)
missing := 0
for _, line := range lines {
if line == nil {
continue
}
if strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
missing++
}
}
if missing == 0 {
return
}
if account == "" {
s.logger.Debug("No fee ledger account mapping found", zap.String("gateway", key), zap.Int("missing_lines", missing))
return
}
assignLedgerAccounts(lines, account)
s.logger.Debug("Applied fee ledger account mapping", zap.String("gateway", key), zap.String("ledger_account", account), zap.Int("lines", missing))
}
func (s *Service) gatewayKeyFromIntent(intent *sharedv1.PaymentIntent) string {
if intent == nil {
return ""
}
key := strings.TrimSpace(intent.GetAttributes()["gateway"])
if key == "" {
if dest := intent.GetDestination(); dest != nil && dest.GetCard() != nil {
key = defaultCardGateway
}
}
return strings.ToLower(key)
}