Fully separated payment quotation and orchestration flows

This commit is contained in:
Stephan D
2026-02-11 17:25:44 +01:00
parent 9b8f59e05a
commit e116535926
112 changed files with 3204 additions and 8686 deletions

View File

@@ -0,0 +1,5 @@
package quotation
const (
defaultCardGateway = "monetix"
)

View File

@@ -0,0 +1,70 @@
package quotation
import (
"context"
"time"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/v2/bson"
)
type paymentEngine interface {
EnsureRepository(ctx context.Context) error
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, quote *orchestratorv1.PaymentQuote) (*model.PaymentPlan, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.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 *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
return e.svc.buildPaymentQuote(ctx, orgRef, req)
}
func (e defaultPaymentEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, quote *orchestratorv1.PaymentQuote) (*model.PaymentPlan, error) {
return e.svc.buildPaymentPlan(ctx, orgID, intent, idempotencyKey, quote)
}
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.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

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

View File

@@ -0,0 +1,64 @@
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 {
if entry == nil || entry.ID == "" {
continue
}
items[entry.ID] = 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 result[i].ID < result[j].ID
})
return result, nil
}

View File

@@ -0,0 +1,686 @@
package quotation
import (
"strings"
"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"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
if src == nil {
return model.PaymentIntent{}
}
intent := model.PaymentIntent{
Ref: src.GetRef(),
Kind: modelKindFromProto(src.GetKind()),
Source: endpointFromProto(src.GetSource()),
Destination: endpointFromProto(src.GetDestination()),
Amount: moneyFromProto(src.GetAmount()),
RequiresFX: src.GetRequiresFx(),
FeePolicy: feePolicyFromProto(src.GetFeePolicy()),
SettlementMode: settlementModeFromProto(src.GetSettlementMode()),
SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()),
Attributes: cloneMetadata(src.GetAttributes()),
Customer: customerFromProto(src.GetCustomer()),
}
if src.GetFx() != nil {
intent.FX = fxIntentFromProto(src.GetFx())
}
return intent
}
func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoint {
if src == nil {
return model.PaymentEndpoint{Type: model.EndpointTypeUnspecified}
}
result := model.PaymentEndpoint{
Type: model.EndpointTypeUnspecified,
InstanceID: strings.TrimSpace(src.GetInstanceId()),
Metadata: cloneMetadata(src.GetMetadata()),
}
if ledger := src.GetLedger(); ledger != nil {
result.Type = model.EndpointTypeLedger
result.Ledger = &model.LedgerEndpoint{
LedgerAccountRef: strings.TrimSpace(ledger.GetLedgerAccountRef()),
ContraLedgerAccountRef: strings.TrimSpace(ledger.GetContraLedgerAccountRef()),
}
return result
}
if managed := src.GetManagedWallet(); managed != nil {
result.Type = model.EndpointTypeManagedWallet
result.ManagedWallet = &model.ManagedWalletEndpoint{
ManagedWalletRef: strings.TrimSpace(managed.GetManagedWalletRef()),
Asset: assetFromProto(managed.GetAsset()),
}
return result
}
if external := src.GetExternalChain(); external != nil {
result.Type = model.EndpointTypeExternalChain
result.ExternalChain = &model.ExternalChainEndpoint{
Asset: assetFromProto(external.GetAsset()),
Address: strings.TrimSpace(external.GetAddress()),
Memo: strings.TrimSpace(external.GetMemo()),
}
return result
}
if card := src.GetCard(); card != nil {
result.Type = model.EndpointTypeCard
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(card.GetPan()),
Token: strings.TrimSpace(card.GetToken()),
Cardholder: strings.TrimSpace(card.GetCardholderName()),
CardholderSurname: strings.TrimSpace(card.GetCardholderSurname()),
ExpMonth: card.GetExpMonth(),
ExpYear: card.GetExpYear(),
Country: strings.TrimSpace(card.GetCountry()),
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
}
return result
}
return result
}
func fxIntentFromProto(src *orchestratorv1.FXIntent) *model.FXIntent {
if src == nil {
return nil
}
return &model.FXIntent{
Pair: pairFromProto(src.GetPair()),
Side: fxSideFromProto(src.GetSide()),
Firm: src.GetFirm(),
TTLMillis: src.GetTtlMs(),
PreferredProvider: strings.TrimSpace(src.GetPreferredProvider()),
MaxAgeMillis: src.GetMaxAgeMs(),
}
}
func quoteSnapshotToModel(src *orchestratorv1.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) *orchestratorv1.PaymentIntent {
intent := &orchestratorv1.PaymentIntent{
Ref: src.Ref,
Kind: protoKindFromModel(src.Kind),
Source: protoEndpointFromModel(src.Source),
Destination: protoEndpointFromModel(src.Destination),
Amount: protoMoney(src.Amount),
RequiresFx: src.RequiresFX,
FeePolicy: feePolicyToProto(src.FeePolicy),
SettlementMode: settlementModeToProto(src.SettlementMode),
SettlementCurrency: strings.TrimSpace(src.SettlementCurrency),
Attributes: cloneMetadata(src.Attributes),
Customer: protoCustomerFromModel(src.Customer),
}
if src.FX != nil {
intent.Fx = protoFXIntentFromModel(src.FX)
}
return intent
}
func customerFromProto(src *orchestratorv1.Customer) *model.Customer {
if src == nil {
return nil
}
return &model.Customer{
ID: strings.TrimSpace(src.GetId()),
FirstName: strings.TrimSpace(src.GetFirstName()),
MiddleName: strings.TrimSpace(src.GetMiddleName()),
LastName: strings.TrimSpace(src.GetLastName()),
IP: strings.TrimSpace(src.GetIp()),
Zip: strings.TrimSpace(src.GetZip()),
Country: strings.TrimSpace(src.GetCountry()),
State: strings.TrimSpace(src.GetState()),
City: strings.TrimSpace(src.GetCity()),
Address: strings.TrimSpace(src.GetAddress()),
}
}
func protoCustomerFromModel(src *model.Customer) *orchestratorv1.Customer {
if src == nil {
return nil
}
return &orchestratorv1.Customer{
Id: strings.TrimSpace(src.ID),
FirstName: strings.TrimSpace(src.FirstName),
MiddleName: strings.TrimSpace(src.MiddleName),
LastName: strings.TrimSpace(src.LastName),
Ip: strings.TrimSpace(src.IP),
Zip: strings.TrimSpace(src.Zip),
Country: strings.TrimSpace(src.Country),
State: strings.TrimSpace(src.State),
City: strings.TrimSpace(src.City),
Address: strings.TrimSpace(src.Address),
}
}
func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint {
endpoint := &orchestratorv1.PaymentEndpoint{
Metadata: cloneMetadata(src.Metadata),
InstanceId: strings.TrimSpace(src.InstanceID),
}
switch src.Type {
case model.EndpointTypeLedger:
if src.Ledger != nil {
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Ledger{
Ledger: &orchestratorv1.LedgerEndpoint{
LedgerAccountRef: src.Ledger.LedgerAccountRef,
ContraLedgerAccountRef: src.Ledger.ContraLedgerAccountRef,
},
}
}
case model.EndpointTypeManagedWallet:
if src.ManagedWallet != nil {
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{
ManagedWalletRef: src.ManagedWallet.ManagedWalletRef,
Asset: assetToProto(src.ManagedWallet.Asset),
},
}
}
case model.EndpointTypeExternalChain:
if src.ExternalChain != nil {
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{
ExternalChain: &orchestratorv1.ExternalChainEndpoint{
Asset: assetToProto(src.ExternalChain.Asset),
Address: src.ExternalChain.Address,
Memo: src.ExternalChain.Memo,
},
}
}
case model.EndpointTypeCard:
if src.Card != nil {
card := &orchestratorv1.CardEndpoint{
CardholderName: src.Card.Cardholder,
CardholderSurname: src.Card.CardholderSurname,
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: src.Card.Country,
MaskedPan: src.Card.MaskedPan,
}
if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
if token := strings.TrimSpace(src.Card.Token); token != "" {
card.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
}
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card}
}
default:
// leave unspecified
}
return endpoint
}
func protoFXIntentFromModel(src *model.FXIntent) *orchestratorv1.FXIntent {
if src == nil {
return nil
}
return &orchestratorv1.FXIntent{
Pair: pairToProto(src.Pair),
Side: fxSideToProto(src.Side),
Firm: src.Firm,
TtlMs: src.TTLMillis,
PreferredProvider: src.PreferredProvider,
MaxAgeMs: src.MaxAgeMillis,
}
}
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote {
if src == nil {
return nil
}
return &orchestratorv1.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) orchestratorv1.PaymentKind {
switch kind {
case model.PaymentKindPayout:
return orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT
case model.PaymentKindInternalTransfer:
return orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER
case model.PaymentKindFXConversion:
return orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION
default:
return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED
}
}
func modelKindFromProto(kind orchestratorv1.PaymentKind) model.PaymentKind {
switch kind {
case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT:
return model.PaymentKindPayout
case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER:
return model.PaymentKindInternalTransfer
case orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION:
return model.PaymentKindFXConversion
default:
return model.PaymentKindUnspecified
}
}
func settlementModeFromProto(mode orchestratorv1.SettlementMode) model.SettlementMode {
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE:
return model.SettlementModeFixSource
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return model.SettlementModeFixReceived
default:
return model.SettlementModeUnspecified
}
}
func settlementModeToProto(mode model.SettlementMode) orchestratorv1.SettlementMode {
switch mode {
case model.SettlementModeFixSource:
return orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE
case model.SettlementModeFixReceived:
return orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
default:
return orchestratorv1.SettlementMode_SETTLEMENT_UNSPECIFIED
}
}
func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money {
if m == nil {
return nil
}
return &paymenttypes.Money{
Currency: m.GetCurrency(),
Amount: m.GetAmount(),
}
}
func protoMoney(m *paymenttypes.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{
Currency: m.GetCurrency(),
Amount: m.GetAmount(),
}
}
func feePolicyFromProto(src *feesv1.PolicyOverrides) *paymenttypes.FeePolicy {
if src == nil {
return nil
}
return &paymenttypes.FeePolicy{
InsufficientNet: insufficientPolicyFromProto(src.GetInsufficientNet()),
}
}
func feePolicyToProto(src *paymenttypes.FeePolicy) *feesv1.PolicyOverrides {
if src == nil {
return nil
}
return &feesv1.PolicyOverrides{
InsufficientNet: insufficientPolicyToProto(src.InsufficientNet),
}
}
func insufficientPolicyFromProto(policy feesv1.InsufficientNetPolicy) paymenttypes.InsufficientNetPolicy {
switch policy {
case feesv1.InsufficientNetPolicy_BLOCK_POSTING:
return paymenttypes.InsufficientNetBlockPosting
case feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH:
return paymenttypes.InsufficientNetSweepOrgCash
case feesv1.InsufficientNetPolicy_INVOICE_LATER:
return paymenttypes.InsufficientNetInvoiceLater
default:
return paymenttypes.InsufficientNetUnspecified
}
}
func insufficientPolicyToProto(policy paymenttypes.InsufficientNetPolicy) feesv1.InsufficientNetPolicy {
switch policy {
case paymenttypes.InsufficientNetBlockPosting:
return feesv1.InsufficientNetPolicy_BLOCK_POSTING
case paymenttypes.InsufficientNetSweepOrgCash:
return feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH
case paymenttypes.InsufficientNetInvoiceLater:
return feesv1.InsufficientNetPolicy_INVOICE_LATER
default:
return feesv1.InsufficientNetPolicy_INSUFFICIENT_NET_UNSPECIFIED
}
}
func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair {
if pair == nil {
return nil
}
return &paymenttypes.CurrencyPair{
Base: pair.GetBase(),
Quote: pair.GetQuote(),
}
}
func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair {
if pair == nil {
return nil
}
return &fxv1.CurrencyPair{
Base: pair.GetBase(),
Quote: pair.GetQuote(),
}
}
func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide {
switch side {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
return paymenttypes.FXSideBuyBaseSellQuote
case fxv1.Side_SELL_BASE_BUY_QUOTE:
return paymenttypes.FXSideSellBaseBuyQuote
default:
return paymenttypes.FXSideUnspecified
}
}
func fxSideToProto(side paymenttypes.FXSide) fxv1.Side {
switch side {
case paymenttypes.FXSideBuyBaseSellQuote:
return fxv1.Side_BUY_BASE_SELL_QUOTE
case paymenttypes.FXSideSellBaseBuyQuote:
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote {
if quote == nil {
return nil
}
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(),
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
}
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,
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
}
return &paymenttypes.Asset{
Chain: chainasset.NetworkAlias(asset.GetChain()),
TokenSymbol: asset.GetTokenSymbol(),
ContractAddress: asset.GetContractAddress(),
}
}
func assetToProto(asset *paymenttypes.Asset) *chainv1.Asset {
if asset == nil {
return nil
}
return &chainv1.Asset{
Chain: chainasset.NetworkFromString(asset.Chain),
TokenSymbol: asset.TokenSymbol,
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

@@ -0,0 +1,137 @@
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),
Version: entry.Version,
IsEnabled: entry.Healthy,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].ID < items[j].ID
})
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) model.Limits {
if src == nil {
return model.Limits{}
}
limits := model.Limits{
MinAmount: strings.TrimSpace(src.MinAmount),
MaxAmount: strings.TrimSpace(src.MaxAmount),
VolumeLimit: map[string]string{},
VelocityLimit: map[string]int{},
}
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
}
if len(limits.VolumeLimit) == 0 {
limits.VolumeLimit = nil
}
if len(limits.VelocityLimit) == 0 {
limits.VelocityLimit = nil
}
return limits
}

View File

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

View File

@@ -0,0 +1,120 @@
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 {
if gw == nil {
continue
}
id := strings.TrimSpace(gw.ID)
if id == "" {
continue
}
items[id] = 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 result[i].ID < result[j].ID
})
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

@@ -0,0 +1,139 @@
package quotation
import (
"context"
"sort"
"strings"
"github.com/shopspring/decimal"
chainclient "github.com/tech/sendico/gateway/chain/client"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
func (s *Service) resolveChainGatewayClient(ctx context.Context, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, paymentRef string) (chainclient.Client, *model.GatewayInstanceDescriptor, error) {
if s.deps.gatewayRegistry != nil && s.deps.gatewayInvokeResolver != nil {
entry, err := selectGatewayForActions(ctx, s.deps.gatewayRegistry, model.RailCrypto, network, amount, actions, instanceID, sendDirectionForRail(model.RailCrypto))
if err != nil {
return nil, nil, err
}
invokeURI := strings.TrimSpace(entry.InvokeURI)
if invokeURI == "" {
return nil, nil, merrors.InvalidArgument("chain gateway: invoke uri is required")
}
client, err := s.deps.gatewayInvokeResolver.Resolve(ctx, invokeURI)
if err != nil {
return nil, nil, err
}
if s.logger != nil {
fields := []zap.Field{
zap.String("gateway_id", entry.ID),
zap.String("instance_id", entry.InstanceID),
zap.String("rail", string(entry.Rail)),
zap.String("network", entry.Network),
zap.String("invoke_uri", invokeURI),
}
if paymentRef != "" {
fields = append(fields, zap.String("payment_ref", paymentRef))
}
if len(actions) > 0 {
fields = append(fields, zap.Strings("actions", railActionNames(actions)))
}
s.logger.Info("Chain gateway selected", fields...)
}
return client, entry, nil
}
if s.deps.gateway.resolver != nil {
client, err := s.deps.gateway.resolver.Resolve(ctx, network)
if err != nil {
return nil, nil, err
}
return client, nil, nil
}
return nil, nil, merrors.NoData("chain gateway unavailable")
}
func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, dir plan.SendDirection) (*model.GatewayInstanceDescriptor, error) {
if registry == nil {
return nil, merrors.NoData("gateway registry unavailable")
}
all, err := registry.List(ctx)
if err != nil {
return nil, err
}
if len(all) == 0 {
return nil, merrors.NoData("no gateway instances available")
}
if len(actions) == 0 {
actions = []model.RailOperation{model.RailOperationSend}
}
currency := ""
amt := decimal.Zero
if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" {
amt, err = decimalFromMoney(amount)
if err != nil {
return nil, err
}
currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
}
network = strings.ToUpper(strings.TrimSpace(network))
eligible := make([]*model.GatewayInstanceDescriptor, 0)
var lastErr error
for _, entry := range all {
if entry == nil || !entry.IsEnabled {
continue
}
if entry.Rail != rail {
continue
}
if instanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) {
continue
}
ok := true
for _, action := range actions {
if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil {
lastErr = err
ok = false
break
}
}
if !ok {
continue
}
eligible = append(eligible, entry)
}
if len(eligible) == 0 {
if lastErr != nil {
return nil, merrors.NoData("no eligible gateway instance found: " + lastErr.Error())
}
return nil, merrors.NoData("no eligible gateway instance found")
}
sort.Slice(eligible, func(i, j int) bool {
return eligible[i].ID < eligible[j].ID
})
return eligible[0], nil
}
func railActionNames(actions []model.RailOperation) []string {
if len(actions) == 0 {
return nil
}
names := make([]string, 0, len(actions))
for _, action := range actions {
name := strings.TrimSpace(string(action))
if name == "" {
continue
}
names = append(names, name)
}
if len(names) == 0 {
return nil
}
return names
}

View File

@@ -0,0 +1,602 @@
package quotation
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"strings"
"time"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"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"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/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 *orchestratorv1.PaymentIntent
previewOnly bool
idempotencyKey string
hash string
}
func (h *quotePaymentCommand) Execute(
ctx context.Context,
req *orchestratorv1.QuotePaymentRequest,
) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.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[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req)
if err != nil {
return h.mapQuoteErr(err)
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{
IdempotencyKey: req.GetIdempotencyKey(),
Quote: quoteProto,
})
}
func (h *quotePaymentCommand) prepareQuoteCtx(req *orchestratorv1.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 storage.QuotesStore,
qc *quoteCtx,
req *orchestratorv1.QuotePaymentRequest,
) (*orchestratorv1.PaymentQuote, 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 quote, nil
}
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
if err != nil && !errors.Is(err, storage.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 modelQuoteToProto(existing.Quote), 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
plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote)
if err != nil {
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),
ExpiresAt: expiresAt,
}
record.SetID(bson.NewObjectID())
record.SetOrganizationRef(qc.orgRef)
if err := quotesStore.Create(ctx, record); err != nil {
if errors.Is(err, storage.ErrDuplicateQuote) {
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
if getErr == nil && existing != nil {
if existing.Hash != qc.hash {
return nil, errIdempotencyParamMismatch
}
return modelQuoteToProto(existing.Quote), 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 quote, nil
}
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if errors.Is(err, errIdempotencyRequired) ||
errors.Is(err, errPreviewWithIdempotency) ||
errors.Is(err, errIdempotencyParamMismatch) {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
// TODO: temprorarary hashing function, replace with a proper solution later
func hashQuoteRequest(req *orchestratorv1.QuotePaymentRequest) string {
cloned := proto.Clone(req).(*orchestratorv1.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 *orchestratorv1.QuotePaymentsRequest,
) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.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[orchestratorv1.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[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
aggregate, expiresAt, err := h.aggregate(quotes, expires)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
_ = expiresAt
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
QuoteRef: "",
Aggregate: aggregate,
Quotes: quotes,
})
}
if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil {
return gsresponse.Auto[orchestratorv1.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[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
aggregate, expiresAt, err := h.aggregate(quotes, expires)
if err != nil {
return gsresponse.Auto[orchestratorv1.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[orchestratorv1.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(&orchestratorv1.QuotePaymentsResponse{
IdempotencyKey: req.GetIdempotencyKey(),
QuoteRef: quoteRef,
Aggregate: aggregate,
Quotes: quotes,
})
}
func (h *quotePaymentsCommand) prepare(req *orchestratorv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*orchestratorv1.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 storage.QuotesStore,
qc *quotePaymentsCtx,
) (*model.PaymentQuoteRecord, bool, error) {
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
if err != nil {
if errors.Is(err, storage.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 *orchestratorv1.RequestMeta,
orgRef bson.ObjectID,
baseKey string,
intents []*orchestratorv1.PaymentIntent,
preview bool,
) ([]*orchestratorv1.PaymentQuote, []*model.PaymentPlan, []time.Time, error) {
quotes := make([]*orchestratorv1.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 := &orchestratorv1.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 []*orchestratorv1.PaymentQuote,
expires []time.Time,
) (*orchestratorv1.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 storage.QuotesStore,
qc *quotePaymentsCtx,
quoteRef string,
intents []*orchestratorv1.PaymentIntent,
quotes []*orchestratorv1.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, storage.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) *orchestratorv1.QuotePaymentsResponse {
quotes := modelQuotesToProto(rec.Quotes)
for _, q := range quotes {
if q != nil {
q.QuoteRef = rec.QuoteRef
}
}
aggregate, _ := aggregatePaymentQuotes(quotes)
return &orchestratorv1.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[orchestratorv1.QuotePaymentsResponse] {
if errors.Is(err, errBatchIdempotencyRequired) ||
errors.Is(err, errBatchPreviewWithIdempotency) ||
errors.Is(err, errBatchIdempotencyParamMismatch) ||
errors.Is(err, errBatchIdempotencyShapeMismatch) {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*orchestratorv1.PaymentQuote {
if len(snaps) == 0 {
return nil
}
out := make([]*orchestratorv1.PaymentQuote, 0, len(snaps))
for _, s := range snaps {
out = append(out, modelQuoteToProto(s))
}
return out
}
func hashQuotePaymentsIntents(intents []*orchestratorv1.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

@@ -0,0 +1,391 @@
package quotation
import (
"strings"
"time"
"github.com/shopspring/decimal"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"google.golang.org/protobuf/proto"
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"
)
type moneyGetter interface {
GetAmount() string
GetCurrency() string
}
const (
feeLineMetaTarget = "fee_target"
feeLineTargetWallet = "wallet"
feeLineMetaWalletRef = "fee_wallet_ref"
feeLineMetaWalletType = "fee_wallet_type"
)
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
if input == nil {
return nil
}
return &moneyv1.Money{
Currency: input.GetCurrency(),
Amount: input.GetAmount(),
}
}
func cloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
clone := make(map[string]string, len(input))
for k, v := range input {
clone[k] = v
}
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
}
out := make([]*feesv1.DerivedPostingLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
if cloned, ok := proto.Clone(line).(*feesv1.DerivedPostingLine); ok {
out = append(out, cloned)
}
}
if len(out) == 0 {
return nil
}
return out
}
func cloneFeeRules(rules []*feesv1.AppliedRule) []*feesv1.AppliedRule {
if len(rules) == 0 {
return nil
}
out := make([]*feesv1.AppliedRule, 0, len(rules))
for _, rule := range rules {
if rule == nil {
continue
}
if cloned, ok := proto.Clone(rule).(*feesv1.AppliedRule); ok {
out = append(out, cloned)
}
}
if len(out) == 0 {
return nil
}
return out
}
func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *moneyv1.Money {
if len(lines) == 0 || currency == "" {
return nil
}
total := decimal.Zero
for _, line := range lines {
if line == nil || line.GetMoney() == nil {
continue
}
if !strings.EqualFold(line.GetMoney().GetCurrency(), currency) {
continue
}
amount, err := decimal.NewFromString(line.GetMoney().GetAmount())
if err != nil {
continue
}
switch line.GetSide() {
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
total = total.Sub(amount.Abs())
default:
total = total.Add(amount.Abs())
}
}
if total.IsZero() {
return nil
}
return &moneyv1.Money{
Currency: currency,
Amount: total.String(),
}
}
func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) {
if fxQuote == nil {
return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount)
}
qSide := fxQuote.GetSide()
if qSide == fxv1.Side_SIDE_UNSPECIFIED {
qSide = side
}
switch qSide {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
pay := cloneProtoMoney(fxQuote.GetQuoteAmount())
settle := cloneProtoMoney(fxQuote.GetBaseAmount())
if pay == nil {
pay = cloneProtoMoney(intentAmount)
}
if settle == nil {
settle = cloneProtoMoney(intentAmount)
}
return pay, settle
case fxv1.Side_SELL_BASE_BUY_QUOTE:
pay := cloneProtoMoney(fxQuote.GetBaseAmount())
settle := cloneProtoMoney(fxQuote.GetQuoteAmount())
if pay == nil {
pay = cloneProtoMoney(intentAmount)
}
if settle == nil {
settle = cloneProtoMoney(intentAmount)
}
return pay, settle
default:
return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount)
}
}
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode orchestratorv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) {
if pay == nil {
return nil, nil
}
debitDecimal, err := decimalFromMoney(pay)
if err != nil {
return cloneProtoMoney(pay), cloneProtoMoney(settlement)
}
settlementCurrency := pay.GetCurrency()
if settlement != nil && strings.TrimSpace(settlement.GetCurrency()) != "" {
settlementCurrency = settlement.GetCurrency()
}
settlementDecimal := debitDecimal
if settlement != nil {
if val, err := decimalFromMoney(settlement); err == nil {
settlementDecimal = val
}
}
applyChargeToDebit := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
if err != nil || converted == nil {
return
}
if val, err := decimalFromMoney(converted); err == nil {
debitDecimal = debitDecimal.Add(val)
}
}
applyChargeToSettlement := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
if err != nil || converted == nil {
return
}
if val, err := decimalFromMoney(converted); err == nil {
settlementDecimal = settlementDecimal.Sub(val)
}
}
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
// Sender pays the fee: keep settlement fixed, increase debit.
applyChargeToDebit(fee)
default:
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
applyChargeToSettlement(fee)
}
if network != nil && network.GetNetworkFee() != nil {
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
applyChargeToDebit(network.GetNetworkFee())
default:
applyChargeToSettlement(network.GetNetworkFee())
}
}
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
}
func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) {
if m == nil {
return decimal.Zero, nil
}
return decimal.NewFromString(m.GetAmount())
}
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
return &moneyv1.Money{
Currency: currency,
Amount: value.String(),
}
}
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
if m == nil || strings.TrimSpace(targetCurrency) == "" {
return nil, nil
}
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
return cloneProtoMoney(m), nil
}
return convertWithQuote(m, quote, targetCurrency)
}
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
return nil, nil
}
base := strings.TrimSpace(quote.GetPair().GetBase())
qt := strings.TrimSpace(quote.GetPair().GetQuote())
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
return nil, nil
}
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
if err != nil || price.IsZero() {
return nil, err
}
value, err := decimalFromMoney(m)
if err != nil {
return nil, err
}
switch {
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
return makeMoney(targetCurrency, value.Mul(price)), nil
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
return makeMoney(targetCurrency, value.Div(price)), nil
default:
return nil, nil
}
}
func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
if src == nil {
return nil
}
return &oraclev1.Quote{
QuoteRef: src.QuoteRef,
Pair: src.Pair,
Side: src.Side,
Price: &moneyv1.Decimal{Value: src.Price},
BaseAmount: cloneProtoMoney(src.BaseAmount),
QuoteAmount: cloneProtoMoney(src.QuoteAmount),
ExpiresAtUnixMs: src.ExpiresAt.UnixMilli(),
Provider: src.Provider,
RateRef: src.RateRef,
Firm: src.Firm,
}
}
func setFeeLineTarget(lines []*feesv1.DerivedPostingLine, target string) {
target = strings.TrimSpace(target)
if target == "" || len(lines) == 0 {
return
}
for _, line := range lines {
if line == nil {
continue
}
if line.Meta == nil {
line.Meta = map[string]string{}
}
line.Meta[feeLineMetaTarget] = target
if strings.EqualFold(target, feeLineTargetWallet) {
line.LedgerAccountRef = ""
}
}
}
func feeLineTarget(line *feesv1.DerivedPostingLine) string {
if line == nil {
return ""
}
return strings.TrimSpace(line.GetMeta()[feeLineMetaTarget])
}
func isWalletTargetFeeLine(line *feesv1.DerivedPostingLine) bool {
return strings.EqualFold(feeLineTarget(line), feeLineTargetWallet)
}
func setFeeLineWalletRef(lines []*feesv1.DerivedPostingLine, walletRef, walletType string) {
walletRef = strings.TrimSpace(walletRef)
walletType = strings.TrimSpace(walletType)
if walletRef == "" || len(lines) == 0 {
return
}
for _, line := range lines {
if line == nil {
continue
}
if line.Meta == nil {
line.Meta = map[string]string{}
}
line.Meta[feeLineMetaWalletRef] = walletRef
if walletType != "" {
line.Meta[feeLineMetaWalletType] = walletType
}
}
}
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
expiry := time.Time{}
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
expiry = feeQuote.GetExpiresAt().AsTime()
}
if expiry.IsZero() {
expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond)
}
if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 {
fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC()
if fxExpiry.Before(expiry) {
expiry = fxExpiry
}
}
return expiry
}
func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine {
if account == "" || len(lines) == 0 {
return lines
}
for _, line := range lines {
if line == nil || isWalletTargetFeeLine(line) {
continue
}
if strings.TrimSpace(line.GetLedgerAccountRef()) != "" {
continue
}
line.LedgerAccountRef = account
}
return lines
}

View File

@@ -0,0 +1,107 @@
package quotation
import (
"context"
"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"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/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)
}
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 orchestratorv1.PaymentKind, requiresFX bool) feesv1.Trigger {
switch kind {
case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT:
return feesv1.Trigger_TRIGGER_PAYOUT
case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER:
return feesv1.Trigger_TRIGGER_CAPTURE
case orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION:
return feesv1.Trigger_TRIGGER_FX_CONVERSION
default:
if requiresFX {
return feesv1.Trigger_TRIGGER_FX_CONVERSION
}
return feesv1.Trigger_TRIGGER_UNSPECIFIED
}
}
func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
if intent == nil {
return false
}
dest := intent.GetDestination()
if dest == nil {
return false
}
if dest.GetCard() != nil {
return false
}
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
return true
}
if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil {
return true
}
return false
}
func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
if intent == nil {
return false
}
if fxIntentForQuote(intent) != nil {
return true
}
return intent.GetRequiresFx()
}
func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIntent {
if intent == nil {
return nil
}
if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil {
return fx
}
amount := intent.GetAmount()
if amount == nil {
return nil
}
settlementCurrency := strings.TrimSpace(intent.GetSettlementCurrency())
if settlementCurrency == "" {
return nil
}
if strings.EqualFold(amount.GetCurrency(), settlementCurrency) {
return nil
}
return &orchestratorv1.FXIntent{
Pair: &fxv1.CurrencyPair{
Base: strings.TrimSpace(amount.GetCurrency()),
Quote: settlementCurrency,
},
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
}
}

View File

@@ -0,0 +1,65 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,299 @@
package quotation
import (
"context"
"sort"
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
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/mlogger"
"github.com/tech/sendico/pkg/payments/rail"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
)
// Option configures service dependencies.
type Option func(*Service)
// GatewayInvokeResolver resolves gateway invoke URIs into chain gateway clients.
type GatewayInvokeResolver interface {
Resolve(ctx context.Context, invokeURI string) (chainclient.Client, error)
}
// ChainGatewayResolver resolves chain gateway clients by network.
type ChainGatewayResolver interface {
Resolve(ctx context.Context, network string) (chainclient.Client, error)
}
type feesDependency struct {
client feesv1.FeeEngineClient
timeout time.Duration
}
func (f feesDependency) available() bool {
if f.client == nil {
return false
}
if checker, ok := f.client.(interface{ Available() bool }); ok {
return checker.Available()
}
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
}
func (o oracleDependency) available() bool {
if o.client == nil {
return false
}
if checker, ok := o.client.(interface{ Available() bool }); ok {
return checker.Available()
}
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")
}
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,
}
}
}
func WithPaymentGatewayBroker(broker mb.Broker) 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,
}
}
}
// WithChainGatewayClient wires the chain gateway client.
func WithChainGatewayClient(client chainclient.Client) Option {
return func(s *Service) {
s.deps.gateway = gatewayDependency{resolver: staticChainGatewayResolver{client: client}}
}
}
// WithChainGatewayResolver wires a resolver for chain gateway clients.
func WithChainGatewayResolver(resolver ChainGatewayResolver) Option {
return func(s *Service) {
if resolver != nil {
s.deps.gateway = gatewayDependency{resolver: resolver}
}
}
}
// 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.
func WithGatewayInvokeResolver(resolver GatewayInvokeResolver) Option {
return func(s *Service) {
if resolver == nil {
return
}
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 {
return func(s *Service) {
if len(gateways) == 0 {
return
}
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}
}
}
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
return func(s *Service) {
if len(routes) == 0 {
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 == "" {
continue
}
s.deps.feeLedgerAccounts[key] = val
}
}
}
// WithPlanBuilder wires a payment plan builder implementation.
func WithPlanBuilder(builder PlanBuilder) Option {
return func(s *Service) {
if builder != nil {
s.deps.planBuilder = builder
}
}
}
// 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)
}
}
}
}
// WithClock overrides the default clock.
func WithClock(clock clockpkg.Clock) Option {
return func(s *Service) {
if clock != nil {
s.clock = clock
}
}
}
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,
}
if len(gateways) == 0 {
return result
}
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())
if railID == model.RailUnspecified {
continue
}
itemsByRail[railID] = append(itemsByRail[railID], item{id: cleanID, gw: gw})
}
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)
}
}
return result
}

View File

@@ -0,0 +1,159 @@
package quotation
import (
"context"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (s *Service) buildPaymentPlan(
ctx context.Context,
orgID bson.ObjectID,
intent *orchestratorv1.PaymentIntent,
idempotencyKey string,
quote *orchestratorv1.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 = &orchestratorv1.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),
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,
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

@@ -0,0 +1,28 @@
package quotation
import (
"context"
"github.com/tech/sendico/payments/storage/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/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 *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
}

View File

@@ -0,0 +1,34 @@
package quotation
import (
"context"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/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 *orchestratorv1.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)
}
func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
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) {
return plan.SelectTemplate(ctx, logger, templates, sourceRail, destRail, network)
}

View File

@@ -0,0 +1,19 @@
package quotation
import (
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/storage/model"
)
func sendDirectionForRail(rail model.Rail) plan.SendDirection {
return plan.SendDirectionForRail(rail)
}
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

@@ -0,0 +1,180 @@
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

@@ -0,0 +1,41 @@
package quotation
import (
"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"
"google.golang.org/grpc"
)
// QuotationService exposes only quotation RPCs as a standalone gRPC service.
type QuotationService struct {
core *Service
quote *quotationService
}
// 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),
}
}
// 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 {
return nil
}
return router.Register(func(reg grpc.ServiceRegistrar) {
quotationv1.RegisterQuotationServiceServer(reg, s.quote)
})
}
// Shutdown releases resources used by the underlying core service.
func (s *QuotationService) Shutdown() {
if s != nil && s.core != nil {
s.core.Shutdown()
}
}

View File

@@ -0,0 +1,24 @@
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,145 @@
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"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/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 []*orchestratorv1.PaymentQuote) (*orchestratorv1.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 &orchestratorv1.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 []*orchestratorv1.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 []*orchestratorv1.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

@@ -0,0 +1,579 @@
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"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.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, intent.GetSettlementMode())
quote := &orchestratorv1.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 *orchestratorv1.QuotePaymentRequest, 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 *orchestratorv1.QuotePaymentRequest, 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 *orchestratorv1.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 *orchestratorv1.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 *orchestratorv1.PaymentIntent) feesv1.Trigger {
if intent == nil {
return feesv1.Trigger_TRIGGER_UNSPECIFIED
}
trigger := triggerFromKind(intent.GetKind(), intent.GetRequiresFx())
if trigger != feesv1.Trigger_TRIGGER_FX_CONVERSION && isManagedWalletEndpoint(intent.GetSource()) && isLedgerEndpoint(intent.GetDestination()) {
return feesv1.Trigger_TRIGGER_CAPTURE
}
return trigger
}
func isManagedWalletEndpoint(endpoint *orchestratorv1.PaymentEndpoint) bool {
return endpoint != nil && endpoint.GetManagedWallet() != nil
}
func isLedgerEndpoint(endpoint *orchestratorv1.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 orchestratorv1.PaymentKind) string {
switch kind {
case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT:
return "payout"
case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER:
return "internal_transfer"
case orchestratorv1.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 *orchestratorv1.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 *orchestratorv1.PaymentIntent) *chainv1.Asset {
if intent == nil {
return nil
}
if asset := assetFromEndpoint(intent.GetDestination()); asset != nil {
return asset
}
return assetFromEndpoint(intent.GetSource())
}
func assetFromEndpoint(endpoint *orchestratorv1.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 *orchestratorv1.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 *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.deps.oracle.available() {
if req.GetIntent().GetRequiresFx() {
return nil, merrors.Internal("fx_oracle_unavailable")
}
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := fxIntentForQuote(intent)
if fxIntent == nil {
if intent.GetRequiresFx() {
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 intent.GetRequiresFx() {
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 *orchestratorv1.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 *orchestratorv1.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 *orchestratorv1.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)
}

View File

@@ -0,0 +1,114 @@
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"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"google.golang.org/grpc"
)
type serviceError string
func (e serviceError) Error() string {
return string(e)
}
const (
defaultFeeQuoteTTLMillis int64 = 120000
defaultOracleTTLMillis int64 = 60000
)
var (
errStorageUnavailable = serviceError("payments.quotation: storage not initialised")
)
// Service handles payment quotation and read models.
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 {
svc := &Service{
logger: logger.Named("payments.quotation"),
storage: repo,
clock: clockpkg.NewSystem(),
}
initMetrics()
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
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 *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.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 *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req)
}

View File

@@ -0,0 +1,186 @@
package quotation
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/protobuf/proto"
)
func validateMetaAndOrgRef(meta *orchestratorv1.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 *orchestratorv1.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) (storage.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 *orchestratorv1.RequestMeta
Intent *orchestratorv1.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) (*orchestratorv1.PaymentQuote, *orchestratorv1.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, storage.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")}
}
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 := &orchestratorv1.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) (*orchestratorv1.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) (*orchestratorv1.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 *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.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
}