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

@@ -17,12 +17,10 @@ replace github.com/tech/sendico/ledger => ../../ledger
replace github.com/tech/sendico/payments/storage => ../storage
require (
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/payments/storage v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0
@@ -41,6 +39,7 @@ require (
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -52,6 +51,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect

View File

@@ -11,6 +11,34 @@ import (
const quotationDiscoverySender = "payment_quotation"
func (i *Imp) initDiscovery(cfg *config) {
if i == nil || cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
return
}
logger := i.logger.Named("discovery")
broker, err := msg.CreateMessagingBroker(logger.Named("bus"), cfg.Messaging)
if err != nil {
i.logger.Warn("Failed to initialise discovery broker", zap.Error(err))
return
}
registry := discovery.NewRegistry()
watcher, err := discovery.NewRegistryWatcher(logger, broker, registry)
if err != nil {
i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(err))
return
}
if err := watcher.Start(); err != nil {
i.logger.Warn("Failed to start discovery registry watcher", zap.Error(err))
return
}
i.discoveryWatcher = watcher
i.discoveryReg = registry
i.logger.Info("Discovery registry watcher started")
}
func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) {
if i == nil || cfg == nil || producer == nil || cfg.GRPC == nil {
return
@@ -43,3 +71,15 @@ func (i *Imp) stopDiscoveryAnnouncer() {
i.discoveryAnnouncer.Stop()
i.discoveryAnnouncer = nil
}
func (i *Imp) stopDiscovery() {
if i == nil {
return
}
i.stopDiscoveryAnnouncer()
if i.discoveryWatcher != nil {
i.discoveryWatcher.Stop()
i.discoveryWatcher = nil
}
i.discoveryReg = nil
}

View File

@@ -1,7 +1,7 @@
package serverimp
import (
quotesvc "github.com/tech/sendico/payments/quotation/internal/service/orchestrator"
quotesvc "github.com/tech/sendico/payments/quotation/internal/service/quotation"
"github.com/tech/sendico/payments/storage"
mongostorage "github.com/tech/sendico/payments/storage/mongo"
"github.com/tech/sendico/pkg/db"
@@ -19,7 +19,7 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
}
func (i *Imp) Shutdown() {
i.stopDiscoveryAnnouncer()
i.stopDiscovery()
if i.service != nil {
i.service.Shutdown()
}
@@ -34,6 +34,7 @@ func (i *Imp) Start() error {
}
i.config = cfg
i.initDiscovery(cfg)
i.deps = i.initDependencies(cfg)
quoteRetention := cfg.quoteRetention()
@@ -54,6 +55,9 @@ func (i *Imp) Start() error {
opts = append(opts, quotesvc.WithChainGatewayClient(i.deps.gatewayClient))
}
}
if registry := quotesvc.NewDiscoveryGatewayRegistry(logger, i.discoveryReg); registry != nil {
opts = append(opts, quotesvc.WithGatewayRegistry(registry))
}
i.startDiscoveryAnnouncer(cfg, producer)
svc := quotesvc.NewQuotationService(logger, repo, opts...)
i.service = svc

View File

@@ -33,5 +33,7 @@ type Imp struct {
service quoteService
deps *clientDependencies
discoveryWatcher *discovery.RegistryWatcher
discoveryReg *discovery.Registry
discoveryAnnouncer *discovery.Announcer
}

View File

@@ -1,10 +0,0 @@
package orchestrator
const (
defaultCardGateway = "monetix"
stepCodeGasTopUp = "gas_top_up"
stepCodeFundingTransfer = "funding_transfer"
stepCodeCardPayout = "card_payout"
stepCodeFeeTransfer = "fee_transfer"
)

View File

@@ -1,367 +0,0 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
chainclient "github.com/tech/sendico/gateway/chain/client"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
source := intent.Source.ManagedWallet
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card funding: source managed wallet is required")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef)
fundingAddress := strings.TrimSpace(route.FundingAddress)
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
intentAmount := cloneMoney(intent.Amount)
if intentAmount == nil || strings.TrimSpace(intentAmount.GetAmount()) == "" || strings.TrimSpace(intentAmount.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: amount is required")
}
intentAmountProto := protoMoney(intentAmount)
payoutAmount, err := cardPayoutAmount(payment)
if err != nil {
return err
}
var feeAmount *paymenttypes.Money
if quote != nil {
feeAmount = moneyFromProto(quote.GetExpectedFeeTotal())
}
if feeAmount == nil && payment.LastQuote != nil {
feeAmount = cloneMoney(payment.LastQuote.ExpectedFeeTotal)
}
feeDecimal := decimal.Zero
if feeAmount != nil && strings.TrimSpace(feeAmount.GetAmount()) != "" {
if strings.TrimSpace(feeAmount.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: fee currency is required")
}
feeDecimal, err = decimalFromMoney(feeAmount)
if err != nil {
return err
}
}
feeRequired := feeDecimal.IsPositive()
feeAmountProto := protoMoney(feeAmount)
network := networkFromEndpoint(intent.Source)
instanceID := strings.TrimSpace(intent.Source.InstanceID)
actions := []model.RailOperation{model.RailOperationSend}
if feeRequired {
actions = append(actions, model.RailOperationFee)
}
chainClient, _, err := s.resolveChainGatewayClient(ctx, network, intentAmount, actions, instanceID, payment.PaymentRef)
if err != nil {
s.logger.Warn("card funding gateway resolution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
fundingDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
}
fundingFee, err := s.estimateTransferNetworkFee(ctx, chainClient, sourceWalletRef, fundingDest, intentAmountProto)
if err != nil {
return err
}
var feeTransferFee *moneyv1.Money
if feeRequired {
if feeWalletRef == "" {
return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists")
}
feeDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
}
feeTransferFee, err = s.estimateTransferNetworkFee(ctx, chainClient, sourceWalletRef, feeDest, feeAmountProto)
if err != nil {
return err
}
}
totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
if err != nil {
return err
}
var estimatedTotalFee *moneyv1.Money
if gasCurrency != "" && !totalFee.IsNegative() {
estimatedTotalFee = makeMoney(gasCurrency, totalFee)
}
var topUpMoney *moneyv1.Money
var topUpFee *moneyv1.Money
topUpPositive := false
if estimatedTotalFee != nil {
computeResp, err := chainClient.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
WalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
})
if err != nil {
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if computeResp != nil {
topUpMoney = computeResp.GetTopupAmount()
}
if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" {
amountDec, err := decimalFromMoney(topUpMoney)
if err != nil {
return err
}
topUpPositive = amountDec.IsPositive()
}
if topUpMoney != nil && topUpPositive {
if strings.TrimSpace(topUpMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
if feeWalletRef == "" {
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, chainClient, feeWalletRef, topUpDest, topUpMoney)
if err != nil {
return err
}
}
}
plan := ensureExecutionPlan(payment)
var gasStep *model.ExecutionStep
var feeStep *model.ExecutionStep
if topUpMoney != nil && topUpPositive {
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
setExecutionStepRole(gasStep, executionStepRoleSource)
setExecutionStepStatus(gasStep, model.OperationStatePlanned)
gasStep.Description = "Top up native gas from fee wallet"
gasStep.Amount = moneyFromProto(topUpMoney)
gasStep.NetworkFee = moneyFromProto(topUpFee)
gasStep.SourceWalletRef = feeWalletRef
gasStep.DestinationRef = sourceWalletRef
}
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
setExecutionStepRole(fundStep, executionStepRoleSource)
setExecutionStepStatus(fundStep, model.OperationStatePlanned)
fundStep.Description = "Transfer payout amount to card funding wallet"
fundStep.Amount = cloneMoney(intentAmount)
fundStep.NetworkFee = moneyFromProto(fundingFee)
fundStep.SourceWalletRef = sourceWalletRef
fundStep.DestinationRef = fundingAddress
if feeRequired {
feeStep = ensureExecutionStep(plan, stepCodeFeeTransfer)
setExecutionStepRole(feeStep, executionStepRoleSource)
setExecutionStepStatus(feeStep, model.OperationStatePlanned)
feeStep.Description = "Transfer fee to fee wallet"
feeStep.Amount = cloneMoney(feeAmount)
feeStep.NetworkFee = moneyFromProto(feeTransferFee)
feeStep.SourceWalletRef = sourceWalletRef
feeStep.DestinationRef = feeWalletRef
}
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
setExecutionStepRole(cardStep, executionStepRoleConsumer)
setExecutionStepStatus(cardStep, model.OperationStatePlanned)
cardStep.Description = "Submit card payout"
cardStep.Amount = cloneMoney(payoutAmount)
if card := intent.Destination.Card; card != nil {
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
cardStep.DestinationRef = masked
}
}
updateExecutionPlanTotalNetworkFee(plan)
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
if topUpMoney != nil && topUpPositive {
ensureResp, gasErr := chainClient.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
OrganizationRef: payment.OrganizationRef.Hex(),
IntentRef: strings.TrimSpace(payment.Intent.Ref),
OperationRef: strings.TrimSpace(cardStep.OperationRef),
SourceWalletRef: feeWalletRef,
TargetWalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
Metadata: cloneMetadata(payment.Metadata),
PaymentRef: payment.PaymentRef,
})
if gasErr != nil {
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
return gasErr
}
if gasStep != nil {
actual := (*moneyv1.Money)(nil)
if ensureResp != nil {
actual = ensureResp.GetTopupAmount()
if transfer := ensureResp.GetTransfer(); transfer != nil {
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
}
}
actualPositive := false
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
actualDec, err := decimalFromMoney(actual)
if err != nil {
return err
}
actualPositive = actualDec.IsPositive()
}
if actual != nil && actualPositive {
gasStep.Amount = moneyFromProto(actual)
if strings.TrimSpace(actual.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: gas top-up currency is required")
}
topUpDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
}
topUpFee, err = s.estimateTransferNetworkFee(ctx, chainClient, feeWalletRef, topUpDest, actual)
if err != nil {
return err
}
gasStep.NetworkFee = moneyFromProto(topUpFee)
setExecutionStepStatus(gasStep, model.OperationStateWaiting)
} else {
gasStep.Amount = nil
gasStep.NetworkFee = nil
gasStep.TransferRef = ""
setExecutionStepStatus(gasStep, model.OperationStateSkipped)
}
}
if gasStep != nil {
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
}
updateExecutionPlanTotalNetworkFee(plan)
}
fundResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: sourceWalletRef,
Destination: fundingDest,
Amount: cloneProtoMoney(intentAmountProto),
Metadata: cloneMetadata(payment.Metadata),
PaymentRef: payment.PaymentRef,
IntentRef: strings.TrimSpace(intent.Ref),
OperationRef: strings.TrimSpace(cardStep.OperationRef),
})
if err != nil {
return err
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
fundStep.TransferRef = exec.ChainTransferRef
}
setExecutionStepStatus(fundStep, model.OperationStateWaiting)
updateExecutionPlanTotalNetworkFee(plan)
if feeRequired {
feeResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IntentRef: intent.Ref,
OperationRef: feeStep.OperationRef,
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: sourceWalletRef,
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
},
Amount: cloneProtoMoney(feeAmountProto),
Metadata: cloneMetadata(payment.Metadata),
PaymentRef: payment.PaymentRef,
})
if err != nil {
return err
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
feeStep.TransferRef = exec.FeeTransferRef
}
setExecutionStepStatus(feeStep, model.OperationStateWaiting)
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
}
payment.Execution = exec
return nil
}
func (s *Service) estimateTransferNetworkFee(ctx context.Context, client chainclient.Client, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) {
if client == nil {
return nil, merrors.InvalidArgument("chain gateway unavailable")
}
sourceWalletRef = strings.TrimSpace(sourceWalletRef)
if sourceWalletRef == "" {
return nil, merrors.InvalidArgument("source wallet ref is required")
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("amount is required")
}
resp, err := client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
SourceWalletRef: sourceWalletRef,
Destination: destination,
Amount: cloneProtoMoney(amount),
})
if err != nil {
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
if resp == nil {
s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
fee := resp.GetNetworkFee()
if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return cloneProtoMoney(fee), nil
}
func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) {
total := decimal.Zero
currency := ""
for _, fee := range fees {
if fee == nil {
continue
}
amount := strings.TrimSpace(fee.GetAmount())
feeCurrency := strings.TrimSpace(fee.GetCurrency())
if amount == "" || feeCurrency == "" {
return decimal.Zero, "", merrors.InvalidArgument("network fee is required")
}
value, err := decimalFromMoney(fee)
if err != nil {
return decimal.Zero, "", err
}
if currency == "" {
currency = feeCurrency
} else if !strings.EqualFold(currency, feeCurrency) {
return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch")
}
total = total.Add(value)
}
return total, currency, nil
}

View File

@@ -1,80 +0,0 @@
package orchestrator
import (
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan {
if payment == nil {
return nil
}
if payment.ExecutionPlan == nil {
payment.ExecutionPlan = &model.ExecutionPlan{}
}
return payment.ExecutionPlan
}
func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep {
if plan == nil {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
return nil
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if strings.EqualFold(step.Code, code) {
if step.Code == "" {
step.Code = code
}
return step
}
}
step := &model.ExecutionStep{Code: code}
plan.Steps = append(plan.Steps, step)
return step
}
func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) {
if plan == nil {
return
}
total := decimal.Zero
currency := ""
hasFee := false
for _, step := range plan.Steps {
if step == nil || step.NetworkFee == nil {
continue
}
fee := step.NetworkFee
if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
continue
}
if currency == "" {
currency = strings.TrimSpace(fee.GetCurrency())
} else if !strings.EqualFold(currency, fee.GetCurrency()) {
continue
}
value, err := decimalFromMoney(fee)
if err != nil {
continue
}
total = total.Add(value)
hasFee = true
}
if !hasFee || currency == "" {
plan.TotalNetworkFee = nil
return
}
plan.TotalNetworkFee = &paymenttypes.Money{
Currency: currency,
Amount: total.String(),
}
}

View File

@@ -1,29 +0,0 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
if len(s.deps.cardRoutes) == 0 {
s.logger.Warn("card routing not configured", zap.String("gateway", gateway))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured")
}
key := strings.ToLower(strings.TrimSpace(gateway))
if key == "" {
key = defaultCardGateway
}
route, ok := s.deps.cardRoutes[key]
if !ok {
s.logger.Warn("card routing missing for gateway", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
}
if strings.TrimSpace(route.FundingAddress) == "" {
s.logger.Warn("card routing missing funding address", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
}
return route, nil
}

View File

@@ -1,351 +0,0 @@
package orchestrator
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
)
func (s *Service) submitCardPayout(ctx context.Context, operationRef string, payment *model.Payment) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
if payment.Execution != nil && strings.TrimSpace(payment.Execution.CardPayoutRef) != "" {
return nil
}
intent := payment.Intent
card := intent.Destination.Card
if card == nil {
return merrors.InvalidArgument("card payout: card endpoint is required")
}
amount, err := cardPayoutAmount(payment)
if err != nil {
return err
}
amtDec, err := decimalFromMoney(amount)
if err != nil {
return err
}
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
payoutID := payment.PaymentRef
currency := strings.TrimSpace(amount.GetCurrency())
holder := strings.TrimSpace(card.Cardholder)
meta := cloneMetadata(payment.Metadata)
customer := intent.Customer
customerID := ""
customerFirstName := ""
customerMiddleName := ""
customerLastName := ""
customerIP := ""
customerZip := ""
customerCountry := ""
customerState := ""
customerCity := ""
customerAddress := ""
if customer != nil {
customerID = strings.TrimSpace(customer.ID)
customerFirstName = strings.TrimSpace(customer.FirstName)
customerMiddleName = strings.TrimSpace(customer.MiddleName)
customerLastName = strings.TrimSpace(customer.LastName)
customerIP = strings.TrimSpace(customer.IP)
customerZip = strings.TrimSpace(customer.Zip)
customerCountry = strings.TrimSpace(customer.Country)
customerState = strings.TrimSpace(customer.State)
customerCity = strings.TrimSpace(customer.City)
customerAddress = strings.TrimSpace(customer.Address)
}
if customerFirstName == "" {
customerFirstName = strings.TrimSpace(card.Cardholder)
}
if customerLastName == "" {
customerLastName = strings.TrimSpace(card.CardholderSurname)
}
if customerID == "" {
return merrors.InvalidArgument("card payout: customer id is required")
}
if customerFirstName == "" {
return merrors.InvalidArgument("card payout: customer first name is required")
}
if customerLastName == "" {
return merrors.InvalidArgument("card payout: customer last name is required")
}
if customerIP == "" {
return merrors.InvalidArgument("card payout: customer ip is required")
}
var (
state *mntxv1.CardPayoutState
)
if token := strings.TrimSpace(card.Token); token != "" {
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
IdempotencyKey: payment.IdempotencyKey,
CustomerId: customerID,
CustomerFirstName: customerFirstName,
CustomerMiddleName: customerMiddleName,
CustomerLastName: customerLastName,
CustomerIp: customerIP,
CustomerZip: customerZip,
CustomerCountry: customerCountry,
CustomerState: customerState,
CustomerCity: customerCity,
CustomerAddress: customerAddress,
AmountMinor: minor,
Currency: currency,
CardToken: token,
CardHolder: holder,
MaskedPan: strings.TrimSpace(card.MaskedPan),
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
if err != nil {
s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
req := &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
IdempotencyKey: payment.IdempotencyKey,
CustomerId: customerID,
CustomerFirstName: customerFirstName,
CustomerMiddleName: customerMiddleName,
CustomerLastName: customerLastName,
CustomerIp: customerIP,
CustomerZip: customerZip,
CustomerCountry: customerCountry,
CustomerState: customerState,
CustomerCity: customerCity,
CustomerAddress: customerAddress,
AmountMinor: minor,
Currency: currency,
CardPan: pan,
CardExpYear: card.ExpYear,
CardExpMonth: card.ExpMonth,
CardHolder: holder,
Metadata: meta,
IntentRef: payment.Intent.Ref,
OperationRef: operationRef,
}
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
if err != nil {
s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else {
return merrors.InvalidArgument("card payout: either token or pan must be provided")
}
if state == nil {
return merrors.Internal("card payout: missing payout state")
}
recordCardPayoutState(payment, state)
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
if exec.CardPayoutRef == "" {
exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
}
payment.Execution = exec
plan := ensureExecutionPlan(payment)
if plan != nil {
step := ensureExecutionStep(plan, stepCodeCardPayout)
setExecutionStepRole(step, executionStepRoleConsumer)
step.Description = "Submit card payout"
step.Amount = cloneMoney(amount)
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
step.DestinationRef = masked
}
if exec.CardPayoutRef != "" {
step.TransferRef = exec.CardPayoutRef
}
setExecutionStepStatus(step, model.OperationStateWaiting)
updateExecutionPlanTotalNetworkFee(plan)
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_id", exec.CardPayoutRef), zap.String("operation_ref", state.OperationRef))
return nil
}
func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) {
if payment == nil || state == nil {
return
}
if payment.CardPayout == nil {
payment.CardPayout = &model.CardPayout{}
}
payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId())
payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId())
payment.CardPayout.Status = state.GetStatus().String()
payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage())
payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode())
if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country)
}
if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan)
}
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
}
func updateCardPayoutPlanSteps(payment *model.Payment, payout *mntxv1.CardPayoutState) bool {
if payment == nil || payout == nil || payment.PaymentPlan == nil {
return false
}
plan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
if plan == nil {
return false
}
payoutID := strings.TrimSpace(payout.GetPayoutId())
if payoutID == "" {
return false
}
updated := false
for idx, planStep := range payment.PaymentPlan.Steps {
if planStep == nil {
continue
}
if planStep.Rail != model.RailCardPayout {
continue
}
if planStep.Action != model.RailOperationSend && planStep.Action != model.RailOperationObserveConfirm {
continue
}
if idx >= len(plan.Steps) {
continue
}
execStep := plan.Steps[idx]
if execStep == nil {
execStep = &model.ExecutionStep{
Code: planStepID(planStep, idx),
Description: describePlanStep(planStep),
OperationRef: uuid.New().String(),
State: model.OperationStateCreated,
}
plan.Steps[idx] = execStep
}
if execStep.TransferRef == "" {
execStep.TransferRef = payoutID
}
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
setExecutionStepStatus(execStep, model.OperationStateCreated)
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
setExecutionStepStatus(execStep, model.OperationStateWaiting)
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
setExecutionStepStatus(execStep, model.OperationStateSuccess)
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
setExecutionStepStatus(execStep, model.OperationStateFailed)
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
setExecutionStepStatus(execStep, model.OperationStateCancelled)
default:
setExecutionStepStatus(execStep, model.OperationStatePlanned)
}
updated = true
}
return updated
}
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
if payment == nil || payout == nil {
return
}
recordCardPayoutState(payment, payout)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
}
updated := updateCardPayoutPlanSteps(payment, payout)
plan := ensureExecutionPlan(payment)
if plan != nil && !updated {
step := findExecutionStepByTransferRef(plan, strings.TrimSpace(payout.GetPayoutId()))
if step == nil {
step = ensureExecutionStep(plan, stepCodeCardPayout)
setExecutionStepRole(step, executionStepRoleConsumer)
if step.TransferRef == "" {
step.TransferRef = payment.Execution.CardPayoutRef
}
}
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
setExecutionStepStatus(step, model.OperationStatePlanned)
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
setExecutionStepStatus(step, model.OperationStateWaiting)
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
setExecutionStepStatus(step, model.OperationStateSuccess)
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
setExecutionStepStatus(step, model.OperationStateFailed)
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
setExecutionStepStatus(step, model.OperationStateCancelled)
default:
setExecutionStepStatus(step, model.OperationStatePlanned)
}
}
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = "payout cancelled"
default:
// CREATED / WAITING — keep as is
}
}
func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) {
if payment == nil {
return nil, merrors.InvalidArgument("payment is required")
}
amount := cloneMoney(payment.Intent.Amount)
if payment.LastQuote != nil {
settlement := payment.LastQuote.ExpectedSettlementAmount
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
amount = cloneMoney(settlement)
}
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("card payout: amount is required")
}
return amount, nil
}

View File

@@ -1,163 +0,0 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
const (
executionStepMetadataRole = "role"
executionStepMetadataStatus = "status"
executionStepRoleSource = "source"
executionStepRoleConsumer = "consumer"
)
func setExecutionStepRole(step *model.ExecutionStep, role string) {
role = strings.ToLower(strings.TrimSpace(role))
setExecutionStepMetadata(step, executionStepMetadataRole, role)
}
func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) {
step.State = state
setExecutionStepMetadata(step, executionStepMetadataStatus, string(state))
}
func executionStepRole(step *model.ExecutionStep) string {
if step == nil {
return ""
}
if role := strings.TrimSpace(step.Metadata[executionStepMetadataRole]); role != "" {
return strings.ToLower(role)
}
if strings.EqualFold(step.Code, stepCodeCardPayout) {
return executionStepRoleConsumer
}
return executionStepRoleSource
}
func isSourceExecutionStep(step *model.ExecutionStep) bool {
return executionStepRole(step) == executionStepRoleSource
}
func sourceStepsConfirmed(plan *model.ExecutionPlan) bool {
if plan == nil || len(plan.Steps) == 0 {
return false
}
hasSource := false
for _, step := range plan.Steps {
if step == nil || !isSourceExecutionStep(step) {
continue
}
if step.State == model.OperationStateSkipped {
continue
}
hasSource = true
if step.State != model.OperationStateSuccess {
return false
}
}
return hasSource
}
func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep {
if plan == nil {
return nil
}
transferRef = strings.TrimSpace(transferRef)
if transferRef == "" {
return nil
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) {
return step
}
}
return nil
}
func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep {
if plan == nil || event == nil || event.GetTransfer() == nil {
return nil
}
transfer := event.GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return nil
}
if status := executionStepStatusFromTransferStatus(transfer.GetStatus()); status != "" {
var updated *model.ExecutionStep
for _, step := range plan.Steps {
if step == nil {
continue
}
if !strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) {
continue
}
if step.TransferRef == "" {
step.TransferRef = transferRef
}
setExecutionStepStatus(step, status)
if updated == nil {
updated = step
}
}
return updated
}
return nil
}
func executionStepStatusFromTransferStatus(status chainv1.TransferStatus) model.OperationState {
switch status {
case chainv1.TransferStatus_TRANSFER_CREATED:
return model.OperationStatePlanned
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return model.OperationStateProcessing
case chainv1.TransferStatus_TRANSFER_WAITING:
return model.OperationStateWaiting
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return model.OperationStateSuccess
case chainv1.TransferStatus_TRANSFER_FAILED:
return model.OperationStateFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return model.OperationStateCancelled
default:
return model.OperationStatePlanned
}
}
func setExecutionStepMetadata(step *model.ExecutionStep, key, value string) {
if step == nil {
return
}
key = strings.TrimSpace(key)
if key == "" {
return
}
value = strings.TrimSpace(value)
if value == "" {
if step.Metadata != nil {
delete(step.Metadata, key)
if len(step.Metadata) == 0 {
step.Metadata = nil
}
}
return
}
if step.Metadata == nil {
step.Metadata = map[string]string{}
}
step.Metadata[key] = value
}

View File

@@ -1,295 +0,0 @@
package orchestrator
import (
"context"
"fmt"
"strings"
paymodel "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
cons "github.com/tech/sendico/pkg/messaging/consumer"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/payments/rail"
"go.uber.org/zap"
)
func (s *Service) startGatewayConsumers() {
if s == nil || s.gatewayBroker == nil {
s.logger.Warn("Missing broker. Gateway feedback consumer has NOT started")
return
}
s.logger.Info("Gateway feedback consumer started")
processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution)
s.consumeGatewayProcessor(processor)
}
func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) {
consumer, err := cons.NewConsumer(s.logger, s.gatewayBroker, processor.GetSubject())
if err != nil {
s.logger.Warn("Failed to create payment gateway consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
return
}
s.gatewayConsumers = append(s.gatewayConsumers, consumer)
go func() {
if err := consumer.ConsumeMessages(processor.Process); err != nil {
s.logger.Warn("Payment gateway consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
}
}()
}
func executionPlanSucceeded(plan *paymodel.ExecutionPlan) bool {
for _, s := range plan.Steps {
if !s.IsTerminal() {
return false
}
if s.State != paymodel.OperationStateSuccess {
return false
}
}
return true
}
func executionPlanFailed(plan *paymodel.ExecutionPlan) bool {
hasFailed := false
for _, s := range plan.Steps {
if !s.IsTerminal() {
return false
}
if s.State == paymodel.OperationStateFailed {
hasFailed = true
}
}
return hasFailed
}
func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error {
if exec == nil {
return merrors.InvalidArgument("payment gateway execution is nil", "execution")
}
paymentRef := strings.TrimSpace(exec.PaymentRef)
if paymentRef == "" {
return merrors.InvalidArgument("payment_ref is required", "payment_ref")
}
store := s.storage.Payments()
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
s.logger.Warn("Failed to fetch payment from database", zap.Error(err))
return err
}
// --- metadata
if payment.Metadata == nil {
payment.Metadata = map[string]string{}
}
payment.Metadata["gateway_operation_result"] = string(exec.Status)
payment.Metadata["gateway_operation_ref"] = exec.OperationRef
payment.Metadata["gateway_request_idempotency"] = exec.IdempotencyKey
// --- update exactly ONE step
if payment.State, err = updateExecutionStepsFromGatewayExecution(s.logger, payment, exec); err != nil {
s.logger.Warn("No execution step matched gateway result",
zap.String("payment_ref", paymentRef),
zap.String("operation_ref", exec.OperationRef),
zap.String("idempotency", exec.IdempotencyKey),
)
}
if err := store.Update(ctx, payment); err != nil {
return err
}
// reload unified state
payment, err = store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return err
}
// --- if plan can continue — continue
if payment.ExecutionPlan != nil && !executionPlanComplete(payment.ExecutionPlan) {
return s.resumePaymentPlan(ctx, store, payment)
}
// --- plan is terminal: decide payment fate by aggregation
if payment.ExecutionPlan != nil && executionPlanComplete(payment.ExecutionPlan) {
switch {
case executionPlanSucceeded(payment.ExecutionPlan):
payment.State = paymodel.PaymentStateSettled
case executionPlanFailed(payment.ExecutionPlan):
payment.State = paymodel.PaymentStateFailed
payment.FailureReason = "execution_plan_failed"
}
return store.Update(ctx, payment)
}
return nil
}
func updateExecutionStepsFromGatewayExecution(
logger mlogger.Logger,
payment *paymodel.Payment,
exec *model.PaymentGatewayExecution,
) (paymodel.PaymentState, error) {
log := logger.With(
zap.String("payment_ref", payment.PaymentRef),
zap.String("operation_ref", strings.TrimSpace(exec.OperationRef)),
zap.String("gateway_status", string(exec.Status)),
)
log.Debug("gateway execution received")
if payment == nil || payment.PaymentPlan == nil || exec == nil {
log.Warn("invalid input: payment/plan/exec is nil")
return paymodel.PaymentStateSubmitted,
merrors.DataConflict("payment is missing plan or execution step")
}
operationRef := strings.TrimSpace(exec.OperationRef)
if operationRef == "" {
log.Warn("empty operation_ref from gateway")
return paymodel.PaymentStateSubmitted,
merrors.InvalidArgument("no operation reference provided")
}
execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
if execPlan == nil {
log.Warn("Execution plan missing")
return paymodel.PaymentStateSubmitted, merrors.InvalidArgument("execution plan missing")
}
status := executionStepStatusFromGatewayStatus(exec.Status)
if status == "" {
log.Warn("Unknown gateway status")
return paymodel.PaymentStateSubmitted,
merrors.DataConflict(fmt.Sprintf("unknown gateway status: %s", exec.Status))
}
var matched bool
for idx, execStep := range execPlan.Steps {
if execStep == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(execStep.OperationRef), operationRef) {
log.Debug("Execution step matched",
zap.Int("step_index", idx),
zap.String("step_code", execStep.Code),
zap.String("prev_state", string(execStep.State)),
)
if execStep.TransferRef == "" && exec.TransferRef != "" {
execStep.TransferRef = strings.TrimSpace(exec.TransferRef)
log.Debug("Transfer_ref attached to step", zap.String("transfer_ref", execStep.TransferRef))
}
setExecutionStepStatus(execStep, status)
if exec.Error != "" && execStep.Error == "" {
execStep.Error = strings.TrimSpace(exec.Error)
}
log.Debug("Execution step state updated",
zap.Int("step_index", idx),
zap.String("step_code", execStep.Code),
zap.String("new_state", string(execStep.State)),
)
matched = true
break
}
}
if !matched {
log.Warn("No execution step found for operation_ref")
return paymodel.PaymentStateSubmitted,
merrors.InvalidArgument(
fmt.Sprintf("execution step not found for operation reference: %s", operationRef),
)
}
// -------- GLOBAL REDUCTION --------
var (
hasSuccess bool
allDone = true
)
for idx, step := range execPlan.Steps {
if step == nil {
continue
}
log.Debug("Evaluating step for payment state",
zap.Int("step_index", idx),
zap.String("step_code", step.Code),
zap.String("step_state", string(step.State)),
)
switch step.State {
case paymodel.OperationStateFailed:
payment.FailureReason = step.Error
log.Info("Payment marked as FAILED due to step failure",
zap.String("failed_step_code", step.Code),
zap.String("error", step.Error),
)
return paymodel.PaymentStateFailed, nil
case paymodel.OperationStateSuccess:
hasSuccess = true
case paymodel.OperationStateSkipped:
// ok
default:
allDone = false
}
}
if hasSuccess && allDone {
log.Info("Payment marked as SUCCESS (all steps completed)")
return paymodel.PaymentStateSuccess, nil
}
log.Info("Payment still PROCESSING (steps not finished)")
return paymodel.PaymentStateSubmitted, nil
}
func executionStepStatusFromGatewayStatus(status rail.OperationResult) paymodel.OperationState {
switch status {
case rail.OperationResultSuccess:
return paymodel.OperationStateSuccess
case rail.OperationResultFailed:
return paymodel.OperationStateFailed
case rail.OperationResultCancelled:
return paymodel.OperationStateCancelled
default:
return paymodel.OperationStateFailed
}
}
func (s *Service) Shutdown() {
if s == nil {
return
}
for _, consumer := range s.gatewayConsumers {
if consumer != nil {
consumer.Close()
}
}
}

View File

@@ -1,922 +0,0 @@
package orchestrator
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"strings"
"time"
"github.com/google/uuid"
"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
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
IdempotencyKey: qc.idempotencyKey,
Hash: qc.hash,
Intent: intentFromProto(qc.intent),
Quote: quoteSnapshotToModel(quote),
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.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, expires, err := h.buildQuotes(ctx, req.GetMeta(), 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, 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,
baseKey string,
intents []*orchestratorv1.PaymentIntent,
preview bool,
) ([]*orchestratorv1.PaymentQuote, []time.Time, error) {
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
expires := make([]time.Time, 0, len(intents))
for i, intent := range intents {
req := &orchestratorv1.QuotePaymentRequest{
Meta: meta,
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
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, err
}
quotes = append(quotes, q)
expires = append(expires, exp)
}
return quotes, 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,
expiresAt time.Time,
) (*model.PaymentQuoteRecord, error) {
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
IdempotencyKey: qc.idempotencyKey,
Hash: qc.hash,
Intents: intentsFromProto(intents),
Quotes: quoteSnapshotsFromProto(quotes),
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
}
type initiatePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
_, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
if quoteRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required"))
}
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := record.Intents
quotes := record.Quotes
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
intents = []model.PaymentIntent{record.Intent}
}
if len(quotes) == 0 && record.Quote != nil {
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
}
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments := make([]*orchestratorv1.Payment, 0, len(intents))
for i := range intents {
intentProto := protoIntentFromModel(intents[i])
if err := requireNonNilIntent(intentProto); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteProto := modelQuoteToProto(quotes[i])
if quoteProto == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteProto.QuoteRef = quoteRef
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil {
payments = append(payments, toProtoPayment(existing))
continue
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments = append(payments, toProtoPayment(entity))
}
h.logger.Info(
"Payments initiated",
mzap.ObjRef("org_ref", orgRef),
zap.String("quote_ref", quoteRef),
zap.String("idempotency_key", idempotencyKey),
zap.Int("payment_count", len(payments)),
)
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
}
type initiatePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
quoteRef := strings.TrimSpace(req.GetQuoteRef())
hasIntent := intent != nil
hasQuote := quoteRef != ""
switch {
case !hasIntent && !hasQuote:
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required"))
case hasIntent && hasQuote:
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive"))
}
if hasIntent {
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Debug(
"Initiate payment request accepted",
mzap.ObjRef("org_ref", orgID),
zap.String("idempotency_key", idempotencyKey),
zap.String("quote_ref", quoteRef),
zap.Bool("has_intent", hasIntent),
)
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug(
"idempotent payment request reused",
zap.String("payment_ref", existing.PaymentRef),
mzap.ObjRef("org_ref", orgID),
zap.String("idempotency_key", idempotencyKey),
zap.String("quote_ref", quoteRef),
)
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
OrgRef: orgRef,
OrgID: orgID,
Meta: req.GetMeta(),
Intent: intent,
QuoteRef: quoteRef,
IdempotencyKey: req.GetIdempotencyKey(),
})
if err != nil {
if qerr, ok := err.(quoteResolutionError); ok {
switch qerr.code {
case "quote_not_found":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_expired":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_intent_mismatch":
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
default:
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
}
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
if err := requireNonNilIntent(resolvedIntent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Debug(
"Payment quote resolved",
mzap.ObjRef("org_ref", orgID),
zap.String("quote_ref", quoteRef),
zap.Bool("quote_ref_used", quoteRef != ""),
)
entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info(
"Payment initiated",
zap.String("payment_ref", entity.PaymentRef),
mzap.ObjRef("org_ref", orgID),
zap.String("kind", resolvedIntent.GetKind().String()),
zap.String("quote_ref", quoteSnapshot.GetQuoteRef()),
zap.String("idempotency_key", idempotencyKey),
)
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
type cancelPaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
type initiateConversionCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), mzap.ObjRef("org_ref", orgID))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Ref: uuid.New().String(),
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
SettlementCurrency: strings.TrimSpace(amount.GetCurrency()),
}
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), mzap.ObjRef("org_ref", orgID))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}

View File

@@ -1,318 +0,0 @@
package orchestrator
import (
"context"
"strings"
"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"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentEventHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
submitCardPayout func(ctx context.Context, operationRef string, payment *model.Payment) error
resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error
releaseHold func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error
}
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger, submitCardPayout func(ctx context.Context, operationRef string, payment *model.Payment) error, resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error, releaseHold func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error) *paymentEventHandler {
return &paymentEventHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
submitCardPayout: submitCardPayout,
resumePlan: resumePlan,
releaseHold: releaseHold,
}
}
func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
if payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 {
if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != len(payment.PaymentPlan.Steps) {
ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
}
updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent())
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = transferRef
}
reason := transferFailureReason(req.GetEvent())
switch transfer.GetStatus() {
case chainv1.TransferStatus_TRANSFER_FAILED:
payment.State = model.PaymentStateFailed
payment.FailureCode = model.PaymentFailureCodeChain
payment.FailureReason = reason
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
case chainv1.TransferStatus_TRANSFER_CANCELLED:
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = reason
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
case chainv1.TransferStatus_TRANSFER_SUCCESS:
if h.resumePlan != nil {
if err := h.resumePlan(ctx, store, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
}
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
case chainv1.TransferStatus_TRANSFER_WAITING:
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
default:
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
}
updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent())
if payment.Intent.Destination.Type == model.EndpointTypeCard {
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = transferRef
}
reason := transferFailureReason(req.GetEvent())
switch transfer.GetStatus() {
case chainv1.TransferStatus_TRANSFER_FAILED:
payment.State = model.PaymentStateFailed
payment.FailureCode = model.PaymentFailureCodeChain
payment.FailureReason = reason
case chainv1.TransferStatus_TRANSFER_CANCELLED:
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = reason
case chainv1.TransferStatus_TRANSFER_SUCCESS:
if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled {
if cardPayoutDependenciesConfirmed(payment.PaymentPlan, payment.ExecutionPlan) {
if payment.Execution.CardPayoutRef == "" {
payment.State = model.PaymentStateFundsReserved
if h.submitCardPayout == nil {
h.logger.Warn("card payout execution skipped", zap.String("payment_ref", payment.PaymentRef))
} else if err := h.submitCardPayout(ctx, transfer.GetOperationRef(), payment); err != nil {
payment.State = model.PaymentStateFailed
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(err.Error())
h.logger.Warn("card payout execution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
}
}
}
}
case chainv1.TransferStatus_TRANSFER_WAITING:
default:
// keep current state
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef))
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
}
func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required"))
}
payout := req.GetEvent().GetPayout()
paymentRef := strings.TrimSpace(payout.GetPayoutId())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyCardPayoutUpdate(payment, payout)
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
h.logger.Info("card payout success received",
zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_ref", payout.GetPayoutId()),
zap.String("payment_state_before", string(payment.State)),
zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0),
zap.Bool("resume_plan_present", h.resumePlan != nil),
)
if h.resumePlan != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 {
if err := h.resumePlan(ctx, store, payment); err != nil {
h.logger.Error("resumePlan failed after payout success",
zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_ref", payout.GetPayoutId()),
zap.Error(err),
)
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("resumePlan executed after payout success",
zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_ref", payout.GetPayoutId()),
)
} else {
h.logger.Warn("payout success but plan cannot be resumed",
zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_ref", payout.GetPayoutId()),
zap.Bool("resume_plan_present", h.resumePlan != nil),
zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0),
)
}
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
h.logger.Warn("card payout failed",
zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_ref", payout.GetPayoutId()),
zap.String("provider_message", payout.GetProviderMessage()),
)
payment.State = model.PaymentStateFailed
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
if h.releaseHold != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 {
h.logger.Info("releasing hold after payout failure",
zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_ref", payout.GetPayoutId()),
)
if err := h.releaseHold(ctx, store, payment); err != nil {
h.logger.Error("releaseHold failed after payout failure",
zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_ref", payout.GetPayoutId()),
zap.Error(err),
)
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
} else {
h.logger.Warn("payout failed but hold cannot be released",
zap.String("payment_ref", payment.PaymentRef),
zap.String("payout_ref", payout.GetPayoutId()),
zap.Bool("release_hold_present", h.releaseHold != nil),
zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0),
)
}
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{
Payment: toProtoPayment(payment),
})
}
func transferFailureReason(event *chainv1.TransferStatusChangedEvent) string {
if event == nil || event.GetTransfer() == nil {
return ""
}
reason := strings.TrimSpace(event.GetReason())
if reason != "" {
return reason
}
return strings.TrimSpace(event.GetTransfer().GetFailureReason())
}

View File

@@ -1,80 +0,0 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/storage"
"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"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentQueryHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler {
return &paymentQueryHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef))
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor()))
return gsresponse.Success(resp)
}

View File

@@ -1,237 +0,0 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentExecutor struct {
deps *serviceDependencies
logger mlogger.Logger
svc *Service
}
func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor {
return &paymentExecutor{deps: deps, logger: logger, svc: svc}
}
func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if store == nil {
return errStorageUnavailable
}
if p.svc == nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "service_unavailable", errStorageUnavailable)
}
if p.svc.storage == nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
}
routeStore := p.svc.storage.Routes()
if routeStore == nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
}
planTemplates := p.svc.storage.PlanTemplates()
if planTemplates == nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "plan_templates_store_unavailable", errStorageUnavailable)
}
builder := p.svc.deps.planBuilder
if builder == nil {
builder = newDefaultPlanBuilder(p.logger)
}
plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry)
if err != nil {
p.logPlanBuilderFailure(payment, err)
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
if plan == nil || len(plan.Steps) == 0 {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_empty", merrors.InvalidArgument("payment plan is required"))
}
payment.PaymentPlan = plan
return p.executePaymentPlan(ctx, store, payment, quote)
}
func (p *paymentExecutor) logPlanBuilderFailure(payment *model.Payment, err error) {
if p == nil || payment == nil {
return
}
intent := payment.Intent
sourceRail, sourceNetwork, sourceErr := railFromEndpoint(intent.Source, intent.Attributes, true)
destRail, destNetwork, destErr := railFromEndpoint(intent.Destination, intent.Attributes, false)
fields := []zap.Field{
zap.Error(err),
zap.String("payment_ref", payment.PaymentRef),
zap.String("org_ref", payment.OrganizationRef.Hex()),
zap.String("idempotency_key", payment.IdempotencyKey),
zap.String("source_rail", string(sourceRail)),
zap.String("destination_rail", string(destRail)),
zap.String("source_network", sourceNetwork),
zap.String("destination_network", destNetwork),
zap.String("source_endpoint_type", string(intent.Source.Type)),
zap.String("destination_endpoint_type", string(intent.Destination.Type)),
}
missing := make([]string, 0, 2)
if sourceErr != nil || sourceRail == model.RailUnspecified {
missing = append(missing, "source")
if sourceErr != nil {
fields = append(fields, zap.String("source_rail_error", sourceErr.Error()))
}
}
if destErr != nil || destRail == model.RailUnspecified {
missing = append(missing, "destination")
if destErr != nil {
fields = append(fields, zap.String("destination_rail_error", destErr.Error()))
}
}
if len(missing) > 0 {
fields = append(fields, zap.String("missing_rails", strings.Join(missing, ",")))
p.logger.Warn("Payment rail resolution failed", fields...)
return
}
routeNetwork, routeErr := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
if routeErr != nil {
fields = append(fields, zap.String("route_network_error", routeErr.Error()))
} else if routeNetwork != "" {
fields = append(fields, zap.String("route_network", routeNetwork))
}
p.logger.Warn("Payment route missing for rails", fields...)
}
func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error {
intent := payment.Intent
source := intent.Source.Ledger
destination := intent.Destination.Ledger
if source == nil || destination == nil {
return merrors.InvalidArgument("ledger: fx conversion requires ledger source and destination")
}
fq := quote.GetFxQuote()
if fq == nil {
return merrors.InvalidArgument("ledger: fx quote missing")
}
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.FX != nil {
fxSide = fxSideToProto(intent.FX.Side)
}
fromMoney, toMoney := resolveTradeAmounts(protoMoney(intent.Amount), fq, fxSide)
if fromMoney == nil {
fromMoney = protoMoney(intent.Amount)
}
if toMoney == nil {
toMoney = cloneProtoMoney(quote.GetExpectedSettlementAmount())
}
rate := ""
if fq.GetPrice() != nil {
rate = fq.GetPrice().GetValue()
}
req := &ledgerv1.FXRequest{
IdempotencyKey: payment.IdempotencyKey,
OrganizationRef: payment.OrganizationRef.Hex(),
FromLedgerAccountRef: strings.TrimSpace(source.LedgerAccountRef),
ToLedgerAccountRef: strings.TrimSpace(destination.LedgerAccountRef),
FromMoney: fromMoney,
ToMoney: toMoney,
Rate: rate,
Description: description,
Charges: charges,
Metadata: metadata,
}
resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req)
if err != nil {
return err
}
exec.FXEntryRef = strings.TrimSpace(resp.GetJournalEntryRef())
payment.Execution = exec
return nil
}
func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if store == nil {
return errStorageUnavailable
}
return store.Update(ctx, payment)
}
func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error {
payment.State = model.PaymentStateFailed
payment.FailureCode = code
payment.FailureReason = strings.TrimSpace(reason)
if store != nil {
if updateErr := store.Update(ctx, payment); updateErr != nil {
p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
}
}
if err != nil {
return err
}
return merrors.Internal(reason)
}
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func applyTransferStatus(event *chainv1.TransferStatusChangedEvent, payment *model.Payment) {
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if event == nil || event.GetTransfer() == nil {
return
}
transfer := event.GetTransfer()
payment.Execution.ChainTransferRef = strings.TrimSpace(transfer.GetTransferRef())
reason := strings.TrimSpace(event.GetReason())
if reason == "" {
reason = strings.TrimSpace(transfer.GetFailureReason())
}
switch transfer.GetStatus() {
case chainv1.TransferStatus_TRANSFER_SUCCESS:
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case chainv1.TransferStatus_TRANSFER_FAILED:
payment.State = model.PaymentStateFailed
payment.FailureCode = model.PaymentFailureCodeChain
payment.FailureReason = reason
case chainv1.TransferStatus_TRANSFER_CANCELLED:
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = reason
case chainv1.TransferStatus_TRANSFER_WAITING:
payment.State = model.PaymentStateSubmitted
case chainv1.TransferStatus_TRANSFER_CREATED,
chainv1.TransferStatus_TRANSFER_PROCESSING:
// do nothing, retain previous state
default:
// retain previous state
}
}

View File

@@ -1,123 +0,0 @@
package orchestrator
import (
"errors"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Liveness string
const (
StepFinal Liveness = "final"
StepRunnable Liveness = "runnable"
StepBlocked Liveness = "blocked"
StepDead Liveness = "dead"
)
func buildPaymentStepIndex(plan *model.PaymentPlan) map[string]*model.PaymentStep {
idx := make(map[string]*model.PaymentStep, len(plan.Steps))
for _, s := range plan.Steps {
idx[s.StepID] = s
}
return idx
}
func buildExecutionStepIndex(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
index := make(map[string]*model.ExecutionStep, len(plan.Steps))
for _, s := range plan.Steps {
if s == nil {
continue
}
index[s.Code] = s
}
return index
}
func stepLiveness(
logger mlogger.Logger,
step *model.ExecutionStep,
pStepIdx map[string]*model.PaymentStep,
eStepIdx map[string]*model.ExecutionStep,
) Liveness {
if step.IsTerminal() {
return StepFinal
}
pStep, ok := pStepIdx[step.Code]
if !ok {
logger.Error("step missing in payment plan",
zap.String("step_id", step.Code),
)
return StepDead
}
for _, depID := range pStep.DependsOn {
dep := eStepIdx[depID]
if dep == nil {
logger.Warn("dependency missing in execution plan",
zap.String("step_id", step.Code),
zap.String("dep_id", depID),
)
continue
}
switch dep.State {
case model.OperationStateFailed:
return StepDead
}
}
allSuccess := true
for _, depID := range pStep.DependsOn {
dep := eStepIdx[depID]
if dep == nil || dep.State != model.OperationStateSuccess {
allSuccess = false
break
}
}
if allSuccess {
return StepRunnable
}
return StepBlocked
}
func analyzeExecutionPlan(
logger mlogger.Logger,
payment *model.Payment,
) (bool, bool, error) {
if payment == nil || payment.ExecutionPlan == nil {
return true, false, nil
}
eIdx := buildExecutionStepIndex(payment.ExecutionPlan)
pIdx := buildPaymentStepIndex(payment.PaymentPlan)
hasRunnable := false
hasFailed := false
var rootErr error
for _, s := range payment.ExecutionPlan.Steps {
live := stepLiveness(logger, s, pIdx, eIdx)
if live == StepRunnable {
hasRunnable = true
}
if s.State == model.OperationStateFailed {
hasFailed = true
if rootErr == nil && s.Error != "" {
rootErr = errors.New(s.Error)
}
}
}
done := !hasRunnable
return done, hasFailed, rootErr
}

View File

@@ -1,196 +0,0 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model/account_role"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *model.Payment, operationRef string, amount *moneyv1.Money, fromRole, toRole *account_role.AccountRole) (string, error) {
if payment == nil {
return "", merrors.InvalidArgument("payment is required")
}
if !p.deps.mntx.available() {
return "", merrors.Internal("card_gateway_unavailable")
}
intent := payment.Intent
card := intent.Destination.Card
if card == nil {
return "", merrors.InvalidArgument("card payout: card endpoint is required")
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return "", merrors.InvalidArgument("card payout: amount is required")
}
amtDec, err := decimalFromMoney(amount)
if err != nil {
return "", err
}
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
payoutID := payment.PaymentRef
currency := strings.TrimSpace(amount.GetCurrency())
holder := strings.TrimSpace(card.Cardholder)
meta := cloneMetadata(payment.Metadata)
if strings.TrimSpace(string(mergeAccountRole(fromRole))) != "" {
if meta == nil {
meta = map[string]string{}
}
meta[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(mergeAccountRole(fromRole)))
}
if strings.TrimSpace(string(mergeAccountRole(toRole))) != "" {
if meta == nil {
meta = map[string]string{}
}
meta[account_role.MetadataKeyToRole] = strings.TrimSpace(string(mergeAccountRole(toRole)))
}
customer := intent.Customer
customerID := ""
customerFirstName := ""
customerMiddleName := ""
customerLastName := ""
customerIP := ""
customerZip := ""
customerCountry := ""
customerState := ""
customerCity := ""
customerAddress := ""
if customer != nil {
customerID = strings.TrimSpace(customer.ID)
customerFirstName = strings.TrimSpace(customer.FirstName)
customerMiddleName = strings.TrimSpace(customer.MiddleName)
customerLastName = strings.TrimSpace(customer.LastName)
customerIP = strings.TrimSpace(customer.IP)
customerZip = strings.TrimSpace(customer.Zip)
customerCountry = strings.TrimSpace(customer.Country)
customerState = strings.TrimSpace(customer.State)
customerCity = strings.TrimSpace(customer.City)
customerAddress = strings.TrimSpace(customer.Address)
}
if customerFirstName == "" {
customerFirstName = strings.TrimSpace(card.Cardholder)
}
if customerLastName == "" {
customerLastName = strings.TrimSpace(card.CardholderSurname)
}
if customerID == "" {
return "", merrors.InvalidArgument("card payout: customer id is required")
}
if customerFirstName == "" {
return "", merrors.InvalidArgument("card payout: customer first name is required")
}
if customerLastName == "" {
return "", merrors.InvalidArgument("card payout: customer last name is required")
}
if customerIP == "" {
return "", merrors.InvalidArgument("card payout: customer ip is required")
}
var state *mntxv1.CardPayoutState
if token := strings.TrimSpace(card.Token); token != "" {
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
CustomerId: customerID,
CustomerFirstName: customerFirstName,
CustomerMiddleName: customerMiddleName,
CustomerLastName: customerLastName,
CustomerIp: customerIP,
CustomerZip: customerZip,
CustomerCountry: customerCountry,
CustomerState: customerState,
CustomerCity: customerCity,
CustomerAddress: customerAddress,
AmountMinor: minor,
Currency: currency,
CardToken: token,
CardHolder: holder,
MaskedPan: strings.TrimSpace(card.MaskedPan),
Metadata: meta,
OperationRef: operationRef,
IntentRef: payment.Intent.Ref,
IdempotencyKey: payment.IdempotencyKey,
}
resp, err := p.deps.mntx.client.CreateCardTokenPayout(ctx, req)
if err != nil {
return "", err
}
state = resp.GetPayout()
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
req := &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
CustomerId: customerID,
CustomerFirstName: customerFirstName,
CustomerMiddleName: customerMiddleName,
CustomerLastName: customerLastName,
CustomerIp: customerIP,
CustomerZip: customerZip,
CustomerCountry: customerCountry,
CustomerState: customerState,
CustomerCity: customerCity,
CustomerAddress: customerAddress,
AmountMinor: minor,
Currency: currency,
CardPan: pan,
CardExpYear: card.ExpYear,
CardExpMonth: card.ExpMonth,
CardHolder: holder,
Metadata: meta,
OperationRef: operationRef,
IntentRef: payment.Intent.Ref,
IdempotencyKey: payment.IdempotencyKey,
}
resp, err := p.deps.mntx.client.CreateCardPayout(ctx, req)
if err != nil {
return "", err
}
state = resp.GetPayout()
} else {
return "", merrors.InvalidArgument("card payout: either token or pan must be provided")
}
if state == nil {
return "", merrors.Internal("card payout: missing payout state")
}
recordCardPayoutState(payment, state)
exec := ensureExecutionRefs(payment)
if exec.CardPayoutRef == "" {
exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
}
return exec.CardPayoutRef, nil
}
func mergeAccountRole(role *account_role.AccountRole) account_role.AccountRole {
if role == nil {
return ""
}
return account_role.AccountRole(strings.TrimSpace(string(*role)))
}
func (p *paymentExecutor) resolveCardRoute(intent model.PaymentIntent) (CardGatewayRoute, error) {
if p.svc != nil {
return p.svc.cardRoute(p.gatewayKeyFromIntent(intent))
}
key := p.gatewayKeyFromIntent(intent)
route, ok := p.deps.cardRoutes[key]
if !ok {
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
}
if strings.TrimSpace(route.FundingAddress) == "" {
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
}
return route, nil
}
func (p *paymentExecutor) gatewayKeyFromIntent(intent model.PaymentIntent) string {
key := strings.TrimSpace(intent.Attributes["gateway"])
if key == "" && intent.Destination.Card != nil {
key = defaultCardGateway
}
return strings.ToLower(key)
}

View File

@@ -1,116 +0,0 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/payments/rail"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amount *paymenttypes.Money, action model.RailOperation, idempotencyKey, operationRef string, quote *orchestratorv1.PaymentQuote, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) {
if payment == nil {
return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment is required")
}
if amount == nil {
return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required")
}
source := payment.Intent.Source.ManagedWallet
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
return rail.TransferRequest{}, merrors.InvalidArgument("chain: source managed wallet is required")
}
destRef, memo, err := p.resolveCryptoDestination(payment, action)
if err != nil {
return rail.TransferRequest{}, err
}
paymentRef := strings.TrimSpace(payment.PaymentRef)
if paymentRef == "" {
return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment reference is required")
}
req := rail.TransferRequest{
IntentRef: strings.TrimSpace(payment.Intent.Ref),
OperationRef: strings.TrimSpace(operationRef),
OrganizationRef: payment.OrganizationRef.Hex(),
PaymentRef: strings.TrimSpace(payment.PaymentRef),
FromAccountID: strings.TrimSpace(source.ManagedWalletRef),
ToAccountID: strings.TrimSpace(destRef),
Currency: strings.TrimSpace(amount.GetCurrency()),
Network: strings.TrimSpace(cryptoNetworkForPayment(payment)),
Amount: strings.TrimSpace(amount.GetAmount()),
IdempotencyKey: strings.TrimSpace(idempotencyKey),
Metadata: cloneMetadata(payment.Metadata),
DestinationMemo: memo,
}
if fromRole != nil {
req.FromRole = *fromRole
}
if toRole != nil {
req.ToRole = *toRole
}
if req.Currency == "" || req.Amount == "" {
return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required")
}
if req.IdempotencyKey == "" {
return rail.TransferRequest{}, merrors.InvalidArgument("chain: idempotency_key is required")
}
return req, nil
}
func (p *paymentExecutor) resolveCryptoDestination(payment *model.Payment, action model.RailOperation) (string, string, error) {
if payment == nil {
return "", "", merrors.InvalidArgument("chain: payment is required")
}
intent := payment.Intent
switch intent.Destination.Type {
case model.EndpointTypeManagedWallet:
if action == model.RailOperationSend {
if intent.Destination.ManagedWallet == nil || strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef) == "" {
return "", "", merrors.InvalidArgument("chain: destination managed wallet is required")
}
return strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef), "", nil
}
case model.EndpointTypeExternalChain:
if action == model.RailOperationSend {
if intent.Destination.ExternalChain == nil || strings.TrimSpace(intent.Destination.ExternalChain.Address) == "" {
return "", "", merrors.InvalidArgument("chain: external address is required")
}
return strings.TrimSpace(intent.Destination.ExternalChain.Address), strings.TrimSpace(intent.Destination.ExternalChain.Memo), nil
}
}
route, err := p.resolveCardRoute(intent)
if err != nil {
return "", "", err
}
switch action {
case model.RailOperationSend:
address := strings.TrimSpace(route.FundingAddress)
if address == "" {
return "", "", merrors.InvalidArgument("chain: funding address is required")
}
return address, "", nil
case model.RailOperationFee:
if walletRef := strings.TrimSpace(route.FeeWalletRef); walletRef != "" {
return walletRef, "", nil
}
if address := strings.TrimSpace(route.FeeAddress); address != "" {
return address, "", nil
}
return "", "", merrors.InvalidArgument("chain: fee destination is required")
default:
return "", "", merrors.InvalidArgument("chain: unsupported action")
}
}
func cryptoNetworkForPayment(payment *model.Payment) string {
if payment == nil {
return ""
}
network := networkFromEndpoint(payment.Intent.Source)
if network != "" {
return network
}
return networkFromEndpoint(payment.Intent.Destination)
}

View File

@@ -1,208 +0,0 @@
package orchestrator
import (
"context"
"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.uber.org/zap"
)
func buildStepIndex(plan *model.PaymentPlan) map[string]int {
m := make(map[string]int, len(plan.Steps))
for i, s := range plan.Steps {
if s == nil {
continue
}
m[s.StepID] = i
}
return m
}
func isPlanComplete(payment *model.Payment) bool {
if (payment.State == model.PaymentStateCancelled) ||
(payment.State == model.PaymentStateSettled) ||
(payment.State == model.PaymentStateFailed) {
return true
}
return false
}
func isStepFinal(step *model.ExecutionStep) bool {
if (step.State == model.OperationStateFailed) || (step.State == model.OperationStateSuccess) || (step.State == model.OperationStateCancelled) {
return true
}
return false
}
func (p *paymentExecutor) pickIndependentSteps(
ctx context.Context,
l *zap.Logger,
store storage.PaymentsStore,
waiting []*model.ExecutionStep,
payment *model.Payment,
quote *orchestratorv1.PaymentQuote,
) error {
logger := l.With(zap.Int("waiting_steps", len(waiting)))
logger.Debug("Selecting independent steps for execution")
execSteps := executionStepsByCode(payment.ExecutionPlan)
planSteps := planStepsByID(payment.PaymentPlan)
execQuote := executionQuote(payment, quote)
charges := ledgerChargesFromFeeLines(execQuote.GetFeeLines())
stepIdx := buildStepIndex(payment.PaymentPlan)
for _, execStep := range waiting {
if execStep == nil {
continue
}
lg := logger.With(
zap.String("step_code", execStep.Code),
zap.String("step_state", string(execStep.State)),
)
planStep := planSteps[execStep.Code]
if planStep == nil {
lg.Warn("Plan step not found")
continue
}
ready, waitingDep, blocked, err :=
stepDependenciesReady(planStep, execSteps, planSteps, true)
if err != nil {
lg.Warn("Dependency evaluation failed", zap.Error(err))
continue
}
if blocked {
lg.Debug("Step permanently blocked by dependency failure")
setExecutionStepStatus(execStep, model.OperationStateCancelled)
continue
}
if waitingDep {
lg.Debug("Step waiting for dependencies")
continue
}
if !ready {
continue
}
lg.Debug("Executing independent step")
idx := stepIdx[execStep.Code]
async, err := p.executePlanStep(
ctx,
payment,
planStep,
execStep,
quote,
charges,
idx,
)
if err != nil {
lg.Warn("Step execution failed", zap.Error(err), zap.Bool("async", async))
return err
}
}
return nil
}
func (p *paymentExecutor) pickWaitingSteps(
ctx context.Context,
l *zap.Logger,
store storage.PaymentsStore,
payment *model.Payment,
quote *orchestratorv1.PaymentQuote,
) error {
if payment == nil || payment.ExecutionPlan == nil {
l.Debug("No execution plan")
return nil
}
logger := l.With(zap.Int("total_steps", len(payment.ExecutionPlan.Steps)))
logger.Debug("Collecting waiting steps")
waitingSteps := make([]*model.ExecutionStep, 0, len(payment.ExecutionPlan.Steps))
for _, step := range payment.ExecutionPlan.Steps {
if step == nil {
continue
}
if step.State != model.OperationStatePlanned {
continue
}
waitingSteps = append(waitingSteps, step)
}
if len(waitingSteps) == 0 {
logger.Debug("No waiting steps to process")
return nil
}
return p.pickIndependentSteps(ctx, logger, store, waitingSteps, payment, quote)
}
func (p *paymentExecutor) executePaymentPlan(
ctx context.Context,
store storage.PaymentsStore,
payment *model.Payment,
quote *orchestratorv1.PaymentQuote,
) error {
if payment == nil {
return merrors.InvalidArgument("plan must be provided")
}
logger := p.logger.With(zap.String("payment_ref", payment.PaymentRef))
logger.Debug("Starting plan execution")
if isPlanComplete(payment) {
logger.Debug("Plan already completed")
return nil
}
if payment.ExecutionPlan == nil {
logger.Debug("Initializing execution plan from payment plan")
payment.ExecutionPlan = ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
if err := store.Update(ctx, payment); err != nil {
return err
}
}
// Execute steps
if err := p.pickWaitingSteps(ctx, logger, store, payment, quote); err != nil {
logger.Warn("Step execution returned infrastructure error", zap.Error(err))
}
if err := store.Update(ctx, payment); err != nil {
return err
}
done, failed, rootErr := analyzeExecutionPlan(logger, payment)
if !done {
return nil
}
if failed {
payment.State = model.PaymentStateFailed
} else {
payment.State = model.PaymentStateSettled
}
if err := store.Update(ctx, payment); err != nil {
logger.Warn("Failed to update final payment state", zap.Error(err))
return err
}
if failed && rootErr != nil {
return rootErr
}
return nil
}

View File

@@ -1,215 +0,0 @@
package orchestrator
import (
"fmt"
"strings"
"github.com/google/uuid"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/model/account_role"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs {
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
return payment.Execution
}
func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote {
if quote != nil {
return quote
}
if payment != nil && payment.LastQuote != nil {
return modelQuoteToProto(payment.LastQuote)
}
return &orchestratorv1.PaymentQuote{}
}
func ensureExecutionPlanForPlan(
payment *model.Payment,
plan *model.PaymentPlan,
) *model.ExecutionPlan {
if payment.ExecutionPlan != nil {
return payment.ExecutionPlan
}
exec := &model.ExecutionPlan{
Steps: make([]*model.ExecutionStep, 0, len(plan.Steps)),
}
for _, step := range plan.Steps {
if step == nil {
continue
}
exec.Steps = append(exec.Steps, &model.ExecutionStep{
Code: step.StepID,
State: model.OperationStatePlanned,
OperationRef: uuid.New().String(),
})
}
return exec
}
func executionPlanComplete(plan *model.ExecutionPlan) bool {
if plan == nil || len(plan.Steps) == 0 {
return false
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if step.State == model.OperationStateSkipped {
continue
}
if step.State != model.OperationStateSuccess {
return false
}
}
return true
}
func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
if plan == nil || execPlan == nil || len(plan.Steps) == 0 {
return false
}
execSteps := executionStepsByCode(execPlan)
for idx, step := range plan.Steps {
if step == nil || step.Action != model.RailOperationBlock {
continue
}
execStep := execSteps[planStepID(step, idx)]
if execStep == nil {
continue
}
if execStep.State == model.OperationStateSuccess {
return true
}
}
return false
}
func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) {
if plan == nil || idx <= 0 {
return nil, nil
}
for i := idx - 1; i >= 0; i-- {
step := plan.Steps[i]
if step == nil {
continue
}
if step.Rail != model.RailLedger || step.Action != model.RailOperationMove {
continue
}
if step.ToRole != nil && strings.TrimSpace(string(*step.ToRole)) != "" {
role := *step.ToRole
return &role, nil
}
}
return nil, nil
}
func linkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) {
if payment == nil || payment.PaymentPlan == nil {
return
}
ref := strings.TrimSpace(referenceID)
if ref == "" {
return
}
plan := payment.PaymentPlan
execPlan := ensureExecutionPlanForPlan(payment, plan)
if execPlan == nil {
return
}
dep := strings.TrimSpace(dependsOn)
for idx, planStep := range plan.Steps {
if planStep == nil {
continue
}
if planStep.Rail != rail || planStep.Action != model.RailOperationObserveConfirm {
continue
}
if dep != "" {
matched := false
for _, entry := range planStep.DependsOn {
if strings.EqualFold(strings.TrimSpace(entry), dep) {
matched = true
break
}
}
if !matched {
continue
}
}
if idx >= len(execPlan.Steps) {
continue
}
execStep := execPlan.Steps[idx]
if execStep == nil {
execStep = &model.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)}
execPlan.Steps[idx] = execStep
}
if execStep.TransferRef == "" {
execStep.TransferRef = ref
}
}
}
func planStepID(step *model.PaymentStep, idx int) string {
if step != nil {
if val := strings.TrimSpace(step.StepID); val != "" {
return val
}
}
return fmt.Sprintf("plan_step_%d", idx)
}
func describePlanStep(step *model.PaymentStep) string {
if step == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%s %s", step.Rail, step.Action))
}
func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string {
base := ""
if payment != nil {
base = strings.TrimSpace(payment.IdempotencyKey)
if base == "" {
base = strings.TrimSpace(payment.PaymentRef)
}
}
if base == "" {
base = "payment"
}
if step == nil {
return fmt.Sprintf("%s:plan:%d", base, idx)
}
stepID := strings.TrimSpace(step.StepID)
if stepID == "" {
stepID = fmt.Sprintf("%d", idx)
}
return fmt.Sprintf("%s:plan:%s:%s:%s", base, stepID, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action)))
}
func failureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode {
if step == nil {
return model.PaymentFailureCodePolicy
}
switch step.Rail {
case model.RailLedger:
if step.Action == model.RailOperationFXConvert {
return model.PaymentFailureCodeFX
}
return model.PaymentFailureCodeLedger
case model.RailCrypto:
return model.PaymentFailureCodeChain
default:
return model.PaymentFailureCodePolicy
}
}

View File

@@ -1,596 +0,0 @@
package orchestrator
import (
"context"
"fmt"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/payments/rail"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (p *paymentExecutor) postLedgerDebit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (string, error) {
paymentRef := ""
if payment != nil {
paymentRef = strings.TrimSpace(payment.PaymentRef)
}
if p.deps.ledger.internal == nil {
p.logger.Error("Ledger client unavailable", zap.String("action", "debit"), zap.String("payment_ref", paymentRef))
return "", merrors.Internal("ledger_client_unavailable")
}
tx, err := p.ledgerTxForAction(ctx, payment, amount, charges, idempotencyKey, idx, action, quote)
if err != nil {
p.logger.Warn("Ledger debit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
return "", err
}
ref, err := p.deps.ledger.internal.CreateTransaction(ctx, tx)
if err != nil {
p.logger.Warn("Ledger debit failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
return "", err
}
p.logger.Info("Ledger debit posted",
zap.String("payment_ref", paymentRef),
zap.Int("step_index", idx),
zap.String("action", string(action)),
zap.String("entry_ref", strings.TrimSpace(ref)))
return ref, nil
}
func (p *paymentExecutor) postLedgerCredit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (string, error) {
paymentRef := ""
if payment != nil {
paymentRef = strings.TrimSpace(payment.PaymentRef)
}
if p.deps.ledger.internal == nil {
p.logger.Error("Ledger client unavailable", zap.String("action", "credit"), zap.String("payment_ref", paymentRef))
return "", merrors.Internal("ledger_client_unavailable")
}
tx, err := p.ledgerTxForAction(ctx, payment, amount, nil, idempotencyKey, idx, action, quote)
if err != nil {
p.logger.Warn("Ledger credit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
return "", err
}
ref, err := p.deps.ledger.internal.CreateTransaction(ctx, tx)
if err != nil {
p.logger.Warn("Ledger credit failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
return "", err
}
p.logger.Info("Ledger credit posted",
zap.String("payment_ref", paymentRef),
zap.Int("step_index", idx),
zap.String("action", string(action)),
zap.String("entry_ref", strings.TrimSpace(ref)))
return ref, nil
}
func (p *paymentExecutor) postLedgerMove(ctx context.Context, payment *model.Payment, step *model.PaymentStep, amount *moneyv1.Money, idempotencyKey string, idx int) (string, error) {
paymentRef := ""
if payment != nil {
paymentRef = strings.TrimSpace(payment.PaymentRef)
}
if p.deps.ledger.internal == nil {
p.logger.Error("Ledger client unavailable", zap.String("action", "move"), zap.String("payment_ref", paymentRef))
return "", merrors.Internal("ledger_client_unavailable")
}
if payment == nil {
return "", merrors.InvalidArgument("ledger: payment is required")
}
if payment.OrganizationRef == bson.NilObjectID {
return "", merrors.InvalidArgument("ledger: organization_ref is required")
}
if step == nil {
return "", merrors.InvalidArgument("ledger: step is required")
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return "", merrors.InvalidArgument("ledger: amount is required")
}
fromRole, toRole, err := ledgerMoveRoles(step)
if err != nil {
return "", err
}
currency := strings.TrimSpace(amount.GetCurrency())
fromAccount, err := p.resolveAccount(ctx, payment.OrganizationRef, currency, model.RailLedger, fromRole)
if err != nil {
return "", err
}
toAccount, err := p.resolveAccount(ctx, payment.OrganizationRef, currency, model.RailLedger, toRole)
if err != nil {
return "", err
}
resp, err := p.deps.ledger.internal.TransferInternal(ctx, &ledgerv1.TransferRequest{
IdempotencyKey: strings.TrimSpace(idempotencyKey),
OrganizationRef: payment.OrganizationRef.Hex(),
FromLedgerAccountRef: strings.TrimSpace(fromAccount),
ToLedgerAccountRef: strings.TrimSpace(toAccount),
Money: cloneProtoMoney(amount),
Description: paymentDescription(payment),
Metadata: cloneMetadata(payment.Metadata),
FromRole: ledgerRoleFromAccountRole(fromRole),
ToRole: ledgerRoleFromAccountRole(toRole),
})
if err != nil {
p.logger.Warn("Ledger move failed",
zap.String("payment_ref", paymentRef),
zap.Int("step_index", idx),
zap.String("from_role", string(fromRole)),
zap.String("to_role", string(toRole)),
zap.String("from_account", strings.TrimSpace(fromAccount)),
zap.String("to_account", strings.TrimSpace(toAccount)),
zap.String("amount", strings.TrimSpace(amount.GetAmount())),
zap.String("currency", currency),
zap.Error(err))
return "", err
}
entryRef := strings.TrimSpace(resp.GetJournalEntryRef())
p.logger.Info("Ledger move posted",
zap.String("payment_ref", paymentRef),
zap.Int("step_index", idx),
zap.String("entry_ref", entryRef),
zap.String("from_role", string(fromRole)),
zap.String("to_role", string(toRole)),
zap.String("from_account", strings.TrimSpace(fromAccount)),
zap.String("to_account", strings.TrimSpace(toAccount)),
zap.String("amount", strings.TrimSpace(amount.GetAmount())),
zap.String("currency", currency))
return entryRef, nil
}
func (p *paymentExecutor) ledgerTxForAction(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (rail.LedgerTx, error) {
if payment == nil {
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: payment is required")
}
if payment.OrganizationRef == bson.NilObjectID {
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: organization_ref is required")
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: amount is required")
}
sourceRail, _, err := railFromEndpoint(payment.Intent.Source, payment.Intent.Attributes, true)
if err != nil {
sourceRail = model.RailUnspecified
}
destRail, _, err := railFromEndpoint(payment.Intent.Destination, payment.Intent.Attributes, false)
if err != nil {
destRail = model.RailUnspecified
}
fromRail := model.RailUnspecified
toRail := model.RailUnspecified
accountRef := ""
contraRef := ""
externalRef := ""
operation := ""
switch action {
case model.RailOperationDebit, model.RailOperationExternalDebit:
fromRail = model.RailLedger
toRail = ledgerStepToRail(payment.PaymentPlan, idx, destRail)
accountRef, contraRef, err = ledgerDebitAccount(payment)
if err != nil {
accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action)
}
if err == nil {
if blockRef := ledgerBlockAccountIfConfirmed(payment); blockRef != "" {
accountRef = blockRef
contraRef = ""
}
}
if action == model.RailOperationExternalDebit {
operation = "external.debit"
}
case model.RailOperationCredit, model.RailOperationExternalCredit:
fromRail = ledgerStepFromRail(payment.PaymentPlan, idx, sourceRail)
toRail = model.RailLedger
accountRef, contraRef, err = ledgerCreditAccount(payment)
if err != nil {
accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action)
}
externalRef = ledgerExternalReference(payment.ExecutionPlan, idx)
if action == model.RailOperationExternalCredit {
operation = "external.credit"
}
default:
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: unsupported action")
}
if err != nil {
return rail.LedgerTx{}, err
}
isDebit := action == model.RailOperationDebit || action == model.RailOperationExternalDebit
isCredit := action == model.RailOperationCredit || action == model.RailOperationExternalCredit
if isCredit && strings.TrimSpace(accountRef) != "" {
setLedgerAccountAttributes(payment, accountRef)
}
if isDebit && toRail == model.RailLedger {
toRail = model.RailUnspecified
}
if isCredit && fromRail == model.RailLedger {
fromRail = model.RailUnspecified
}
planID := payment.PaymentRef
if payment.PaymentPlan != nil && strings.TrimSpace(payment.PaymentPlan.ID) != "" {
planID = strings.TrimSpace(payment.PaymentPlan.ID)
}
feeAmount := ""
if isDebit {
if feeMoney := resolveFeeAmount(payment, quote); feeMoney != nil {
feeAmount = strings.TrimSpace(feeMoney.GetAmount())
}
}
fxRate := ""
if quote != nil && quote.GetFxQuote() != nil && quote.GetFxQuote().GetPrice() != nil {
fxRate = strings.TrimSpace(quote.GetFxQuote().GetPrice().GetValue())
}
return rail.LedgerTx{
PaymentPlanID: planID,
Currency: strings.TrimSpace(amount.GetCurrency()),
Amount: strings.TrimSpace(amount.GetAmount()),
FeeAmount: feeAmount,
FromRail: ledgerRailValue(fromRail),
ToRail: ledgerRailValue(toRail),
ExternalReferenceID: externalRef,
Operation: operation,
FXRateUsed: fxRate,
IdempotencyKey: strings.TrimSpace(idempotencyKey),
CreatedAt: planTimestamp(payment),
OrganizationRef: payment.OrganizationRef.Hex(),
LedgerAccountRef: strings.TrimSpace(accountRef),
ContraLedgerAccountRef: strings.TrimSpace(contraRef),
Description: paymentDescription(payment),
Charges: charges,
Metadata: cloneMetadata(payment.Metadata),
}, nil
}
func ledgerRailValue(railValue model.Rail) string {
if railValue == model.RailUnspecified || strings.TrimSpace(string(railValue)) == "" {
return ""
}
return string(railValue)
}
func ledgerStepFromRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail {
if plan == nil || idx <= 0 {
return fallback
}
for i := idx - 1; i >= 0; i-- {
step := plan.Steps[i]
if step == nil {
continue
}
if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified {
return step.Rail
}
}
return fallback
}
func ledgerStepToRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail {
if plan == nil || idx < 0 {
return fallback
}
for i := idx + 1; i < len(plan.Steps); i++ {
step := plan.Steps[i]
if step == nil {
continue
}
if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified {
return step.Rail
}
}
return fallback
}
func ledgerExternalReference(plan *model.ExecutionPlan, idx int) string {
if plan == nil || idx <= 0 {
return ""
}
for i := idx - 1; i >= 0; i-- {
step := plan.Steps[i]
if step == nil {
continue
}
if ref := strings.TrimSpace(step.TransferRef); ref != "" {
return ref
}
}
return ""
}
func ledgerMoveRoles(step *model.PaymentStep) (account_role.AccountRole, account_role.AccountRole, error) {
if step == nil {
return "", "", merrors.InvalidArgument("ledger: step is required")
}
if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" {
return "", "", merrors.InvalidArgument("ledger: from_role is required")
}
if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" {
return "", "", merrors.InvalidArgument("ledger: to_role is required")
}
from := strings.ToLower(strings.TrimSpace(string(*step.FromRole)))
to := strings.ToLower(strings.TrimSpace(string(*step.ToRole)))
if from == "" || to == "" || strings.EqualFold(from, to) {
return "", "", merrors.InvalidArgument("ledger: from_role and to_role must differ")
}
return account_role.AccountRole(from), account_role.AccountRole(to), nil
}
func ledgerRoleFromAccountRole(role account_role.AccountRole) ledgerv1.AccountRole {
if strings.TrimSpace(string(role)) == "" {
return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
}
if parsed, ok := ledgerconv.ParseAccountRole(string(role)); ok {
return parsed
}
return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
}
func (p *paymentExecutor) resolveAccount(ctx context.Context, orgRef bson.ObjectID, asset string, rail model.Rail, role account_role.AccountRole) (string, error) {
switch rail {
case model.RailLedger:
return p.resolveLedgerAccountByRole(ctx, orgRef, asset, role)
default:
return "", nil
}
}
func (p *paymentExecutor) resolveLedgerAccountByRole(ctx context.Context, orgRef bson.ObjectID, asset string, role account_role.AccountRole) (string, error) {
if p == nil || p.deps == nil || p.deps.ledger.client == nil {
return "", merrors.Internal("ledger_client_unavailable")
}
if orgRef == bson.NilObjectID {
return "", merrors.InvalidArgument("ledger: organization_ref is required")
}
currency := strings.TrimSpace(asset)
if currency == "" {
return "", merrors.InvalidArgument("ledger: asset is required")
}
if strings.TrimSpace(string(role)) == "" {
return "", merrors.InvalidArgument("ledger: role is required")
}
resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{
OrganizationRef: orgRef.Hex(),
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
Asset: currency,
})
if err != nil {
return "", err
}
expectedRole := strings.ToLower(strings.TrimSpace(string(role)))
for _, account := range resp.GetAccounts() {
if account == nil {
continue
}
if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT {
continue
}
if asset := strings.TrimSpace(account.GetAsset()); asset == "" || !strings.EqualFold(asset, currency) {
continue
}
if strings.TrimSpace(account.GetOwnerRef()) != "" {
continue
}
accRole := strings.ToLower(strings.TrimSpace(string(connectorAccountRole(account))))
if accRole == "" || !strings.EqualFold(accRole, expectedRole) {
continue
}
if ref := account.GetRef(); ref != nil {
if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" {
return accountID, nil
}
}
}
return "", merrors.InvalidArgument("ledger: account role not found")
}
func (p *paymentExecutor) resolveLedgerAccountRef(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, action model.RailOperation) (string, string, error) {
if payment == nil {
return "", "", merrors.InvalidArgument("ledger: payment is required")
}
if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" {
return "", "", merrors.InvalidArgument("ledger: amount is required")
}
switch action {
case model.RailOperationCredit, model.RailOperationExternalCredit:
if account, _, err := ledgerDebitAccount(payment); err == nil && strings.TrimSpace(account) != "" {
setLedgerAccountAttributes(payment, account)
return account, "", nil
}
case model.RailOperationDebit, model.RailOperationExternalDebit:
if account, _, err := ledgerCreditAccount(payment); err == nil && strings.TrimSpace(account) != "" {
setLedgerAccountAttributes(payment, account)
return account, "", nil
}
}
account, err := p.resolveOrgOwnedLedgerAccount(ctx, payment, amount)
if err != nil {
return "", "", err
}
setLedgerAccountAttributes(payment, account)
return account, "", nil
}
func (p *paymentExecutor) resolveOrgOwnedLedgerAccount(ctx context.Context, payment *model.Payment, amount *moneyv1.Money) (string, error) {
if payment == nil {
return "", merrors.InvalidArgument("ledger: payment is required")
}
if payment.OrganizationRef == bson.NilObjectID {
return "", merrors.InvalidArgument("ledger: organization_ref is required")
}
if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" {
return "", merrors.InvalidArgument("ledger: amount is required")
}
if p == nil || p.deps == nil || p.deps.ledger.client == nil {
return "", merrors.Internal("ledger_client_unavailable")
}
currency := strings.TrimSpace(amount.GetCurrency())
resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{
OrganizationRef: payment.OrganizationRef.Hex(),
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
Asset: currency,
})
if err != nil {
return "", err
}
for _, account := range resp.GetAccounts() {
if account == nil {
continue
}
if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT {
continue
}
asset := strings.TrimSpace(account.GetAsset())
if asset == "" || !strings.EqualFold(asset, currency) {
continue
}
if strings.TrimSpace(account.GetOwnerRef()) != "" {
continue
}
if connectorAccountIsSettlement(account) {
continue
}
if ref := account.GetRef(); ref != nil {
if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" {
return accountID, nil
}
}
}
return "", merrors.InvalidArgument("ledger: org-owned account not found")
}
func connectorAccountIsSettlement(account *connectorv1.Account) bool {
return connectorAccountRole(account) == account_role.AccountRoleSettlement
}
func connectorAccountRole(account *connectorv1.Account) account_role.AccountRole {
if account == nil || account.GetProviderDetails() == nil {
return ""
}
details := account.GetProviderDetails().AsMap()
if value := strings.TrimSpace(fmt.Sprint(details["role"])); value != "" {
if role, ok := account_role.Parse(value); ok {
return role
}
}
switch v := details["is_settlement"].(type) {
case bool:
if v {
return account_role.AccountRoleSettlement
}
case string:
if strings.EqualFold(strings.TrimSpace(v), "true") {
return account_role.AccountRoleSettlement
}
}
return ""
}
func setLedgerAccountAttributes(payment *model.Payment, accountRef string) {
if payment == nil || strings.TrimSpace(accountRef) == "" {
return
}
if payment.Intent.Attributes == nil {
payment.Intent.Attributes = map[string]string{}
}
if attributeLookup(payment.Intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef") == "" {
payment.Intent.Attributes["ledger_debit_account_ref"] = accountRef
}
if attributeLookup(payment.Intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef") == "" {
payment.Intent.Attributes["ledger_credit_account_ref"] = accountRef
}
}
func ledgerDebitAccount(payment *model.Payment) (string, string, error) {
if payment == nil {
return "", "", merrors.InvalidArgument("ledger: payment is required")
}
intent := payment.Intent
if intent.Source.Ledger != nil && strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef) != "" {
return strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef), nil
}
if ref := attributeLookup(intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef"); ref != "" {
return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_debit_contra_account_ref", "ledgerDebitContraAccountRef")), nil
}
return "", "", merrors.InvalidArgument("ledger: source account is required")
}
func ledgerBlockAccount(payment *model.Payment) (string, error) {
if payment == nil {
return "", merrors.InvalidArgument("ledger: payment is required")
}
intent := payment.Intent
if intent.Source.Ledger != nil {
if ref := strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef); ref != "" {
return ref, nil
}
}
if ref := attributeLookup(intent.Attributes,
"ledger_block_account_ref",
"ledgerBlockAccountRef",
"ledger_hold_account_ref",
"ledgerHoldAccountRef",
"ledger_debit_contra_account_ref",
"ledgerDebitContraAccountRef",
); ref != "" {
return ref, nil
}
return "", merrors.InvalidArgument("ledger: block account is required")
}
func ledgerBlockAccountIfConfirmed(payment *model.Payment) string {
if payment == nil {
return ""
}
if !blockStepConfirmed(payment.PaymentPlan, payment.ExecutionPlan) {
return ""
}
ref, err := ledgerBlockAccount(payment)
if err != nil {
return ""
}
return ref
}
func ledgerCreditAccount(payment *model.Payment) (string, string, error) {
if payment == nil {
return "", "", merrors.InvalidArgument("ledger: payment is required")
}
intent := payment.Intent
if intent.Destination.Ledger != nil && strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef) != "" {
return strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Destination.Ledger.ContraLedgerAccountRef), nil
}
if ref := attributeLookup(intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef"); ref != "" {
return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_credit_contra_account_ref", "ledgerCreditContraAccountRef")), nil
}
return "", "", merrors.InvalidArgument("ledger: destination account is required")
}
func attributeLookup(attrs map[string]string, keys ...string) string {
if len(keys) == 0 {
return ""
}
for _, key := range keys {
if key == "" || attrs == nil {
continue
}
if val := strings.TrimSpace(attrs[key]); val != "" {
return val
}
}
return ""
}

View File

@@ -1,226 +0,0 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
result := map[string]*model.ExecutionStep{}
if plan == nil {
return result
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if code := strings.TrimSpace(step.Code); code != "" {
result[code] = step
}
}
return result
}
func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep {
result := map[string]*model.PaymentStep{}
if plan == nil {
return result
}
for idx, step := range plan.Steps {
if step == nil {
continue
}
id := planStepID(step, idx)
if id == "" {
continue
}
result[id] = step
}
return result
}
func stepDependenciesReady(
step *model.PaymentStep,
execSteps map[string]*model.ExecutionStep,
planSteps map[string]*model.PaymentStep,
requireSuccess bool,
) (ready bool, waiting bool, blocked bool, err error) {
if step == nil {
return false, false, false,
merrors.InvalidArgument("payment plan: step is required")
}
for _, dep := range step.DependsOn {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
execStep := execSteps[key]
if execStep == nil {
// step has not been started
return false, true, false, nil
}
if execStep.State == model.OperationStateFailed ||
execStep.State == model.OperationStateCancelled {
// dependency dead, step is impossible
return false, false, true, nil
}
if !execStep.ReadyForNext() {
// step is processed
return false, true, false, nil
}
}
// ------------------------------------------------------------
// Commit policies
// ------------------------------------------------------------
switch step.CommitPolicy {
case model.CommitPolicyImmediate, model.CommitPolicyUnspecified:
return true, false, false, nil
case model.CommitPolicyAfterSuccess:
commitAfter := step.CommitAfter
if len(commitAfter) == 0 {
commitAfter = step.DependsOn
}
for _, dep := range commitAfter {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
execStep := execSteps[key]
if execStep == nil {
return false, true, false,
merrors.InvalidArgument("commit dependency missing")
}
if execStep.State == model.OperationStateFailed ||
execStep.State == model.OperationStateCancelled {
return false, false, true, nil
}
if !execStep.IsSuccess() {
return false, true, false, nil
}
}
return true, false, false, nil
case model.CommitPolicyAfterFailure:
commitAfter := step.CommitAfter
if len(commitAfter) == 0 {
commitAfter = step.DependsOn
}
for _, dep := range commitAfter {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
execStep := execSteps[key]
if execStep == nil {
return false, true, false,
merrors.InvalidArgument("commit dependency missing")
}
if execStep.State == model.OperationStateFailed {
continue
}
if execStep.IsTerminal() {
// complete with fail, block
return false, false, true, nil
}
// still exexuting, wait
return false, true, false, nil
}
return true, false, false, nil
case model.CommitPolicyAfterCanceled:
commitAfter := step.CommitAfter
if len(commitAfter) == 0 {
commitAfter = step.DependsOn
}
for _, dep := range commitAfter {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
execStep := execSteps[key]
if execStep == nil {
return false, true, false,
merrors.InvalidArgument("commit dependency missing")
}
if !execStep.IsTerminal() {
return false, true, false, nil
}
}
return true, false, false, nil
default:
return true, false, false, nil
}
}
func cardPayoutDependenciesConfirmed(
plan *model.PaymentPlan,
execPlan *model.ExecutionPlan,
) bool {
if execPlan == nil {
return false
}
if plan == nil || len(plan.Steps) == 0 {
return sourceStepsConfirmed(execPlan)
}
execSteps := executionStepsByCode(execPlan)
planSteps := planStepsByID(plan)
for _, step := range plan.Steps {
if step == nil {
continue
}
if step.Rail != model.RailCardPayout ||
step.Action != model.RailOperationSend {
continue
}
ready, waiting, blocked, err :=
stepDependenciesReady(step, execSteps, planSteps, true)
if err != nil || blocked {
// payout definitely cannot run
return false
}
if waiting {
// dependencies exist but are not finished yet
// payout must NOT run
return false
}
// only true when dependencies are REALLY satisfied
return ready
}
return false
}

View File

@@ -1,50 +0,0 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"go.uber.org/zap"
)
func (p *paymentExecutor) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if store == nil {
return errStorageUnavailable
}
if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 {
return nil
}
execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
if execPlan == nil || !blockStepConfirmed(payment.PaymentPlan, execPlan) {
return nil
}
execSteps := executionStepsByCode(execPlan)
execQuote := executionQuote(payment, nil)
for idx, step := range payment.PaymentPlan.Steps {
if step == nil || step.Action != model.RailOperationRelease {
continue
}
stepID := planStepID(step, idx)
execStep := execSteps[stepID]
if execStep == nil {
execStep = &model.ExecutionStep{Code: stepID}
execSteps[stepID] = execStep
if idx < len(execPlan.Steps) {
execPlan.Steps[idx] = execStep
}
}
if execStep.State == model.OperationStateSuccess {
p.logger.Debug("Payment step already confirmed, skipping", zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef))
continue
}
if _, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, nil, idx); err != nil {
p.logger.Warn("Failed to execute payment step", zap.Error(err),
zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef))
return err
}
}
return p.persistPayment(ctx, store, payment)
}

View File

@@ -1,446 +0,0 @@
package orchestrator
import (
"context"
"fmt"
"math/big"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
func (p *paymentExecutor) executePlanStep(
ctx context.Context,
payment *model.Payment,
step *model.PaymentStep,
execStep *model.ExecutionStep,
quote *orchestratorv1.PaymentQuote,
charges []*ledgerv1.PostingLine,
idx int,
) (bool, error) {
if payment == nil || step == nil || execStep == nil {
return false, merrors.InvalidArgument("payment plan: step is required")
}
stepID := execStep.Code
logger := p.logger.With(
zap.String("payment_ref", payment.PaymentRef),
zap.String("step_id", stepID),
zap.String("rail", string(step.Rail)),
zap.String("action", string(step.Action)),
zap.Int("idx", idx),
)
logger.Debug("Executing payment plan step")
if execStep.IsTerminal() {
logger.Debug("Step already in terminal state, skipping execution",
zap.String("state", string(execStep.State)),
)
return false, nil
}
switch step.Action {
case model.RailOperationMove:
logger.Debug("Posting ledger move")
amount, err := requireMoney(cloneMoney(step.Amount), "ledger move amount")
if err != nil {
logger.Warn("Ledger move amount invalid", zap.Error(err))
return false, err
}
ref, err := p.postLedgerMove(ctx, payment, step, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx)
if err != nil {
logger.Warn("Ledger move failed", zap.Error(err))
return false, err
}
execStep.TransferRef = strings.TrimSpace(ref)
setExecutionStepStatus(execStep, model.OperationStateSuccess)
logger.Info("Ledger move completed", zap.String("journal_ref", ref))
return false, nil
case model.RailOperationDebit, model.RailOperationExternalDebit:
logger.Debug("Posting ledger debit")
amount, err := requireMoney(cloneMoney(step.Amount), "ledger debit amount")
if err != nil {
logger.Warn("Ledger debit amount invalid", zap.Error(err))
return false, err
}
ref, err := p.postLedgerDebit(ctx, payment, protoMoney(amount), charges, planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote)
if err != nil {
logger.Warn("Ledger debit failed", zap.Error(err))
return false, err
}
ensureExecutionRefs(payment).DebitEntryRef = ref
setExecutionStepStatus(execStep, model.OperationStateSuccess)
logger.Info("Ledger debit completed", zap.String("journal_ref", ref))
return false, nil
case model.RailOperationCredit, model.RailOperationExternalCredit:
logger.Debug("Posting ledger credit")
amount, err := requireMoney(cloneMoney(step.Amount), "ledger credit amount")
if err != nil {
logger.Warn("Ledger credit amount invalid", zap.Error(err))
return false, err
}
ref, err := p.postLedgerCredit(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote)
if err != nil {
logger.Warn("Ledger credit failed", zap.Error(err))
return false, err
}
ensureExecutionRefs(payment).CreditEntryRef = ref
setExecutionStepStatus(execStep, model.OperationStateSuccess)
logger.Info("Ledger credit completed", zap.String("journal_ref", ref))
return false, nil
case model.RailOperationFXConvert:
logger.Debug("Applying FX conversion")
if err := p.applyFX(ctx, payment, quote, charges, paymentDescription(payment), cloneMetadata(payment.Metadata), ensureExecutionRefs(payment)); err != nil {
logger.Warn("FX conversion failed", zap.Error(err))
return false, err
}
setExecutionStepStatus(execStep, model.OperationStateSuccess)
logger.Info("FX conversion completed")
return false, nil
case model.RailOperationObserveConfirm:
setExecutionStepStatus(execStep, model.OperationStateWaiting)
logger.Info("ObserveConfirm step set to waiting for external confirmation")
return true, nil
case model.RailOperationSend:
logger.Debug("Executing send step")
async, err := p.executeSendStep(ctx, payment, step, execStep, quote, idx)
if err != nil {
setExecutionStepStatus(execStep, model.OperationStateFailed)
execStep.Error = err.Error()
logger.Warn("Send step failed", zap.Error(err))
return false, err
}
return async, nil
case model.RailOperationFee:
logger.Debug("Executing fee step")
async, err := p.executeFeeStep(ctx, payment, step, execStep, idx)
if err != nil {
logger.Warn("Fee step failed", zap.Error(err))
return false, err
}
logger.Info("Fee step submitted")
return async, nil
default:
logger.Warn("Unsupported payment plan action")
return false, merrors.InvalidArgument("payment plan: unsupported action")
}
}
func sub(a, b string) (string, error) {
ra, ok := new(big.Rat).SetString(a)
if !ok {
return "", fmt.Errorf("invalid number: %s", a)
}
rb, ok := new(big.Rat).SetString(b)
if !ok {
return "", fmt.Errorf("invalid number: %s", b)
}
ra.Sub(ra, rb)
// 2 знака после запятой (как у тебя)
return ra.FloatString(2), nil
}
func (p *paymentExecutor) executeSendStep(
ctx context.Context,
payment *model.Payment,
step *model.PaymentStep,
execStep *model.ExecutionStep,
quote *orchestratorv1.PaymentQuote,
idx int,
) (bool, error) {
stepID := execStep.Code
logger := p.logger.With(
zap.String("payment_ref", payment.PaymentRef),
zap.String("step_id", stepID),
zap.String("rail", string(step.Rail)),
zap.String("action", string(step.Action)),
zap.Int("idx", idx),
)
logger.Debug("Executing send step")
switch step.Rail {
case model.RailCrypto:
logger.Debug("Preparing crypto transfer")
amount, err := requireMoney(cloneMoney(step.Amount), "crypto send amount")
if err != nil {
logger.Warn("Invalid crypto amount", zap.Error(err))
return false, err
}
if !p.deps.railGateways.available() {
logger.Warn("Rail gateway unavailable")
return false, merrors.Internal("rail gateway unavailable")
}
fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx)
req, err := p.buildCryptoTransferRequest(
payment,
amount,
model.RailOperationSend,
planStepIdempotencyKey(payment, idx, step),
execStep.OperationRef,
quote,
fromRole, toRole,
)
if err != nil {
logger.Warn("Failed to build crypto transfer request", zap.Error(err))
return false, err
}
gw, err := p.deps.railGateways.resolve(ctx, step)
if err != nil {
logger.Warn("Failed to resolve rail gateway", zap.Error(err))
return false, err
}
logger.Debug("Sending crypto transfer",
zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef),
zap.String("operation_ref", req.OperationRef),
)
result, err := gw.Send(ctx, req)
if err != nil {
execStep.Error = strings.TrimSpace(err.Error())
setExecutionStepStatus(execStep, model.OperationStateFailed)
payment.State = model.PaymentStateFailed
payment.FailureCode = model.PaymentFailureCodeChain
logger.Warn("Send failed; step marked as failed", zap.Error(err))
return false, nil
}
execStep.TransferRef = strings.TrimSpace(result.ReferenceID)
logger.Info("Crypto transfer submitted",
zap.String("transfer_ref", execStep.TransferRef),
)
exec := ensureExecutionRefs(payment)
if exec.ChainTransferRef == "" && execStep.TransferRef != "" {
exec.ChainTransferRef = execStep.TransferRef
}
if execStep.TransferRef != "" {
linkRailObservation(payment, step.Rail, execStep.TransferRef, stepID)
}
setExecutionStepStatus(execStep, model.OperationStateWaiting)
return true, nil
case model.RailCardPayout:
logger.Debug("Submitting card payout")
amount, err := requireMoney(cloneMoney(step.Amount), "card payout amount")
if err != nil {
logger.Warn("Invalid card payout amount", zap.Error(err))
return false, err
}
fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx)
ref, err := p.submitCardPayoutPlan(
ctx,
payment,
execStep.OperationRef,
protoMoney(amount),
fromRole, toRole,
)
if err != nil {
logger.Warn("Card payout submission failed", zap.Error(err))
return false, err
}
execStep.TransferRef = ref
ensureExecutionRefs(payment).CardPayoutRef = ref
logger.Info("Card payout submitted", zap.String("payout_ref", ref))
setExecutionStepStatus(execStep, model.OperationStateWaiting)
return true, nil
case model.RailProviderSettlement:
logger.Debug("Preparing provider settlement transfer")
amount, err := requireMoney(cloneMoney(payment.LastQuote.DebitSettlementAmount), "provider settlement amount")
if err != nil {
logger.Warn("Invalid provider settlement amount", zap.Error(err), zap.Any("settlement", payment.LastQuote.DebitSettlementAmount))
return false, err
}
logger.Debug("Expected settlement amount", zap.String("amount", amount.Amount), zap.String("currency", amount.Currency))
fee, err := requireMoney(cloneMoney(payment.LastQuote.ExpectedFeeTotal), "provider settlement amount")
if err != nil {
logger.Warn("Invalid fee settlement amount", zap.Error(err))
return false, err
}
if fee.Currency != amount.Currency {
logger.Warn("Fee and amount currencies do not match",
zap.String("amount_currency", amount.Currency), zap.String("fee_currency", fee.Currency),
)
return false, merrors.DataConflict("settlement payment: currencies mismatch")
}
if !p.deps.railGateways.available() {
logger.Warn("Rail gateway unavailable")
return false, merrors.Internal("rail gateway unavailable")
}
fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx)
req, err := p.buildProviderSettlementTransferRequest(
payment,
step,
execStep.OperationRef,
amount,
quote,
idx,
fromRole, toRole)
if err != nil {
logger.Warn("Failed to build provider settlement request", zap.Error(err))
return false, err
}
gw, err := p.deps.railGateways.resolve(ctx, step)
if err != nil {
logger.Warn("Failed to resolve rail gateway", zap.Error(err))
return false, err
}
logger.Info("Sending provider settlement transfer",
zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef),
)
result, err := gw.Send(ctx, req)
if err != nil {
execStep.Error = strings.TrimSpace(err.Error())
setExecutionStepStatus(execStep, model.OperationStateFailed)
payment.State = model.PaymentStateFailed
payment.FailureCode = model.PaymentFailureCodeSettlement
logger.Warn("Send failed; step marked as failed", zap.Error(err))
return false, nil
}
execStep.TransferRef = strings.TrimSpace(result.ReferenceID)
if execStep.TransferRef == "" {
execStep.TransferRef = strings.TrimSpace(req.IdempotencyKey)
}
logger.Info("Provider settlement submitted",
zap.String("transfer_ref", execStep.TransferRef),
)
linkProviderSettlementObservation(payment, execStep.TransferRef)
setExecutionStepStatus(execStep, model.OperationStateWaiting)
return true, nil
case model.RailFiatOnRamp:
logger.Warn("Fiat on-ramp not implemented")
return false, merrors.InvalidArgument("payment plan: fiat on-ramp execution not implemented")
default:
logger.Warn("Unsupported send rail")
return false, merrors.InvalidArgument("payment plan: unsupported send rail")
}
}
func (p *paymentExecutor) executeFeeStep(
ctx context.Context,
payment *model.Payment,
step *model.PaymentStep,
execStep *model.ExecutionStep,
idx int,
) (bool, error) {
if payment == nil || step == nil || execStep == nil {
return false, merrors.InvalidArgument("payment plan: fee step is required")
}
switch step.Rail {
case model.RailCrypto:
amount, err := requireMoney(cloneMoney(step.Amount), "crypto fee amount")
if err != nil {
return false, err
}
if !p.deps.railGateways.available() {
return false, merrors.Internal("rail gateway unavailable")
}
fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx)
req, err := p.buildCryptoTransferRequest(
payment,
amount,
model.RailOperationFee,
planStepIdempotencyKey(payment, idx, step),
execStep.OperationRef,
nil,
fromRole,
toRole,
)
if err != nil {
return false, err
}
gw, err := p.deps.railGateways.resolve(ctx, step)
if err != nil {
return false, err
}
p.logger.Debug("Executing crypto fee transfer",
zap.String("payment_ref", payment.PaymentRef),
zap.String("step_id", planStepID(step, idx)),
zap.String("amount", amount.GetAmount()),
zap.String("currency", amount.GetCurrency()),
)
result, err := gw.Send(ctx, req)
if err != nil {
p.logger.Warn("Crypto fee transfer failed to submit", zap.Error(err),
zap.String("payment_ref", payment.PaymentRef),
)
return false, nil
}
execStep.TransferRef = strings.TrimSpace(result.ReferenceID)
if execStep.TransferRef != "" {
ensureExecutionRefs(payment).FeeTransferRef = execStep.TransferRef
}
// ВАЖНО: больше не Submitted
setExecutionStepStatus(execStep, model.OperationStateWaiting)
p.logger.Info("Crypto fee transfer submitted, waiting confirmation",
zap.String("payment_ref", payment.PaymentRef),
zap.String("transfer_ref", execStep.TransferRef),
)
return true, nil
default:
return false, merrors.InvalidArgument("payment plan: unsupported fee rail")
}
}

View File

@@ -1,132 +0,0 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/payments/rail"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
const (
providerSettlementMetaPaymentIntentID = "payment_ref"
providerSettlementMetaQuoteRef = "quote_ref"
providerSettlementMetaTargetChatID = "target_chat_id"
providerSettlementMetaOutgoingLeg = "outgoing_leg"
providerSettlementMetaSourceAmount = "source_amount"
providerSettlementMetaSourceCurrency = "source_currency"
)
func (p *paymentExecutor) buildProviderSettlementTransferRequest(payment *model.Payment, step *model.PaymentStep, operationRef string, amount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote, idx int, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) {
if payment == nil || step == nil {
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment and step are required")
}
if amount == nil {
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: amount is required")
}
requestID := planStepIdempotencyKey(payment, idx, step)
if requestID == "" {
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: idempotency key is required")
}
intentRef := strings.TrimSpace(payment.Intent.Ref)
if intentRef == "" {
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: intention ref is required")
}
paymentRef := strings.TrimSpace(payment.PaymentRef)
if paymentRef == "" {
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment_ref is required")
}
metadata := cloneMetadata(payment.Metadata)
if metadata == nil {
metadata = map[string]string{}
}
metadata[providerSettlementMetaPaymentIntentID] = paymentRef
if quoteRef := paymentGatewayQuoteRef(payment, quote); quoteRef != "" {
metadata[providerSettlementMetaQuoteRef] = quoteRef
}
if chatID := paymentGatewayTargetChatID(payment); chatID != "" {
metadata[providerSettlementMetaTargetChatID] = chatID
}
if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" {
metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(string(step.Rail)))
}
if strings.TrimSpace(metadata[providerSettlementMetaSourceAmount]) == "" {
metadata[providerSettlementMetaSourceAmount] = strings.TrimSpace(amount.Amount)
}
if strings.TrimSpace(metadata[providerSettlementMetaSourceCurrency]) == "" {
metadata[providerSettlementMetaSourceCurrency] = strings.TrimSpace(amount.Currency)
}
sourceWalletRef := ""
if payment.Intent.Source.ManagedWallet != nil {
sourceWalletRef = strings.TrimSpace(payment.Intent.Source.ManagedWallet.ManagedWalletRef)
}
if sourceWalletRef == "" {
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: source managed wallet is required")
}
destRef := ""
if payment.Intent.Destination.Type == model.EndpointTypeCard {
if route, err := p.resolveCardRoute(payment.Intent); err == nil {
destRef = strings.TrimSpace(route.FundingAddress)
}
}
if destRef == "" {
destRef = paymentRef
}
req := rail.TransferRequest{
OrganizationRef: payment.OrganizationRef.Hex(),
FromAccountID: sourceWalletRef,
ToAccountID: destRef,
Currency: strings.TrimSpace(amount.GetCurrency()),
Amount: strings.TrimSpace(amount.GetAmount()),
IdempotencyKey: requestID,
DestinationMemo: paymentRef,
Metadata: metadata,
PaymentRef: paymentRef,
OperationRef: operationRef,
IntentRef: intentRef,
}
if fromRole != nil {
req.FromRole = *fromRole
}
if toRole != nil {
req.ToRole = *toRole
}
return req, nil
}
func paymentGatewayQuoteRef(payment *model.Payment, quote *orchestratorv1.PaymentQuote) string {
if quote != nil {
if ref := strings.TrimSpace(quote.GetQuoteRef()); ref != "" {
return ref
}
}
if payment != nil && payment.LastQuote != nil {
return strings.TrimSpace(payment.LastQuote.QuoteRef)
}
return ""
}
func paymentGatewayTargetChatID(payment *model.Payment) string {
if payment == nil {
return ""
}
if payment.Intent.Attributes != nil {
if chatID := strings.TrimSpace(payment.Intent.Attributes["target_chat_id"]); chatID != "" {
return chatID
}
}
if payment.Metadata != nil {
return strings.TrimSpace(payment.Metadata["target_chat_id"])
}
return ""
}
func linkProviderSettlementObservation(payment *model.Payment, requestID string) {
linkRailObservation(payment, model.RailProviderSettlement, requestID, "")
}

View File

@@ -1,210 +0,0 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"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"
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.orchestrator: storage not initialised")
)
// Service orchestrates payments across ledger, billing, FX, and chain domains.
type Service struct {
logger mlogger.Logger
storage storage.Repository
clock clockpkg.Clock
deps serviceDependencies
h handlerSet
comp componentSet
gatewayBroker mb.Broker
gatewayConsumers []msg.Consumer
orchestratorv1.UnimplementedPaymentOrchestratorServer
}
type serviceDependencies struct {
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
railGateways railGatewayDependency
providerGateway providerGatewayDependency
oracle oracleDependency
mntx mntxDependency
gatewayRegistry GatewayRegistry
gatewayInvokeResolver GatewayInvokeResolver
cardRoutes map[string]CardGatewayRoute
feeLedgerAccounts map[string]string
planBuilder PlanBuilder
}
type handlerSet struct {
commands *paymentCommandFactory
queries *paymentQueryHandler
events *paymentEventHandler
}
type componentSet struct {
executor *paymentExecutor
}
// NewService constructs a payment orchestrator service.
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("payment_orchestrator"),
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)
svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries"))
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"), svc.submitCardPayout, svc.resumePaymentPlan, svc.releasePaymentHold)
svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc)
svc.startGatewayConsumers()
return svc
}
func (s *Service) ensureHandlers() {
if s.h.commands == nil {
s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger)
}
if s.h.queries == nil {
s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries"))
}
if s.h.events == nil {
s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"), s.submitCardPayout, s.resumePaymentPlan, s.releasePaymentHold)
}
if s.comp.executor == nil {
s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s)
}
}
// Register attaches the service to the supplied gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
orchestratorv1.RegisterPaymentOrchestratorServer(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)
}
// InitiatePayment captures a payment intent and reserves funds orchestration.
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req)
}
// InitiatePayments executes multiple payments using a stored quote reference.
func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().Execute, req)
}
// CancelPayment attempts to cancel an in-flight payment.
func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req)
}
// GetPayment returns a stored payment record.
func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req)
}
// ListPayments lists stored payment records.
func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req)
}
// InitiateConversion orchestrates standalone FX conversions.
func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req)
}
// ProcessTransferUpdate reconciles chain events back into payment state.
func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req)
}
// ProcessDepositObserved reconciles deposit events to ledger.
func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req)
}
// ProcessCardPayoutUpdate reconciles card payout events back into payment state.
func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req)
}
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
s.ensureHandlers()
return s.comp.executor.executePayment(ctx, store, payment, quote)
}
func (s *Service) resumePaymentPlan(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 {
return nil
}
s.ensureHandlers()
return s.comp.executor.executePaymentPlan(ctx, store, payment, nil)
}
func (s *Service) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 {
return nil
}
s.ensureHandlers()
return s.comp.executor.releasePaymentHold(ctx, store, payment)
}

View File

@@ -0,0 +1,70 @@
package plan
import (
"context"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
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 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)
}
// Builder constructs ordered payment plans from intents, quotes, and routing policy.
type Builder interface {
Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
}
type SendDirection = sendDirection
const (
SendDirectionAny SendDirection = sendDirectionAny
SendDirectionOut SendDirection = sendDirectionOut
SendDirectionIn SendDirection = sendDirectionIn
)
func NewDefaultBuilder(logger mlogger.Logger) Builder {
return newDefaultBuilder(logger)
}
func RailFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
return railFromEndpoint(endpoint, attrs, isSource)
}
func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
return resolveRouteNetwork(attrs, sourceNetwork, destNetwork)
}
func SelectTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
return selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
}
func SendDirectionForRail(rail model.Rail) SendDirection {
return sendDirectionForRail(rail)
}
func IsGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir SendDirection, amount decimal.Decimal) error {
return isGatewayEligible(gw, rail, network, currency, action, sendDirection(dir), amount)
}
func ParseRailValue(value string) model.Rail {
return parseRailValue(value)
}
func NetworkFromEndpoint(endpoint model.PaymentEndpoint) string {
return networkFromEndpoint(endpoint)
}

View File

@@ -0,0 +1,365 @@
package plan
import (
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
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"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
type moneyGetter interface {
GetAmount() string
GetCurrency() string
}
func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money {
if input == nil {
return nil
}
return &paymenttypes.Money{Currency: input.GetCurrency(), Amount: input.GetAmount()}
}
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 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] = strings.TrimSpace(v)
}
return clone
}
func attributeLookup(attrs map[string]string, keys ...string) string {
if len(attrs) == 0 || len(keys) == 0 {
return ""
}
for _, key := range keys {
needle := strings.ToLower(strings.TrimSpace(key))
if needle == "" {
continue
}
for attrKey, value := range attrs {
if strings.EqualFold(strings.TrimSpace(attrKey), needle) {
if val := strings.TrimSpace(value); val != "" {
return val
}
}
}
}
return ""
}
func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) {
if m == nil {
return decimal.Zero, nil
}
return decimal.NewFromString(m.GetAmount())
}
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 cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
if input == nil {
return nil
}
return &moneyv1.Money{Currency: input.GetCurrency(), Amount: input.GetAmount()}
}
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 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 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 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 executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote {
if quote != nil {
return quote
}
if payment != nil && payment.LastQuote != nil {
return &orchestratorv1.PaymentQuote{
DebitAmount: protoMoney(payment.LastQuote.DebitAmount),
DebitSettlementAmount: protoMoney(payment.LastQuote.DebitSettlementAmount),
ExpectedSettlementAmount: protoMoney(payment.LastQuote.ExpectedSettlementAmount),
ExpectedFeeTotal: protoMoney(payment.LastQuote.ExpectedFeeTotal),
FeeLines: feeLinesToProto(payment.LastQuote.FeeLines),
FxQuote: fxQuoteToProto(payment.LastQuote.FXQuote),
QuoteRef: strings.TrimSpace(payment.LastQuote.QuoteRef),
}
}
return &orchestratorv1.PaymentQuote{}
}
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 cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) {
if payment == nil {
return nil, merrors.InvalidArgument("payment is required")
}
amount := cloneMoney(payment.Intent.Amount)
if payment.LastQuote != nil {
settlement := payment.LastQuote.ExpectedSettlementAmount
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
amount = cloneMoney(settlement)
}
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("card payout: amount is required")
}
return amount, nil
}

View File

@@ -1,4 +1,4 @@
package orchestrator
package plan
import (
"context"
@@ -11,17 +11,17 @@ import (
"go.uber.org/zap"
)
type defaultPlanBuilder struct {
type defaultBuilder struct {
logger mlogger.Logger
}
func newDefaultPlanBuilder(logger mlogger.Logger) *defaultPlanBuilder {
return &defaultPlanBuilder{
func newDefaultBuilder(logger mlogger.Logger) *defaultBuilder {
return &defaultBuilder{
logger: logger.Named("plan_builder"),
}
}
func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
func (b *defaultBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
if payment == nil {
return nil, merrors.InvalidArgument("plan builder: payment is required")
}

View File

@@ -1,12 +1,12 @@
package orchestrator
package plan
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"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/mutil/mzap"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
@@ -14,7 +14,7 @@ import (
"go.uber.org/zap"
)
func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) {
func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) {
if template == nil {
return nil, merrors.InvalidArgument("plan builder: plan template is required")
}
@@ -136,8 +136,8 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
CommitPolicy: policy,
CommitAfter: cloneStringList(tpl.CommitAfter),
Amount: cloneMoney(amount),
FromRole: cloneAccountRole(tpl.FromRole),
ToRole: cloneAccountRole(tpl.ToRole),
FromRole: shared.CloneAccountRole(tpl.FromRole),
ToRole: shared.CloneAccountRole(tpl.ToRole),
}
needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm
@@ -353,14 +353,6 @@ func observeAmountForRail(rail model.Rail, source, settlement, payout *paymentty
return source
}
func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
if role == nil {
return nil
}
cloned := *role
return &cloned
}
func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote) (*paymenttypes.Money, error) {
if sourceAmount == nil {
return nil, merrors.InvalidArgument("plan builder: source amount is required")

View File

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

View File

@@ -1,4 +1,4 @@
package orchestrator
package quotation
import (
"context"
@@ -8,13 +8,14 @@ import (
"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)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error)
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) 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
}
@@ -30,12 +31,12 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
return e.svc.buildPaymentQuote(ctx, orgRef, req)
}
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
return e.svc.resolvePaymentQuote(ctx, in)
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) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
return e.svc.executePayment(ctx, store, payment, 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 {
@@ -57,41 +58,13 @@ func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paym
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
return &quotePaymentCommand{
engine: f.engine,
logger: f.logger.Named("quote_payment"),
logger: f.logger.Named("quote.payment"),
}
}
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
return &quotePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("quote_payments"),
}
}
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
return &initiatePaymentCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payment"),
}
}
func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand {
return &initiatePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payments"),
}
}
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
return &cancelPaymentCommand{
engine: f.engine,
logger: f.logger.Named("cancel_payment"),
}
}
func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand {
return &initiateConversionCommand{
engine: f.engine,
logger: f.logger.Named("initiate_conversion"),
logger: f.logger.Named("quote.payments"),
}
}

View File

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

View File

@@ -1,8 +1,7 @@
package orchestrator
package quotation
import (
"strings"
"time"
"github.com/tech/sendico/payments/storage/model"
chainasset "github.com/tech/sendico/pkg/chain"
@@ -10,12 +9,10 @@ import (
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"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/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"
"google.golang.org/protobuf/types/known/timestamppb"
)
func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
@@ -123,46 +120,6 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
}
}
func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
if src == nil {
return nil
}
payment := &orchestratorv1.Payment{
PaymentRef: src.PaymentRef,
IdempotencyKey: src.IdempotencyKey,
Intent: protoIntentFromModel(src.Intent),
State: protoStateFromModel(src.State),
FailureCode: protoFailureFromModel(src.FailureCode),
FailureReason: src.FailureReason,
LastQuote: modelQuoteToProto(src.LastQuote),
Execution: protoExecutionFromModel(src.Execution),
ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan),
PaymentPlan: protoPaymentPlanFromModel(src.PaymentPlan),
Metadata: cloneMetadata(src.Metadata),
}
if src.CardPayout != nil {
payment.CardPayout = &orchestratorv1.CardPayout{
PayoutRef: src.CardPayout.PayoutRef,
ProviderPaymentId: src.CardPayout.ProviderPaymentID,
Status: src.CardPayout.Status,
FailureReason: src.CardPayout.FailureReason,
CardCountry: src.CardPayout.CardCountry,
MaskedPan: src.CardPayout.MaskedPan,
ProviderCode: src.CardPayout.ProviderCode,
GatewayReference: src.CardPayout.GatewayReference,
}
}
if src.CreatedAt.IsZero() {
payment.CreatedAt = timestamppb.New(time.Now().UTC())
} else {
payment.CreatedAt = timestamppb.New(src.CreatedAt.UTC())
}
if src.UpdatedAt != (time.Time{}) {
payment.UpdatedAt = timestamppb.New(src.UpdatedAt.UTC())
}
return payment
}
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
intent := &orchestratorv1.PaymentIntent{
Ref: src.Ref,
@@ -291,99 +248,6 @@ func protoFXIntentFromModel(src *model.FXIntent) *orchestratorv1.FXIntent {
}
}
func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.ExecutionRefs {
if src == nil {
return nil
}
return &orchestratorv1.ExecutionRefs{
DebitEntryRef: src.DebitEntryRef,
CreditEntryRef: src.CreditEntryRef,
FxEntryRef: src.FXEntryRef,
ChainTransferRef: src.ChainTransferRef,
CardPayoutRef: src.CardPayoutRef,
FeeTransferRef: src.FeeTransferRef,
}
}
func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.ExecutionStep {
if src == nil {
return nil
}
return &orchestratorv1.ExecutionStep{
Code: src.Code,
Description: src.Description,
Amount: protoMoney(src.Amount),
NetworkFee: protoMoney(src.NetworkFee),
SourceWalletRef: src.SourceWalletRef,
DestinationRef: src.DestinationRef,
TransferRef: src.TransferRef,
Metadata: cloneMetadata(src.Metadata),
OperationRef: src.OperationRef,
}
}
func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.ExecutionPlan {
if src == nil {
return nil
}
steps := make([]*orchestratorv1.ExecutionStep, 0, len(src.Steps))
for _, step := range src.Steps {
if protoStep := protoExecutionStepFromModel(step); protoStep != nil {
steps = append(steps, protoStep)
}
}
if len(steps) == 0 {
steps = nil
}
return &orchestratorv1.ExecutionPlan{
Steps: steps,
TotalNetworkFee: protoMoney(src.TotalNetworkFee),
}
}
func protoPaymentStepFromModel(src *model.PaymentStep) *orchestratorv1.PaymentStep {
if src == nil {
return nil
}
return &orchestratorv1.PaymentStep{
Rail: protoRailFromModel(src.Rail),
GatewayId: strings.TrimSpace(src.GatewayID),
Action: protoRailOperationFromModel(src.Action),
Amount: protoMoney(src.Amount),
StepId: strings.TrimSpace(src.StepID),
InstanceId: strings.TrimSpace(src.InstanceID),
DependsOn: cloneStringList(src.DependsOn),
CommitPolicy: strings.TrimSpace(string(src.CommitPolicy)),
CommitAfter: cloneStringList(src.CommitAfter),
}
}
func protoPaymentPlanFromModel(src *model.PaymentPlan) *orchestratorv1.PaymentPlan {
if src == nil {
return nil
}
steps := make([]*orchestratorv1.PaymentStep, 0, len(src.Steps))
for _, step := range src.Steps {
if protoStep := protoPaymentStepFromModel(step); protoStep != nil {
steps = append(steps, protoStep)
}
}
if len(steps) == 0 {
steps = nil
}
plan := &orchestratorv1.PaymentPlan{
Id: strings.TrimSpace(src.ID),
Steps: steps,
IdempotencyKey: strings.TrimSpace(src.IdempotencyKey),
FxQuote: fxQuoteToProto(src.FXQuote),
Fees: feeLinesToProto(src.Fees),
}
if !src.CreatedAt.IsZero() {
plan.CreatedAt = timestamppb.New(src.CreatedAt.UTC())
}
return plan
}
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote {
if src == nil {
return nil
@@ -401,28 +265,6 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
}
}
func filterFromProto(req *orchestratorv1.ListPaymentsRequest) *model.PaymentFilter {
if req == nil {
return &model.PaymentFilter{}
}
filter := &model.PaymentFilter{
SourceRef: strings.TrimSpace(req.GetSourceRef()),
DestinationRef: strings.TrimSpace(req.GetDestinationRef()),
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
}
if req.GetPage() != nil {
filter.Cursor = strings.TrimSpace(req.GetPage().GetCursor())
filter.Limit = req.GetPage().GetLimit()
}
if len(req.GetFilterStates()) > 0 {
filter.States = make([]model.PaymentState, 0, len(req.GetFilterStates()))
for _, st := range req.GetFilterStates() {
filter.States = append(filter.States, modelStateFromProto(st))
}
}
return filter
}
func protoKindFromModel(kind model.PaymentKind) orchestratorv1.PaymentKind {
switch kind {
case model.PaymentKindPayout:
@@ -449,109 +291,6 @@ func modelKindFromProto(kind orchestratorv1.PaymentKind) model.PaymentKind {
}
}
func protoRailFromModel(rail model.Rail) gatewayv1.Rail {
switch strings.ToUpper(strings.TrimSpace(string(rail))) {
case string(model.RailCrypto):
return gatewayv1.Rail_RAIL_CRYPTO
case string(model.RailProviderSettlement):
return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT
case string(model.RailLedger):
return gatewayv1.Rail_RAIL_LEDGER
case string(model.RailCardPayout):
return gatewayv1.Rail_RAIL_CARD_PAYOUT
case string(model.RailFiatOnRamp):
return gatewayv1.Rail_RAIL_FIAT_ONRAMP
default:
return gatewayv1.Rail_RAIL_UNSPECIFIED
}
}
func protoRailOperationFromModel(action model.RailOperation) gatewayv1.RailOperation {
switch strings.ToUpper(strings.TrimSpace(string(action))) {
case string(model.RailOperationDebit):
return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT
case string(model.RailOperationCredit):
return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT
case string(model.RailOperationExternalDebit):
return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT
case string(model.RailOperationExternalCredit):
return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT
case string(model.RailOperationMove):
return gatewayv1.RailOperation_RAIL_OPERATION_MOVE
case string(model.RailOperationSend):
return gatewayv1.RailOperation_RAIL_OPERATION_SEND
case string(model.RailOperationFee):
return gatewayv1.RailOperation_RAIL_OPERATION_FEE
case string(model.RailOperationObserveConfirm):
return gatewayv1.RailOperation_RAIL_OPERATION_OBSERVE_CONFIRM
case string(model.RailOperationFXConvert):
return gatewayv1.RailOperation_RAIL_OPERATION_FX_CONVERT
case string(model.RailOperationBlock):
return gatewayv1.RailOperation_RAIL_OPERATION_BLOCK
case string(model.RailOperationRelease):
return gatewayv1.RailOperation_RAIL_OPERATION_RELEASE
default:
return gatewayv1.RailOperation_RAIL_OPERATION_UNSPECIFIED
}
}
func protoStateFromModel(state model.PaymentState) orchestratorv1.PaymentState {
switch state {
case model.PaymentStateAccepted:
return orchestratorv1.PaymentState_PAYMENT_STATE_ACCEPTED
case model.PaymentStateFundsReserved:
return orchestratorv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED
case model.PaymentStateSubmitted:
return orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED
case model.PaymentStateSettled:
return orchestratorv1.PaymentState_PAYMENT_STATE_SETTLED
case model.PaymentStateFailed:
return orchestratorv1.PaymentState_PAYMENT_STATE_FAILED
case model.PaymentStateCancelled:
return orchestratorv1.PaymentState_PAYMENT_STATE_CANCELLED
default:
return orchestratorv1.PaymentState_PAYMENT_STATE_UNSPECIFIED
}
}
func modelStateFromProto(state orchestratorv1.PaymentState) model.PaymentState {
switch state {
case orchestratorv1.PaymentState_PAYMENT_STATE_ACCEPTED:
return model.PaymentStateAccepted
case orchestratorv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED:
return model.PaymentStateFundsReserved
case orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED:
return model.PaymentStateSubmitted
case orchestratorv1.PaymentState_PAYMENT_STATE_SETTLED:
return model.PaymentStateSettled
case orchestratorv1.PaymentState_PAYMENT_STATE_FAILED:
return model.PaymentStateFailed
case orchestratorv1.PaymentState_PAYMENT_STATE_CANCELLED:
return model.PaymentStateCancelled
default:
return model.PaymentStateUnspecified
}
}
func protoFailureFromModel(code model.PaymentFailureCode) orchestratorv1.PaymentFailureCode {
switch code {
case model.PaymentFailureCodeBalance:
return orchestratorv1.PaymentFailureCode_FAILURE_BALANCE
case model.PaymentFailureCodeLedger:
return orchestratorv1.PaymentFailureCode_FAILURE_LEDGER
case model.PaymentFailureCodeFX:
return orchestratorv1.PaymentFailureCode_FAILURE_FX
case model.PaymentFailureCodeChain:
return orchestratorv1.PaymentFailureCode_FAILURE_CHAIN
case model.PaymentFailureCodeFees:
return orchestratorv1.PaymentFailureCode_FAILURE_FEES
case model.PaymentFailureCodePolicy:
return orchestratorv1.PaymentFailureCode_FAILURE_POLICY
default:
return orchestratorv1.PaymentFailureCode_FAILURE_UNSPECIFIED
}
}
func settlementModeFromProto(mode orchestratorv1.SettlementMode) model.SettlementMode {
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE:

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

@@ -1,4 +1,4 @@
package orchestrator
package quotation
import (
"context"
@@ -7,6 +7,7 @@ import (
"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"
@@ -55,7 +56,7 @@ func (s *Service) resolveChainGatewayClient(ctx context.Context, network string,
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 sendDirection) (*model.GatewayInstanceDescriptor, error) {
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")
}

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

@@ -1,4 +1,4 @@
package orchestrator
package quotation
import (
"strings"
@@ -6,9 +6,7 @@ import (
"github.com/shopspring/decimal"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/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"
@@ -359,45 +357,6 @@ func setFeeLineWalletRef(lines []*feesv1.DerivedPostingLine, walletRef, walletTy
}
}
func ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.PostingLine {
if len(lines) == 0 {
return nil
}
charges := make([]*ledgerv1.PostingLine, 0, len(lines))
for _, line := range lines {
if line == nil || isWalletTargetFeeLine(line) || strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
continue
}
money := cloneProtoMoney(line.GetMoney())
if money == nil {
continue
}
charges = append(charges, &ledgerv1.PostingLine{
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
Money: money,
LineType: ledgerLineTypeFromAccounting(line.GetLineType()),
})
}
if len(charges) == 0 {
return nil
}
return charges
}
func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv1.LineType {
switch lineType {
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
return ledgerv1.LineType_LINE_SPREAD
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
return ledgerv1.LineType_LINE_REVERSAL
case accountingv1.PostingLineType_POSTING_LINE_FEE,
accountingv1.PostingLineType_POSTING_LINE_TAX:
return ledgerv1.LineType_LINE_FEE
default:
return ledgerv1.LineType_LINE_MAIN
}
}
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
expiry := time.Time{}
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
@@ -430,34 +389,3 @@ func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []
}
return lines
}
func moneyEquals(a, b moneyGetter) bool {
if a == nil || b == nil {
return false
}
if !strings.EqualFold(a.GetCurrency(), b.GetCurrency()) {
return false
}
return strings.TrimSpace(a.GetAmount()) == strings.TrimSpace(b.GetAmount())
}
func conversionAmountFromMetadata(meta map[string]string, fx *orchestratorv1.FXIntent) (*moneyv1.Money, error) {
if meta == nil {
meta = map[string]string{}
}
amount := strings.TrimSpace(meta["amount"])
if amount == "" {
return nil, merrors.InvalidArgument("conversion amount metadata is required")
}
currency := strings.TrimSpace(meta["currency"])
if currency == "" && fx != nil && fx.GetPair() != nil {
currency = strings.TrimSpace(fx.GetPair().GetBase())
}
if currency == "" {
return nil, merrors.InvalidArgument("conversion currency metadata is required")
}
return &moneyv1.Money{
Currency: currency,
Amount: amount,
}, nil
}

View File

@@ -1,16 +1,14 @@
package orchestrator
package quotation
import (
"context"
"strings"
"time"
"github.com/tech/sendico/payments/storage/model"
"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"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
@@ -107,26 +105,3 @@ func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIn
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
}
}
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
return model.PaymentStateFundsReserved
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
return model.PaymentStateSubmitted
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
return model.PaymentStateSettled
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return model.PaymentStateFailed
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
return model.PaymentStateCancelled
default:
return model.PaymentStateUnspecified
}
}

View File

@@ -1,4 +1,4 @@
package orchestrator
package quotation
import (
"errors"

View File

@@ -1,4 +1,4 @@
package orchestrator
package quotation
import paymenttypes "github.com/tech/sendico/pkg/payments/types"

View File

@@ -1,4 +1,4 @@
package orchestrator
package quotation
import (
"context"
@@ -6,10 +6,8 @@ import (
"strings"
"time"
"github.com/shopspring/decimal"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
@@ -18,7 +16,6 @@ import (
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/payments/rail"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"go.uber.org/zap"
)
// Option configures service dependencies.
@@ -67,139 +64,6 @@ type railGatewayDependency struct {
logger mlogger.Logger
}
func (g railGatewayDependency) available() bool {
return len(g.byID) > 0 || len(g.byRail) > 0 || (g.registry != nil && (g.chainResolver != nil || g.providerResolver != nil))
}
func (g railGatewayDependency) resolve(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) {
if step == nil {
return nil, merrors.InvalidArgument("rail gateway: step is required")
}
if id := strings.TrimSpace(step.GatewayID); id != "" {
if gw, ok := g.byID[id]; ok {
return gw, nil
}
return g.resolveDynamic(ctx, step)
}
if len(g.byRail) == 0 {
return g.resolveDynamic(ctx, step)
}
list := g.byRail[step.Rail]
if len(list) == 0 {
return g.resolveDynamic(ctx, step)
}
return list[0], nil
}
func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) {
if g.registry == nil {
return nil, merrors.InvalidArgument("rail gateway: registry is required")
}
if g.chainResolver == nil && g.providerResolver == nil {
return nil, merrors.InvalidArgument("rail gateway: gateway resolver is required")
}
items, err := g.registry.List(ctx)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, merrors.InvalidArgument("rail gateway: no gateway instances available")
}
currency := ""
amount := decimal.Zero
if step.Amount != nil && strings.TrimSpace(step.Amount.GetAmount()) != "" {
value, err := decimalFromMoney(step.Amount)
if err != nil {
return nil, err
}
amount = value
currency = strings.ToUpper(strings.TrimSpace(step.Amount.GetCurrency()))
}
candidates := make([]*model.GatewayInstanceDescriptor, 0)
var lastErr error
for _, entry := range items {
if entry == nil || !entry.IsEnabled {
continue
}
if entry.Rail != step.Rail {
continue
}
if step.GatewayID != "" && entry.ID != step.GatewayID {
continue
}
if step.InstanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(step.InstanceID)) {
continue
}
if step.Action != model.RailOperationUnspecified {
if err := isGatewayEligible(entry, step.Rail, "", currency, step.Action, sendDirectionForRail(step.Rail), amount); err != nil {
lastErr = err
continue
}
}
candidates = append(candidates, entry)
}
if len(candidates) == 0 {
if lastErr != nil {
return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail: " + lastErr.Error())
}
return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail")
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].ID < candidates[j].ID
})
entry := candidates[0]
invokeURI := strings.TrimSpace(entry.InvokeURI)
if invokeURI == "" {
return nil, merrors.InvalidArgument("rail gateway: invoke uri is required")
}
cfg := chainclient.RailGatewayConfig{
Rail: string(entry.Rail),
Network: entry.Network,
Capabilities: rail.RailCapabilities{
CanPayIn: entry.Capabilities.CanPayIn,
CanPayOut: entry.Capabilities.CanPayOut,
CanReadBalance: entry.Capabilities.CanReadBalance,
CanSendFee: entry.Capabilities.CanSendFee,
RequiresObserveConfirm: entry.Capabilities.RequiresObserveConfirm,
CanBlock: entry.Capabilities.CanBlock,
CanRelease: entry.Capabilities.CanRelease,
},
}
g.logger.Info("Rail gateway resolved",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.String("action", string(step.Action)),
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))
switch entry.Rail {
case model.RailProviderSettlement:
if g.providerResolver == nil {
return nil, merrors.InvalidArgument("rail gateway: provider settlement resolver required")
}
client, err := g.providerResolver.Resolve(ctx, invokeURI)
if err != nil {
return nil, err
}
return NewProviderSettlementGateway(client, cfg), nil
default:
if g.chainResolver == nil {
return nil, merrors.InvalidArgument("rail gateway: chain gateway resolver required")
}
client, err := g.chainResolver.Resolve(ctx, invokeURI)
if err != nil {
return nil, err
}
return chainclient.NewRailGateway(client, cfg), nil
}
}
type oracleDependency struct {
client oracleclient.Client
}
@@ -214,20 +78,6 @@ func (o oracleDependency) available() bool {
return true
}
type mntxDependency struct {
client mntxclient.Client
}
func (m mntxDependency) available() bool {
if m.client == nil {
return false
}
if checker, ok := m.client.(interface{ Available() bool }); ok {
return checker.Available()
}
return true
}
type providerGatewayDependency struct {
resolver ChainGatewayResolver
}
@@ -339,13 +189,6 @@ func WithOracleClient(client oracleclient.Client) Option {
}
}
// WithMntxGateway wires the Monetix gateway client.
func WithMntxGateway(client mntxclient.Client) Option {
return func(s *Service) {
s.deps.mntx = mntxDependency{client: client}
}
}
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
return func(s *Service) {

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

@@ -1,10 +1,10 @@
package orchestrator
package quotation
import (
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/mlogger"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
"google.golang.org/grpc"
)
@@ -29,7 +29,7 @@ func (s *QuotationService) Register(router routers.GRPC) error {
return nil
}
return router.Register(func(reg grpc.ServiceRegistrar) {
orchestratorv1.RegisterPaymentQuotationServer(reg, s.quote)
quotationv1.RegisterQuotationServiceServer(reg, s.quote)
})
}

View File

@@ -1,24 +1,24 @@
package orchestrator
package quotation
import (
"context"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
)
type quotationService struct {
svc *Service
orchestratorv1.UnimplementedPaymentQuotationServer
quotationv1.UnimplementedQuotationServiceServer
}
func newQuotationService(svc *Service) *quotationService {
return &quotationService{svc: svc}
}
func (s *quotationService) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
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 *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
func (s *quotationService) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) {
return s.svc.QuotePayments(ctx, req)
}

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

@@ -1,4 +1,4 @@
package orchestrator
package quotation
import (
"context"
@@ -7,10 +7,7 @@ import (
"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"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/protobuf/proto"
@@ -31,22 +28,6 @@ func validateMetaAndOrgRef(meta *orchestratorv1.RequestMeta) (string, bson.Objec
return orgRef, orgID, nil
}
func requireIdempotencyKey(k string) (string, error) {
key := strings.TrimSpace(k)
if key == "" {
return "", merrors.InvalidArgument("idempotency_key is required")
}
return key, nil
}
func requirePaymentRef(ref string) (string, error) {
val := strings.TrimSpace(ref)
if val == "" {
return "", merrors.InvalidArgument("payment_ref is required")
}
return val, nil
}
func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error {
if intent == nil {
return merrors.InvalidArgument("intent is required")
@@ -60,17 +41,6 @@ func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error {
return nil
}
func ensurePaymentsStore(repo storage.Repository) (storage.PaymentsStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Payments()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) {
if repo == nil {
return nil, errStorageUnavailable
@@ -82,14 +52,6 @@ func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) {
return store, nil
}
func getPaymentByIdempotencyKey(ctx context.Context, store storage.PaymentsStore, orgID bson.ObjectID, key string) (*model.Payment, error) {
payment, err := store.GetByIdempotencyKey(ctx, orgID, key)
if err != nil {
return nil, err
}
return payment, nil
}
type quoteResolutionInput struct {
OrgRef string
OrgID bson.ObjectID
@@ -106,39 +68,43 @@ type quoteResolutionError struct {
func (e quoteResolutionError) Error() string { return e.err.Error() }
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, 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, err
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, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
}
return nil, nil, err
return nil, nil, nil, err
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
}
intent, err := recordIntentFromQuote(record)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match 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, err
return nil, nil, nil, err
}
quote.QuoteRef = ref
return quote, intent, nil
plan, err := recordPlanFromQuote(record)
if err != nil {
return nil, nil, nil, err
}
return quote, intent, plan, nil
}
if in.Intent == nil {
return nil, nil, merrors.InvalidArgument("intent is required")
return nil, nil, nil, merrors.InvalidArgument("intent is required")
}
req := &orchestratorv1.QuotePaymentRequest{
Meta: in.Meta,
@@ -148,9 +114,13 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
}
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
if err != nil {
return nil, nil, err
return nil, nil, nil, err
}
return quote, in.Intent, nil
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) {
@@ -185,6 +155,22 @@ func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.Pay
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())
@@ -198,10 +184,3 @@ func newPayment(orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idemp
entity.Normalize()
return entity
}
func paymentNotFoundResponder[T any](svc mservice.Type, logger mlogger.Logger, err error) gsresponse.Responder[T] {
if errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.NotFound[T](logger, svc, err)
}
return gsresponse.Auto[T](logger, svc, err)
}

View File

@@ -0,0 +1,11 @@
package shared
import "github.com/tech/sendico/pkg/model/account_role"
func CloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
if role == nil {
return nil
}
cloned := *role
return &cloned
}