unified gateway interface

This commit is contained in:
Stephan D
2025-12-31 17:47:32 +01:00
parent 19b7b69bd8
commit 97ba7500dc
104 changed files with 8228 additions and 1742 deletions

View File

@@ -70,3 +70,15 @@ card_gateways:
fee_ledger_accounts:
monetix: "ledger:fees:monetix"
# gateway_instances:
# - id: "crypto-tron"
# rail: "CRYPTO"
# network: "TRON"
# currencies: ["USDT"]
# capabilities:
# can_pay_out: true
# can_send_fee: true
# limits:
# min_amount: "0"
# max_amount: "100000"

View File

@@ -11,13 +11,19 @@ import (
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/orchestrator/internal/appversion"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/payments/rail"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
@@ -32,24 +38,28 @@ type Imp struct {
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
feesConn *grpc.ClientConn
ledgerClient ledgerclient.Client
gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client
config *config
app *grpcapp.App[storage.Repository]
discoverySvc *discovery.RegistryService
discoveryReg *discovery.Registry
discoveryAnnouncer *discovery.Announcer
feesConn *grpc.ClientConn
ledgerClient ledgerclient.Client
gatewayClient chainclient.Client
mntxClient mntxclient.Client
oracleClient oracleclient.Client
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Mntx clientConfig `yaml:"mntx"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
*grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Mntx clientConfig `yaml:"mntx"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"`
}
type clientConfig struct {
@@ -65,6 +75,44 @@ type cardGatewayRouteConfig struct {
FeeWalletRef string `yaml:"fee_wallet_ref"`
}
type gatewayInstanceConfig struct {
ID string `yaml:"id"`
Rail string `yaml:"rail"`
Network string `yaml:"network"`
Currencies []string `yaml:"currencies"`
Capabilities gatewayCapabilitiesConfig `yaml:"capabilities"`
Limits limitsConfig `yaml:"limits"`
Version string `yaml:"version"`
IsEnabled *bool `yaml:"is_enabled"`
}
type gatewayCapabilitiesConfig struct {
CanPayIn bool `yaml:"can_pay_in"`
CanPayOut bool `yaml:"can_pay_out"`
CanReadBalance bool `yaml:"can_read_balance"`
CanSendFee bool `yaml:"can_send_fee"`
RequiresObserveConfirm bool `yaml:"requires_observe_confirm"`
}
type limitsConfig struct {
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
PerTxMaxFee string `yaml:"per_tx_max_fee"`
PerTxMinAmount string `yaml:"per_tx_min_amount"`
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
VolumeLimit map[string]string `yaml:"volume_limit"`
VelocityLimit map[string]int `yaml:"velocity_limit"`
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
}
type limitsOverrideCfg struct {
MaxVolume string `yaml:"max_volume"`
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
MaxFee string `yaml:"max_fee"`
MaxOps int `yaml:"max_ops"`
}
func (c clientConfig) address() string {
return strings.TrimSpace(c.Address)
}
@@ -92,6 +140,12 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
}
func (i *Imp) Shutdown() {
if i.discoveryAnnouncer != nil {
i.discoveryAnnouncer.Stop()
}
if i.discoverySvc != nil {
i.discoverySvc.Stop()
}
if i.app != nil {
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
@@ -126,6 +180,32 @@ func (i *Imp) Start() error {
}
i.config = cfg
if cfg.Messaging != nil && cfg.Messaging.Driver != "" {
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
if err != nil {
i.logger.Warn("Failed to initialise discovery broker", zap.Error(err))
} else {
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
registry := discovery.NewRegistry()
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.PaymentOrchestrator))
if err != nil {
i.logger.Warn("Failed to start discovery registry service", zap.Error(err))
} else {
svc.Start()
i.discoverySvc = svc
i.discoveryReg = registry
i.logger.Info("Discovery registry service started")
}
announce := discovery.Announcement{
Service: "PAYMENTS_ORCHESTRATOR",
Operations: []string{"payment.quote", "payment.initiate"},
Version: appversion.Create().Short(),
}
i.discoveryAnnouncer = discovery.NewAnnouncer(i.logger, producer, string(mservice.PaymentOrchestrator), announce)
i.discoveryAnnouncer.Start()
}
}
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
@@ -166,6 +246,9 @@ func (i *Imp) Start() error {
if gatewayClient != nil {
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
}
if railGateways := buildRailGateways(gatewayClient, cfg.GatewayInstances); len(railGateways) > 0 {
opts = append(opts, orchestrator.WithRailGateways(railGateways))
}
if mntxClient != nil {
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
}
@@ -178,6 +261,9 @@ func (i *Imp) Start() error {
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
}
if registry := buildGatewayRegistry(i.logger, mntxClient, cfg.GatewayInstances, i.discoveryReg); registry != nil {
opts = append(opts, orchestrator.WithGatewayRegistry(registry))
}
return orchestrator.NewService(logger, repo, opts...), nil
}
@@ -382,3 +468,178 @@ func buildFeeLedgerAccounts(src map[string]string) map[string]string {
}
return result
}
func buildGatewayRegistry(logger mlogger.Logger, mntxClient mntxclient.Client, src []gatewayInstanceConfig, registry *discovery.Registry) orchestrator.GatewayRegistry {
static := buildGatewayInstances(logger, src)
staticRegistry := orchestrator.NewGatewayRegistry(logger, mntxClient, static)
discoveryRegistry := orchestrator.NewDiscoveryGatewayRegistry(logger, registry)
return orchestrator.NewCompositeGatewayRegistry(logger, staticRegistry, discoveryRegistry)
}
func buildRailGateways(chainClient chainclient.Client, src []gatewayInstanceConfig) map[string]rail.RailGateway {
if chainClient == nil || len(src) == 0 {
return nil
}
instances := buildGatewayInstances(nil, src)
if len(instances) == 0 {
return nil
}
result := map[string]rail.RailGateway{}
for _, inst := range instances {
if inst == nil || !inst.IsEnabled {
continue
}
if inst.Rail != model.RailCrypto {
continue
}
cfg := chainclient.RailGatewayConfig{
Rail: string(inst.Rail),
Network: inst.Network,
Capabilities: rail.RailCapabilities{
CanPayIn: inst.Capabilities.CanPayIn,
CanPayOut: inst.Capabilities.CanPayOut,
CanReadBalance: inst.Capabilities.CanReadBalance,
CanSendFee: inst.Capabilities.CanSendFee,
RequiresObserveConfirm: inst.Capabilities.RequiresObserveConfirm,
},
}
result[inst.ID] = chainclient.NewRailGateway(chainClient, cfg)
}
if len(result) == 0 {
return nil
}
return result
}
func buildGatewayInstances(logger mlogger.Logger, src []gatewayInstanceConfig) []*model.GatewayInstanceDescriptor {
if len(src) == 0 {
return nil
}
if logger != nil {
logger = logger.Named("gateway_instances")
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(src))
for _, cfg := range src {
id := strings.TrimSpace(cfg.ID)
if id == "" {
if logger != nil {
logger.Warn("Gateway instance skipped: missing id")
}
continue
}
rail := parseRail(cfg.Rail)
if rail == model.RailUnspecified {
if logger != nil {
logger.Warn("Gateway instance skipped: invalid rail", zap.String("id", id), zap.String("rail", cfg.Rail))
}
continue
}
enabled := true
if cfg.IsEnabled != nil {
enabled = *cfg.IsEnabled
}
result = append(result, &model.GatewayInstanceDescriptor{
ID: id,
Rail: rail,
Network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
Currencies: normalizeCurrencies(cfg.Currencies),
Capabilities: model.RailCapabilities{
CanPayIn: cfg.Capabilities.CanPayIn,
CanPayOut: cfg.Capabilities.CanPayOut,
CanReadBalance: cfg.Capabilities.CanReadBalance,
CanSendFee: cfg.Capabilities.CanSendFee,
RequiresObserveConfirm: cfg.Capabilities.RequiresObserveConfirm,
},
Limits: buildGatewayLimits(cfg.Limits),
Version: strings.TrimSpace(cfg.Version),
IsEnabled: enabled,
})
}
return result
}
func parseRail(value string) model.Rail {
switch strings.ToUpper(strings.TrimSpace(value)) {
case string(model.RailCrypto):
return model.RailCrypto
case string(model.RailProviderSettlement):
return model.RailProviderSettlement
case string(model.RailLedger):
return model.RailLedger
case string(model.RailCardPayout):
return model.RailCardPayout
case string(model.RailFiatOnRamp):
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func normalizeCurrencies(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
clean := strings.ToUpper(strings.TrimSpace(value))
if clean == "" || seen[clean] {
continue
}
seen[clean] = true
result = append(result, clean)
}
return result
}
func buildGatewayLimits(cfg limitsConfig) model.Limits {
limits := model.Limits{
MinAmount: strings.TrimSpace(cfg.MinAmount),
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
}
if len(cfg.VolumeLimit) > 0 {
limits.VolumeLimit = map[string]string{}
for key, value := range cfg.VolumeLimit {
bucket := strings.TrimSpace(key)
amount := strings.TrimSpace(value)
if bucket == "" || amount == "" {
continue
}
limits.VolumeLimit[bucket] = amount
}
}
if len(cfg.VelocityLimit) > 0 {
limits.VelocityLimit = map[string]int{}
for key, value := range cfg.VelocityLimit {
bucket := strings.TrimSpace(key)
if bucket == "" {
continue
}
limits.VelocityLimit[bucket] = value
}
}
if len(cfg.CurrencyLimits) > 0 {
limits.CurrencyLimits = map[string]model.LimitsOverride{}
for key, override := range cfg.CurrencyLimits {
currency := strings.ToUpper(strings.TrimSpace(key))
if currency == "" {
continue
}
limits.CurrencyLimits[currency] = model.LimitsOverride{
MaxVolume: strings.TrimSpace(override.MaxVolume),
MinAmount: strings.TrimSpace(override.MinAmount),
MaxAmount: strings.TrimSpace(override.MaxAmount),
MaxFee: strings.TrimSpace(override.MaxFee),
MaxOps: override.MaxOps,
}
}
}
return limits
}

View File

@@ -1,702 +0,0 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
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"
)
const (
defaultCardGateway = "monetix"
stepCodeGasTopUp = "gas_top_up"
stepCodeFundingTransfer = "funding_transfer"
stepCodeCardPayout = "card_payout"
stepCodeFeeTransfer = "fee_transfer"
)
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
}
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")
}
if !s.deps.gateway.available() {
s.logger.Warn("card funding aborted: chain gateway unavailable")
return merrors.InvalidArgument("card funding: chain gateway unavailable")
}
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)
amount := cloneMoney(intent.Amount)
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: amount is required")
}
payoutAmount, err := cardPayoutAmount(payment)
if err != nil {
return err
}
feeMoney := (*moneyv1.Money)(nil)
if quote != nil {
feeMoney = quote.GetExpectedFeeTotal()
}
if feeMoney == nil && payment.LastQuote != nil {
feeMoney = payment.LastQuote.ExpectedFeeTotal
}
feeDecimal := decimal.Zero
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card funding: fee currency is required")
}
feeDecimal, err = decimalFromMoney(feeMoney)
if err != nil {
return err
}
}
feeRequired := feeDecimal.IsPositive()
fundingDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
}
fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, amount)
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, sourceWalletRef, feeDest, feeMoney)
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 := s.deps.gateway.client.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, feeWalletRef, topUpDest, topUpMoney)
if err != nil {
return err
}
}
}
plan := ensureExecutionPlan(payment)
var gasStep *model.ExecutionStep
if topUpMoney != nil && topUpPositive {
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
gasStep.Description = "Top up native gas from fee wallet"
gasStep.Amount = cloneMoney(topUpMoney)
gasStep.NetworkFee = cloneMoney(topUpFee)
gasStep.SourceWalletRef = feeWalletRef
gasStep.DestinationRef = sourceWalletRef
}
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
fundStep.Description = "Transfer payout amount to card funding wallet"
fundStep.Amount = cloneMoney(amount)
fundStep.NetworkFee = cloneMoney(fundingFee)
fundStep.SourceWalletRef = sourceWalletRef
fundStep.DestinationRef = fundingAddress
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
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
}
}
if feeRequired {
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
step.Description = "Transfer fee to fee wallet"
step.Amount = cloneMoney(feeMoney)
step.NetworkFee = cloneMoney(feeTransferFee)
step.SourceWalletRef = sourceWalletRef
step.DestinationRef = feeWalletRef
}
updateExecutionPlanTotalNetworkFee(plan)
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
if topUpMoney != nil && topUpPositive {
ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: feeWalletRef,
TargetWalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: 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 = cloneMoney(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, feeWalletRef, topUpDest, actual)
if err != nil {
return err
}
gasStep.NetworkFee = cloneMoney(topUpFee)
} else {
gasStep.Amount = nil
gasStep.NetworkFee = nil
}
}
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)
}
// Transfer payout amount to funding wallet.
fundReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: sourceWalletRef,
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
},
Amount: amount,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq)
if err != nil {
s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
fundStep.TransferRef = exec.ChainTransferRef
}
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
payment.Execution = exec
return nil
}
func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
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,
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,
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,
}
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)
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
}
updateExecutionPlanTotalNetworkFee(plan)
}
feeMoney := (*moneyv1.Money)(nil)
if payment.LastQuote != nil {
feeMoney = payment.LastQuote.ExpectedFeeTotal
}
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
return merrors.InvalidArgument("card payout: fee currency is required")
}
feeDecimal, err := decimalFromMoney(feeMoney)
if err != nil {
return err
}
if feeDecimal.IsPositive() {
if !s.deps.gateway.available() {
s.logger.Warn("card fee aborted: chain gateway unavailable")
return merrors.InvalidArgument("card payout: chain gateway unavailable")
}
sourceWallet := intent.Source.ManagedWallet
if sourceWallet == nil || strings.TrimSpace(sourceWallet.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card payout: source managed wallet is required")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
if feeWalletRef == "" {
return merrors.InvalidArgument("card payout: fee wallet ref is required when fee exists")
}
feeReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(sourceWallet.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
},
Amount: feeMoney,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
if feeErr != nil {
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
return feeErr
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
if plan != nil {
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
step.Description = "Transfer fee to fee wallet"
step.Amount = cloneMoney(feeMoney)
step.SourceWalletRef = strings.TrimSpace(sourceWallet.ManagedWalletRef)
step.DestinationRef = feeWalletRef
step.TransferRef = exec.FeeTransferRef
updateExecutionPlanTotalNetworkFee(plan)
}
}
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef))
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 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())
}
payment.State = mapMntxStatusToState(payout.GetStatus())
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
default:
// leave as-is for pending/unspecified
}
}
func cardPayoutAmount(payment *model.Payment) (*moneyv1.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
}
func (s *Service) estimateTransferNetworkFee(ctx context.Context, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) {
if !s.deps.gateway.available() {
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 := s.deps.gateway.client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
SourceWalletRef: sourceWalletRef,
Destination: destination,
Amount: cloneMoney(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 cloneMoney(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
}
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 = makeMoney(currency, total)
}

View File

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

View File

@@ -0,0 +1,353 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/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")
}
if !s.deps.gateway.available() {
s.logger.Warn("card funding aborted: chain gateway unavailable")
return merrors.InvalidArgument("card funding: chain gateway unavailable")
}
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)
fundingDest := &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
}
fundingFee, err := s.estimateTransferNetworkFee(ctx, 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, 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 := s.deps.gateway.client.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, 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, executionStepStatusPlanned)
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, executionStepStatusPlanned)
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, executionStepStatusPlanned)
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, executionStepStatusPlanned)
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 := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: feeWalletRef,
TargetWalletRef: sourceWalletRef,
EstimatedTotalFee: estimatedTotalFee,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: 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, feeWalletRef, topUpDest, actual)
if err != nil {
return err
}
gasStep.NetworkFee = moneyFromProto(topUpFee)
setExecutionStepStatus(gasStep, executionStepStatusSubmitted)
} else {
gasStep.Amount = nil
gasStep.NetworkFee = nil
gasStep.TransferRef = ""
setExecutionStepStatus(gasStep, executionStepStatusSkipped)
}
}
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 := s.deps.gateway.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: sourceWalletRef,
Destination: fundingDest,
Amount: cloneProtoMoney(intentAmountProto),
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
})
if err != nil {
return err
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
fundStep.TransferRef = exec.ChainTransferRef
}
setExecutionStepStatus(fundStep, executionStepStatusSubmitted)
updateExecutionPlanTotalNetworkFee(plan)
if feeRequired {
feeResp, err := s.deps.gateway.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
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),
ClientReference: 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, executionStepStatusSubmitted)
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, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) {
if !s.deps.gateway.available() {
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 := s.deps.gateway.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

@@ -0,0 +1,80 @@
package orchestrator
import (
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/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

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

@@ -0,0 +1,262 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/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, 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,
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,
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,
}
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, executionStepStatusSubmitted)
updateExecutionPlanTotalNetworkFee(plan)
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef))
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 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())
}
plan := ensureExecutionPlan(payment)
if plan != nil {
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_PROCESSED:
setExecutionStepStatus(step, executionStepStatusConfirmed)
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
setExecutionStepStatus(step, executionStepStatusFailed)
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
setExecutionStepStatus(step, executionStepStatusSubmitted)
default:
setExecutionStepStatus(step, executionStepStatusPlanned)
}
}
payment.State = mapMntxStatusToState(payout.GetStatus())
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
default:
// leave as-is for pending/unspecified
}
}
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

@@ -9,6 +9,7 @@ import (
mntxclient "github.com/tech/sendico/gateway/mntx/client"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mo "github.com/tech/sendico/pkg/model"
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"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
@@ -101,7 +102,7 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
MaskedPan: "4111",
},
},
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"},
},
}
@@ -122,8 +123,8 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
if len(ensureCalls) != 1 {
t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls))
}
if len(submitCalls) != 1 {
t.Fatalf("expected 1 transfer submission, got %d", len(submitCalls))
if len(submitCalls) != 2 {
t.Fatalf("expected 2 transfer submissions, got %d", len(submitCalls))
}
computeCall := computeCalls[0]
@@ -153,7 +154,15 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
t.Fatalf("funding amount mismatch: %s %s", fundCall.GetAmount().GetCurrency(), fundCall.GetAmount().GetAmount())
}
if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" {
feeCall := findSubmitCall(t, submitCalls, "pay-1:card:fee")
if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef {
t.Fatalf("fee destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef())
}
if feeCall.GetAmount().GetCurrency() != "USDT" || feeCall.GetAmount().GetAmount() != "0.35" {
t.Fatalf("fee amount mismatch: %s %s", feeCall.GetAmount().GetCurrency(), feeCall.GetAmount().GetAmount())
}
if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" || payment.Execution.FeeTransferRef != "pay-1:card:fee" {
t.Fatalf("expected funding transfer ref recorded, got %v", payment.Execution)
}
@@ -192,8 +201,8 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
if feeStep.NetworkFee.GetAmount() != "0.02" || feeStep.NetworkFee.GetCurrency() != "ETH" {
t.Fatalf("fee step network fee mismatch: %s %s", feeStep.NetworkFee.GetCurrency(), feeStep.NetworkFee.GetAmount())
}
if feeStep.TransferRef != "" {
t.Fatalf("expected fee step transfer ref to be empty before payout, got %s", feeStep.TransferRef)
if feeStep.TransferRef != "pay-1:card:fee" {
t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef)
}
if plan.TotalNetworkFee == nil || plan.TotalNetworkFee.GetAmount() != "0.035" || plan.TotalNetworkFee.GetCurrency() != "ETH" {
@@ -201,13 +210,10 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
}
}
func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
func TestSubmitCardPayout_UsesSettlementAmount(t *testing.T) {
ctx := context.Background()
const (
sourceWalletRef = "wallet-src"
feeWalletRef = "wallet-fee"
)
const sourceWalletRef = "wallet-src"
var payoutReq *mntxv1.CardPayoutRequest
var submitCalls []*chainv1.SubmitTransferRequest
@@ -237,12 +243,6 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
deps: serviceDependencies{
gateway: gatewayDependency{client: gateway},
mntx: mntxDependency{client: mntx},
cardRoutes: map[string]CardGatewayRoute{
defaultCardGateway: {
FundingAddress: "0xfunding",
FeeWalletRef: feeWalletRef,
},
},
},
}
@@ -265,7 +265,7 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
Cardholder: "Stephan",
},
},
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"},
Customer: &model.Customer{
ID: "recipient-1",
FirstName: "Stephan",
@@ -274,8 +274,8 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
},
},
LastQuote: &model.PaymentQuoteSnapshot{
ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"},
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
ExpectedSettlementAmount: &paymenttypes.Money{Currency: "RUB", Amount: "392.30"},
ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "0.35"},
},
}
@@ -293,19 +293,9 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
if payment.Execution == nil || payment.Execution.CardPayoutRef != "payout-1" {
t.Fatalf("expected card payout ref recorded, got %v", payment.Execution)
}
if payment.Execution.FeeTransferRef != "fee-transfer" {
t.Fatalf("expected fee transfer ref recorded, got %v", payment.Execution)
}
if len(submitCalls) != 1 {
t.Fatalf("expected 1 fee transfer submission, got %d", len(submitCalls))
}
feeCall := submitCalls[0]
if feeCall.GetSourceWalletRef() != sourceWalletRef {
t.Fatalf("fee transfer source mismatch: %s", feeCall.GetSourceWalletRef())
}
if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef {
t.Fatalf("fee transfer destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef())
if len(submitCalls) != 0 {
t.Fatalf("expected 0 fee transfer submissions, got %d", len(submitCalls))
}
plan := payment.ExecutionPlan
@@ -320,13 +310,6 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount())
}
feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer)
if feeStep.TransferRef != "fee-transfer" {
t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef)
}
if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" {
t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount())
}
}
func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) {
@@ -370,7 +353,7 @@ func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) {
MaskedPan: "4111",
},
},
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"},
},
}

View File

@@ -0,0 +1,64 @@
package orchestrator
import (
"context"
"sort"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type compositeGatewayRegistry struct {
logger mlogger.Logger
registries []GatewayRegistry
}
func NewCompositeGatewayRegistry(logger mlogger.Logger, registries ...GatewayRegistry) GatewayRegistry {
items := make([]GatewayRegistry, 0, len(registries))
for _, registry := range registries {
if registry != nil {
items = append(items, registry)
}
}
if len(items) == 0 {
return nil
}
if logger != nil {
logger = logger.Named("gateway_registry")
}
return &compositeGatewayRegistry{
logger: logger,
registries: items,
}
}
func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) {
if r == nil || len(r.registries) == 0 {
return nil, nil
}
items := map[string]*model.GatewayInstanceDescriptor{}
for _, registry := range r.registries {
list, err := registry.List(ctx)
if err != nil {
if r.logger != nil {
r.logger.Warn("Failed to list gateway registry", zap.Error(err))
}
continue
}
for _, entry := range list {
if entry == nil || entry.ID == "" {
continue
}
items[entry.ID] = entry
}
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
for _, entry := range items {
result = append(result, entry)
}
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
})
return result, nil
}

View File

@@ -5,11 +5,15 @@ import (
"time"
"github.com/tech/sendico/payments/orchestrator/storage/model"
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"
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/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -21,10 +25,10 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
Kind: modelKindFromProto(src.GetKind()),
Source: endpointFromProto(src.GetSource()),
Destination: endpointFromProto(src.GetDestination()),
Amount: cloneMoney(src.GetAmount()),
Amount: moneyFromProto(src.GetAmount()),
RequiresFX: src.GetRequiresFx(),
FeePolicy: src.GetFeePolicy(),
SettlementMode: src.GetSettlementMode(),
FeePolicy: feePolicyFromProto(src.GetFeePolicy()),
SettlementMode: settlementModeFromProto(src.GetSettlementMode()),
Attributes: cloneMetadata(src.GetAttributes()),
Customer: customerFromProto(src.GetCustomer()),
}
@@ -54,14 +58,14 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
result.Type = model.EndpointTypeManagedWallet
result.ManagedWallet = &model.ManagedWalletEndpoint{
ManagedWalletRef: strings.TrimSpace(managed.GetManagedWalletRef()),
Asset: cloneAsset(managed.GetAsset()),
Asset: assetFromProto(managed.GetAsset()),
}
return result
}
if external := src.GetExternalChain(); external != nil {
result.Type = model.EndpointTypeExternalChain
result.ExternalChain = &model.ExternalChainEndpoint{
Asset: cloneAsset(external.GetAsset()),
Asset: assetFromProto(external.GetAsset()),
Address: strings.TrimSpace(external.GetAddress()),
Memo: strings.TrimSpace(external.GetMemo()),
}
@@ -89,8 +93,8 @@ func fxIntentFromProto(src *orchestratorv1.FXIntent) *model.FXIntent {
return nil
}
return &model.FXIntent{
Pair: clonePair(src.GetPair()),
Side: src.GetSide(),
Pair: pairFromProto(src.GetPair()),
Side: fxSideFromProto(src.GetSide()),
Firm: src.GetFirm(),
TTLMillis: src.GetTtlMs(),
PreferredProvider: strings.TrimSpace(src.GetPreferredProvider()),
@@ -103,13 +107,13 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
return nil
}
return &model.PaymentQuoteSnapshot{
DebitAmount: cloneMoney(src.GetDebitAmount()),
ExpectedSettlementAmount: cloneMoney(src.GetExpectedSettlementAmount()),
ExpectedFeeTotal: cloneMoney(src.GetExpectedFeeTotal()),
FeeLines: cloneFeeLines(src.GetFeeLines()),
FeeRules: cloneFeeRules(src.GetFeeRules()),
FXQuote: cloneFXQuote(src.GetFxQuote()),
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
DebitAmount: moneyFromProto(src.GetDebitAmount()),
ExpectedSettlementAmount: moneyFromProto(src.GetExpectedSettlementAmount()),
ExpectedFeeTotal: moneyFromProto(src.GetExpectedFeeTotal()),
FeeLines: feeLinesFromProto(src.GetFeeLines()),
FeeRules: feeRulesFromProto(src.GetFeeRules()),
FXQuote: fxQuoteFromProto(src.GetFxQuote()),
NetworkFee: networkFeeFromProto(src.GetNetworkFee()),
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
}
}
@@ -128,6 +132,7 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
LastQuote: modelQuoteToProto(src.LastQuote),
Execution: protoExecutionFromModel(src.Execution),
ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan),
PaymentPlan: protoPaymentPlanFromModel(src.PaymentPlan),
Metadata: cloneMetadata(src.Metadata),
}
if src.CardPayout != nil {
@@ -158,10 +163,10 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent
Kind: protoKindFromModel(src.Kind),
Source: protoEndpointFromModel(src.Source),
Destination: protoEndpointFromModel(src.Destination),
Amount: cloneMoney(src.Amount),
Amount: protoMoney(src.Amount),
RequiresFx: src.RequiresFX,
FeePolicy: src.FeePolicy,
SettlementMode: src.SettlementMode,
FeePolicy: feePolicyToProto(src.FeePolicy),
SettlementMode: settlementModeToProto(src.SettlementMode),
Attributes: cloneMetadata(src.Attributes),
Customer: protoCustomerFromModel(src.Customer),
}
@@ -226,7 +231,7 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{
ManagedWalletRef: src.ManagedWallet.ManagedWalletRef,
Asset: cloneAsset(src.ManagedWallet.Asset),
Asset: assetToProto(src.ManagedWallet.Asset),
},
}
}
@@ -234,7 +239,7 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
if src.ExternalChain != nil {
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{
ExternalChain: &orchestratorv1.ExternalChainEndpoint{
Asset: cloneAsset(src.ExternalChain.Asset),
Asset: assetToProto(src.ExternalChain.Asset),
Address: src.ExternalChain.Address,
Memo: src.ExternalChain.Memo,
},
@@ -269,8 +274,8 @@ func protoFXIntentFromModel(src *model.FXIntent) *orchestratorv1.FXIntent {
return nil
}
return &orchestratorv1.FXIntent{
Pair: clonePair(src.Pair),
Side: src.Side,
Pair: pairToProto(src.Pair),
Side: fxSideToProto(src.Side),
Firm: src.Firm,
TtlMs: src.TTLMillis,
PreferredProvider: src.PreferredProvider,
@@ -299,8 +304,8 @@ func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.Execu
return &orchestratorv1.ExecutionStep{
Code: src.Code,
Description: src.Description,
Amount: cloneMoney(src.Amount),
NetworkFee: cloneMoney(src.NetworkFee),
Amount: protoMoney(src.Amount),
NetworkFee: protoMoney(src.NetworkFee),
SourceWalletRef: src.SourceWalletRef,
DestinationRef: src.DestinationRef,
TransferRef: src.TransferRef,
@@ -323,22 +328,59 @@ func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.Execu
}
return &orchestratorv1.ExecutionPlan{
Steps: steps,
TotalNetworkFee: cloneMoney(src.TotalNetworkFee),
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),
Ref: strings.TrimSpace(src.Ref),
}
}
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),
}
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
}
return &orchestratorv1.PaymentQuote{
DebitAmount: cloneMoney(src.DebitAmount),
ExpectedSettlementAmount: cloneMoney(src.ExpectedSettlementAmount),
ExpectedFeeTotal: cloneMoney(src.ExpectedFeeTotal),
FeeLines: cloneFeeLines(src.FeeLines),
FeeRules: cloneFeeRules(src.FeeRules),
FxQuote: cloneFXQuote(src.FXQuote),
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
DebitAmount: protoMoney(src.DebitAmount),
ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount),
ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal),
FeeLines: feeLinesToProto(src.FeeLines),
FeeRules: feeRulesToProto(src.FeeRules),
FxQuote: fxQuoteToProto(src.FXQuote),
NetworkFee: networkFeeToProto(src.NetworkFee),
QuoteRef: strings.TrimSpace(src.QuoteRef),
}
}
@@ -390,6 +432,42 @@ 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.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
default:
return gatewayv1.RailOperation_RAIL_OPERATION_UNSPECIFIED
}
}
func protoStateFromModel(state model.PaymentState) orchestratorv1.PaymentState {
switch state {
case model.PaymentStateAccepted:
@@ -431,34 +509,119 @@ func modelStateFromProto(state orchestratorv1.PaymentState) model.PaymentState {
func protoFailureFromModel(code model.PaymentFailureCode) orchestratorv1.PaymentFailureCode {
switch code {
case model.PaymentFailureCodeBalance:
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE
return orchestratorv1.PaymentFailureCode_FAILURE_BALANCE
case model.PaymentFailureCodeLedger:
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER
return orchestratorv1.PaymentFailureCode_FAILURE_LEDGER
case model.PaymentFailureCodeFX:
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX
return orchestratorv1.PaymentFailureCode_FAILURE_FX
case model.PaymentFailureCodeChain:
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN
return orchestratorv1.PaymentFailureCode_FAILURE_CHAIN
case model.PaymentFailureCodeFees:
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES
return orchestratorv1.PaymentFailureCode_FAILURE_FEES
case model.PaymentFailureCodePolicy:
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY
return orchestratorv1.PaymentFailureCode_FAILURE_POLICY
default:
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_UNSPECIFIED
return orchestratorv1.PaymentFailureCode_FAILURE_UNSPECIFIED
}
}
func cloneAsset(asset *chainv1.Asset) *chainv1.Asset {
if asset == nil {
func settlementModeFromProto(mode orchestratorv1.SettlementMode) model.SettlementMode {
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE:
return model.SettlementModeFixSource
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
return model.SettlementModeFixReceived
default:
return model.SettlementModeUnspecified
}
}
func settlementModeToProto(mode model.SettlementMode) orchestratorv1.SettlementMode {
switch mode {
case model.SettlementModeFixSource:
return orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE
case model.SettlementModeFixReceived:
return orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
default:
return orchestratorv1.SettlementMode_SETTLEMENT_UNSPECIFIED
}
}
func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money {
if m == nil {
return nil
}
return &chainv1.Asset{
Chain: asset.GetChain(),
TokenSymbol: asset.GetTokenSymbol(),
ContractAddress: asset.GetContractAddress(),
return &paymenttypes.Money{
Currency: m.GetCurrency(),
Amount: m.GetAmount(),
}
}
func clonePair(pair *fxv1.CurrencyPair) *fxv1.CurrencyPair {
func protoMoney(m *paymenttypes.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{
Currency: m.GetCurrency(),
Amount: m.GetAmount(),
}
}
func feePolicyFromProto(src *feesv1.PolicyOverrides) *paymenttypes.FeePolicy {
if src == nil {
return nil
}
return &paymenttypes.FeePolicy{
InsufficientNet: insufficientPolicyFromProto(src.GetInsufficientNet()),
}
}
func feePolicyToProto(src *paymenttypes.FeePolicy) *feesv1.PolicyOverrides {
if src == nil {
return nil
}
return &feesv1.PolicyOverrides{
InsufficientNet: insufficientPolicyToProto(src.InsufficientNet),
}
}
func insufficientPolicyFromProto(policy feesv1.InsufficientNetPolicy) paymenttypes.InsufficientNetPolicy {
switch policy {
case feesv1.InsufficientNetPolicy_BLOCK_POSTING:
return paymenttypes.InsufficientNetBlockPosting
case feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH:
return paymenttypes.InsufficientNetSweepOrgCash
case feesv1.InsufficientNetPolicy_INVOICE_LATER:
return paymenttypes.InsufficientNetInvoiceLater
default:
return paymenttypes.InsufficientNetUnspecified
}
}
func insufficientPolicyToProto(policy paymenttypes.InsufficientNetPolicy) feesv1.InsufficientNetPolicy {
switch policy {
case paymenttypes.InsufficientNetBlockPosting:
return feesv1.InsufficientNetPolicy_BLOCK_POSTING
case paymenttypes.InsufficientNetSweepOrgCash:
return feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH
case paymenttypes.InsufficientNetInvoiceLater:
return feesv1.InsufficientNetPolicy_INVOICE_LATER
default:
return feesv1.InsufficientNetPolicy_INSUFFICIENT_NET_UNSPECIFIED
}
}
func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair {
if pair == nil {
return nil
}
return &paymenttypes.CurrencyPair{
Base: pair.GetBase(),
Quote: pair.GetQuote(),
}
}
func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair {
if pair == nil {
return nil
}
@@ -468,22 +631,290 @@ func clonePair(pair *fxv1.CurrencyPair) *fxv1.CurrencyPair {
}
}
func cloneFXQuote(quote *oraclev1.Quote) *oraclev1.Quote {
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
}
if cloned, ok := proto.Clone(quote).(*oraclev1.Quote); ok {
return cloned
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(),
}
return nil
}
func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.EstimateTransferFeeResponse {
func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote {
if quote == nil {
return nil
}
return &oraclev1.Quote{
QuoteRef: strings.TrimSpace(quote.QuoteRef),
Pair: pairToProto(quote.Pair),
Side: fxSideToProto(quote.Side),
Price: decimalToProto(quote.Price),
BaseAmount: protoMoney(quote.BaseAmount),
QuoteAmount: protoMoney(quote.QuoteAmount),
ExpiresAtUnixMs: quote.ExpiresAtUnixMs,
Provider: strings.TrimSpace(quote.Provider),
RateRef: strings.TrimSpace(quote.RateRef),
Firm: quote.Firm,
}
}
func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal {
if value == nil {
return nil
}
return &paymenttypes.Decimal{Value: value.GetValue()}
}
func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal {
if value == nil {
return nil
}
return &moneyv1.Decimal{Value: value.GetValue()}
}
func assetFromProto(asset *chainv1.Asset) *paymenttypes.Asset {
if asset == nil {
return nil
}
return &paymenttypes.Asset{
Chain: chainNetworkName(asset.GetChain()),
TokenSymbol: asset.GetTokenSymbol(),
ContractAddress: asset.GetContractAddress(),
}
}
func assetToProto(asset *paymenttypes.Asset) *chainv1.Asset {
if asset == nil {
return nil
}
return &chainv1.Asset{
Chain: chainNetworkFromName(asset.Chain),
TokenSymbol: asset.TokenSymbol,
ContractAddress: asset.ContractAddress,
}
}
func networkFeeFromProto(resp *chainv1.EstimateTransferFeeResponse) *paymenttypes.NetworkFeeEstimate {
if resp == nil {
return nil
}
if cloned, ok := proto.Clone(resp).(*chainv1.EstimateTransferFeeResponse); ok {
return cloned
return &paymenttypes.NetworkFeeEstimate{
NetworkFee: moneyFromProto(resp.GetNetworkFee()),
EstimationContext: strings.TrimSpace(resp.GetEstimationContext()),
}
}
func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse {
if resp == nil {
return nil
}
return &chainv1.EstimateTransferFeeResponse{
NetworkFee: protoMoney(resp.NetworkFee),
EstimationContext: strings.TrimSpace(resp.EstimationContext),
}
}
func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine {
if len(lines) == 0 {
return nil
}
result := make([]*paymenttypes.FeeLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
result = append(result, &paymenttypes.FeeLine{
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
Money: moneyFromProto(line.GetMoney()),
LineType: postingLineTypeFromProto(line.GetLineType()),
Side: entrySideFromProto(line.GetSide()),
Meta: cloneMetadata(line.GetMeta()),
})
}
if len(result) == 0 {
return nil
}
return result
}
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
if len(lines) == 0 {
return nil
}
result := make([]*feesv1.DerivedPostingLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
result = append(result, &feesv1.DerivedPostingLine{
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
Money: protoMoney(line.Money),
LineType: postingLineTypeToProto(line.LineType),
Side: entrySideToProto(line.Side),
Meta: cloneMetadata(line.Meta),
})
}
if len(result) == 0 {
return nil
}
return result
}
func feeRulesFromProto(rules []*feesv1.AppliedRule) []*paymenttypes.AppliedRule {
if len(rules) == 0 {
return nil
}
result := make([]*paymenttypes.AppliedRule, 0, len(rules))
for _, rule := range rules {
if rule == nil {
continue
}
result = append(result, &paymenttypes.AppliedRule{
RuleID: strings.TrimSpace(rule.GetRuleId()),
RuleVersion: strings.TrimSpace(rule.GetRuleVersion()),
Formula: strings.TrimSpace(rule.GetFormula()),
Rounding: roundingModeFromProto(rule.GetRounding()),
TaxCode: strings.TrimSpace(rule.GetTaxCode()),
TaxRate: strings.TrimSpace(rule.GetTaxRate()),
Parameters: cloneMetadata(rule.GetParameters()),
})
}
if len(result) == 0 {
return nil
}
return result
}
func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule {
if len(rules) == 0 {
return nil
}
result := make([]*feesv1.AppliedRule, 0, len(rules))
for _, rule := range rules {
if rule == nil {
continue
}
result = append(result, &feesv1.AppliedRule{
RuleId: strings.TrimSpace(rule.RuleID),
RuleVersion: strings.TrimSpace(rule.RuleVersion),
Formula: strings.TrimSpace(rule.Formula),
Rounding: roundingModeToProto(rule.Rounding),
TaxCode: strings.TrimSpace(rule.TaxCode),
TaxRate: strings.TrimSpace(rule.TaxRate),
Parameters: cloneMetadata(rule.Parameters),
})
}
if len(result) == 0 {
return nil
}
return result
}
func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide {
switch side {
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
return paymenttypes.EntrySideDebit
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
return paymenttypes.EntrySideCredit
default:
return paymenttypes.EntrySideUnspecified
}
}
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
switch side {
case paymenttypes.EntrySideDebit:
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case paymenttypes.EntrySideCredit:
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType {
switch lineType {
case accountingv1.PostingLineType_POSTING_LINE_FEE:
return paymenttypes.PostingLineTypeFee
case accountingv1.PostingLineType_POSTING_LINE_TAX:
return paymenttypes.PostingLineTypeTax
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
return paymenttypes.PostingLineTypeSpread
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
return paymenttypes.PostingLineTypeReversal
default:
return paymenttypes.PostingLineTypeUnspecified
}
}
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
switch lineType {
case paymenttypes.PostingLineTypeFee:
return accountingv1.PostingLineType_POSTING_LINE_FEE
case paymenttypes.PostingLineTypeTax:
return accountingv1.PostingLineType_POSTING_LINE_TAX
case paymenttypes.PostingLineTypeSpread:
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case paymenttypes.PostingLineTypeReversal:
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
}
}
func roundingModeFromProto(mode moneyv1.RoundingMode) paymenttypes.RoundingMode {
switch mode {
case moneyv1.RoundingMode_ROUND_HALF_EVEN:
return paymenttypes.RoundingModeHalfEven
case moneyv1.RoundingMode_ROUND_HALF_UP:
return paymenttypes.RoundingModeHalfUp
case moneyv1.RoundingMode_ROUND_DOWN:
return paymenttypes.RoundingModeDown
default:
return paymenttypes.RoundingModeUnspecified
}
}
func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode {
switch mode {
case paymenttypes.RoundingModeHalfEven:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
case paymenttypes.RoundingModeHalfUp:
return moneyv1.RoundingMode_ROUND_HALF_UP
case paymenttypes.RoundingModeDown:
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
}
return nil
}

View File

@@ -0,0 +1,114 @@
package orchestrator
import (
"testing"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
)
func TestMoneyConversionRoundTrip(t *testing.T) {
proto := &moneyv1.Money{Currency: "USD", Amount: "12.34"}
model := moneyFromProto(proto)
if model == nil || model.Currency != "USD" || model.Amount != "12.34" {
t.Fatalf("moneyFromProto mismatch: %#v", model)
}
back := protoMoney(model)
if back == nil || back.GetCurrency() != "USD" || back.GetAmount() != "12.34" {
t.Fatalf("protoMoney mismatch: %#v", back)
}
}
func TestFeePolicyConversionRoundTrip(t *testing.T) {
proto := &feesv1.PolicyOverrides{InsufficientNet: feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH}
model := feePolicyFromProto(proto)
if model == nil || model.InsufficientNet != paymenttypes.InsufficientNetSweepOrgCash {
t.Fatalf("feePolicyFromProto mismatch: %#v", model)
}
back := feePolicyToProto(model)
if back == nil || back.GetInsufficientNet() != feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH {
t.Fatalf("feePolicyToProto mismatch: %#v", back)
}
}
func TestFeeLineConversionRoundTrip(t *testing.T) {
protoLine := &feesv1.DerivedPostingLine{
LedgerAccountRef: "ledger:fees",
Money: &moneyv1.Money{Currency: "EUR", Amount: "1.00"},
LineType: accountingv1.PostingLineType_POSTING_LINE_FEE,
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
Meta: map[string]string{"k": "v"},
}
modelLines := feeLinesFromProto([]*feesv1.DerivedPostingLine{protoLine})
if len(modelLines) != 1 {
t.Fatalf("expected 1 model line, got %d", len(modelLines))
}
modelLine := modelLines[0]
if modelLine.LedgerAccountRef != "ledger:fees" || modelLine.Money.GetCurrency() != "EUR" || modelLine.Money.GetAmount() != "1.00" {
t.Fatalf("model line mismatch: %#v", modelLine)
}
if modelLine.LineType != paymenttypes.PostingLineTypeFee || modelLine.Side != paymenttypes.EntrySideDebit {
t.Fatalf("model line enums mismatch: %#v", modelLine)
}
back := feeLinesToProto(modelLines)
if len(back) != 1 {
t.Fatalf("expected 1 proto line, got %d", len(back))
}
protoBack := back[0]
if protoBack.GetLedgerAccountRef() != "ledger:fees" || protoBack.GetMoney().GetCurrency() != "EUR" || protoBack.GetMoney().GetAmount() != "1.00" {
t.Fatalf("proto line mismatch: %#v", protoBack)
}
if protoBack.GetLineType() != accountingv1.PostingLineType_POSTING_LINE_FEE || protoBack.GetSide() != accountingv1.EntrySide_ENTRY_SIDE_DEBIT {
t.Fatalf("proto line enums mismatch: %#v", protoBack)
}
}
func TestFXQuoteConversionRoundTrip(t *testing.T) {
proto := &oraclev1.Quote{
QuoteRef: "q1",
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
Price: &moneyv1.Decimal{Value: "0.9"},
BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "100"},
QuoteAmount: &moneyv1.Money{Currency: "EUR", Amount: "90"},
ExpiresAtUnixMs: 1700000000000,
Provider: "provider",
RateRef: "rate",
Firm: true,
}
model := fxQuoteFromProto(proto)
if model == nil || model.QuoteRef != "q1" || model.Pair.GetBase() != "USD" || model.Pair.GetQuote() != "EUR" {
t.Fatalf("fxQuoteFromProto mismatch: %#v", model)
}
if model.Side != paymenttypes.FXSideSellBaseBuyQuote || model.Price.GetValue() != "0.9" {
t.Fatalf("fxQuoteFromProto enums mismatch: %#v", model)
}
back := fxQuoteToProto(model)
if back == nil || back.GetQuoteRef() != "q1" || back.GetPair().GetBase() != "USD" || back.GetPair().GetQuote() != "EUR" {
t.Fatalf("fxQuoteToProto mismatch: %#v", back)
}
if back.GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE || back.GetPrice().GetValue() != "0.9" {
t.Fatalf("fxQuoteToProto enums mismatch: %#v", back)
}
}
func TestAssetConversionRoundTrip(t *testing.T) {
proto := &chainv1.Asset{
Chain: chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET,
TokenSymbol: "USDT",
ContractAddress: "0xabc",
}
model := assetFromProto(proto)
if model == nil || model.Chain != "TRON" || model.TokenSymbol != "USDT" || model.ContractAddress != "0xabc" {
t.Fatalf("assetFromProto mismatch: %#v", model)
}
back := assetToProto(model)
if back == nil || back.GetChain() != chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET || back.GetTokenSymbol() != "USDT" || back.GetContractAddress() != "0xabc" {
t.Fatalf("assetToProto mismatch: %#v", back)
}
}

View File

@@ -0,0 +1,131 @@
package orchestrator
import (
"context"
"sort"
"strings"
"time"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/mlogger"
)
type discoveryGatewayRegistry struct {
logger mlogger.Logger
registry *discovery.Registry
}
func NewDiscoveryGatewayRegistry(logger mlogger.Logger, registry *discovery.Registry) GatewayRegistry {
if registry == nil {
return nil
}
if logger != nil {
logger = logger.Named("discovery_gateway_registry")
}
return &discoveryGatewayRegistry{
logger: logger,
registry: registry,
}
}
func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) {
if r == nil || r.registry == nil {
return nil, nil
}
entries := r.registry.List(time.Now(), true)
items := make([]*model.GatewayInstanceDescriptor, 0, len(entries))
for _, entry := range entries {
if entry.Rail == "" {
continue
}
rail := railFromDiscovery(entry.Rail)
if rail == model.RailUnspecified {
continue
}
items = append(items, &model.GatewayInstanceDescriptor{
ID: entry.ID,
Rail: rail,
Network: entry.Network,
Currencies: normalizeCurrencies(entry.Currencies),
Capabilities: capabilitiesFromOps(entry.Operations),
Limits: limitsFromDiscovery(entry.Limits),
Version: entry.Version,
IsEnabled: entry.Healthy,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].ID < items[j].ID
})
return items, nil
}
func railFromDiscovery(value string) model.Rail {
switch strings.ToUpper(strings.TrimSpace(value)) {
case string(model.RailCrypto):
return model.RailCrypto
case string(model.RailProviderSettlement):
return model.RailProviderSettlement
case string(model.RailLedger):
return model.RailLedger
case string(model.RailCardPayout):
return model.RailCardPayout
case string(model.RailFiatOnRamp):
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func capabilitiesFromOps(ops []string) model.RailCapabilities {
var cap model.RailCapabilities
for _, op := range ops {
switch strings.ToLower(strings.TrimSpace(op)) {
case "payin.crypto", "payin.card", "payin.fiat":
cap.CanPayIn = true
case "payout.crypto", "payout.card", "payout.fiat":
cap.CanPayOut = true
case "balance.read":
cap.CanReadBalance = true
case "fee.send":
cap.CanSendFee = true
case "observe.confirm", "observe.confirmation":
cap.RequiresObserveConfirm = true
}
}
return cap
}
func limitsFromDiscovery(src *discovery.Limits) model.Limits {
if src == nil {
return model.Limits{}
}
limits := model.Limits{
MinAmount: strings.TrimSpace(src.MinAmount),
MaxAmount: strings.TrimSpace(src.MaxAmount),
VolumeLimit: map[string]string{},
VelocityLimit: map[string]int{},
}
for key, value := range src.VolumeLimit {
k := strings.TrimSpace(key)
v := strings.TrimSpace(value)
if k == "" || v == "" {
continue
}
limits.VolumeLimit[k] = v
}
for key, value := range src.VelocityLimit {
k := strings.TrimSpace(key)
if k == "" {
continue
}
limits.VelocityLimit[k] = value
}
if len(limits.VolumeLimit) == 0 {
limits.VolumeLimit = nil
}
if len(limits.VelocityLimit) == 0 {
limits.VelocityLimit = nil
}
return limits
}

View File

@@ -0,0 +1,168 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/payments/orchestrator/storage/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
const (
executionStepMetadataRole = "role"
executionStepMetadataStatus = "status"
executionStepRoleSource = "source"
executionStepRoleConsumer = "consumer"
executionStepStatusPlanned = "planned"
executionStepStatusSubmitted = "submitted"
executionStepStatusConfirmed = "confirmed"
executionStepStatusFailed = "failed"
executionStepStatusCancelled = "cancelled"
executionStepStatusSkipped = "skipped"
)
func setExecutionStepRole(step *model.ExecutionStep, role string) {
role = strings.ToLower(strings.TrimSpace(role))
setExecutionStepMetadata(step, executionStepMetadataRole, role)
}
func setExecutionStepStatus(step *model.ExecutionStep, status string) {
status = strings.ToLower(strings.TrimSpace(status))
setExecutionStepMetadata(step, executionStepMetadataStatus, status)
}
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 executionStepStatus(step *model.ExecutionStep) string {
if step == nil {
return ""
}
status := strings.TrimSpace(step.Metadata[executionStepMetadataStatus])
if status == "" {
return executionStepStatusPlanned
}
return strings.ToLower(status)
}
func isSourceExecutionStep(step *model.ExecutionStep) bool {
return executionStepRole(step) == executionStepRoleSource
}
func isConsumerExecutionStep(step *model.ExecutionStep) bool {
return executionStepRole(step) == executionStepRoleConsumer
}
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
}
status := executionStepStatus(step)
if status == executionStepStatusSkipped {
continue
}
hasSource = true
if status != executionStepStatusConfirmed {
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
}
step := findExecutionStepByTransferRef(plan, transferRef)
if step == nil {
return nil
}
if step.TransferRef == "" {
step.TransferRef = transferRef
}
if status := executionStepStatusFromTransferStatus(transfer.GetStatus()); status != "" {
setExecutionStepStatus(step, status)
}
return step
}
func executionStepStatusFromTransferStatus(status chainv1.TransferStatus) string {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return executionStepStatusConfirmed
case chainv1.TransferStatus_TRANSFER_FAILED:
return executionStepStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return executionStepStatusCancelled
case chainv1.TransferStatus_TRANSFER_SIGNING,
chainv1.TransferStatus_TRANSFER_PENDING,
chainv1.TransferStatus_TRANSFER_SUBMITTED:
return executionStepStatusSubmitted
default:
return ""
}
}
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

@@ -0,0 +1,249 @@
package orchestrator
import (
"context"
"sort"
"strings"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/mlogger"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
)
type gatewayRegistry struct {
logger mlogger.Logger
mntx mntxclient.Client
static []*model.GatewayInstanceDescriptor
}
// NewGatewayRegistry aggregates static and remote gateway descriptors.
func NewGatewayRegistry(logger mlogger.Logger, mntxClient mntxclient.Client, static []*model.GatewayInstanceDescriptor) GatewayRegistry {
if mntxClient == nil && len(static) == 0 {
return nil
}
if logger != nil {
logger = logger.Named("gateway_registry")
}
return &gatewayRegistry{
logger: logger,
mntx: mntxClient,
static: cloneGatewayDescriptors(static),
}
}
func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) {
items := map[string]*model.GatewayInstanceDescriptor{}
for _, gw := range r.static {
if gw == nil {
continue
}
id := strings.TrimSpace(gw.ID)
if id == "" {
continue
}
items[id] = cloneGatewayDescriptor(gw)
}
if r.mntx != nil {
resp, err := r.mntx.ListGatewayInstances(ctx, &mntxv1.ListGatewayInstancesRequest{})
if err != nil {
if r.logger != nil {
r.logger.Warn("Failed to list Monetix gateway instances", zap.Error(err))
}
} else {
for _, gw := range resp.GetItems() {
modelGw := modelGatewayFromProto(gw)
if modelGw == nil {
continue
}
id := strings.TrimSpace(modelGw.ID)
if id == "" {
continue
}
items[id] = modelGw
}
}
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
for _, gw := range items {
result = append(result, gw)
}
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
})
return result, nil
}
func modelGatewayFromProto(src *gatewayv1.GatewayInstanceDescriptor) *model.GatewayInstanceDescriptor {
if src == nil {
return nil
}
limits := modelLimitsFromProto(src.GetLimits())
return &model.GatewayInstanceDescriptor{
ID: strings.TrimSpace(src.GetId()),
Rail: modelRailFromProto(src.GetRail()),
Network: strings.ToUpper(strings.TrimSpace(src.GetNetwork())),
Currencies: normalizeCurrencies(src.GetCurrencies()),
Capabilities: modelCapabilitiesFromProto(src.GetCapabilities()),
Limits: limits,
Version: strings.TrimSpace(src.GetVersion()),
IsEnabled: src.GetIsEnabled(),
}
}
func modelRailFromProto(rail gatewayv1.Rail) model.Rail {
switch rail {
case gatewayv1.Rail_RAIL_CRYPTO:
return model.RailCrypto
case gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT:
return model.RailProviderSettlement
case gatewayv1.Rail_RAIL_LEDGER:
return model.RailLedger
case gatewayv1.Rail_RAIL_CARD_PAYOUT:
return model.RailCardPayout
case gatewayv1.Rail_RAIL_FIAT_ONRAMP:
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func modelCapabilitiesFromProto(src *gatewayv1.RailCapabilities) model.RailCapabilities {
if src == nil {
return model.RailCapabilities{}
}
return model.RailCapabilities{
CanPayIn: src.GetCanPayIn(),
CanPayOut: src.GetCanPayOut(),
CanReadBalance: src.GetCanReadBalance(),
CanSendFee: src.GetCanSendFee(),
RequiresObserveConfirm: src.GetRequiresObserveConfirm(),
}
}
func modelLimitsFromProto(src *gatewayv1.Limits) model.Limits {
if src == nil {
return model.Limits{}
}
limits := model.Limits{
MinAmount: strings.TrimSpace(src.GetMinAmount()),
MaxAmount: strings.TrimSpace(src.GetMaxAmount()),
PerTxMaxFee: strings.TrimSpace(src.GetPerTxMaxFee()),
PerTxMinAmount: strings.TrimSpace(src.GetPerTxMinAmount()),
PerTxMaxAmount: strings.TrimSpace(src.GetPerTxMaxAmount()),
}
if len(src.GetVolumeLimit()) > 0 {
limits.VolumeLimit = map[string]string{}
for key, value := range src.GetVolumeLimit() {
bucket := strings.TrimSpace(key)
amount := strings.TrimSpace(value)
if bucket == "" || amount == "" {
continue
}
limits.VolumeLimit[bucket] = amount
}
}
if len(src.GetVelocityLimit()) > 0 {
limits.VelocityLimit = map[string]int{}
for key, value := range src.GetVelocityLimit() {
bucket := strings.TrimSpace(key)
if bucket == "" {
continue
}
limits.VelocityLimit[bucket] = int(value)
}
}
if len(src.GetCurrencyLimits()) > 0 {
limits.CurrencyLimits = map[string]model.LimitsOverride{}
for key, override := range src.GetCurrencyLimits() {
currency := strings.ToUpper(strings.TrimSpace(key))
if currency == "" || override == nil {
continue
}
limits.CurrencyLimits[currency] = model.LimitsOverride{
MaxVolume: strings.TrimSpace(override.GetMaxVolume()),
MinAmount: strings.TrimSpace(override.GetMinAmount()),
MaxAmount: strings.TrimSpace(override.GetMaxAmount()),
MaxFee: strings.TrimSpace(override.GetMaxFee()),
MaxOps: int(override.GetMaxOps()),
}
}
}
return limits
}
func normalizeCurrencies(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
clean := strings.ToUpper(strings.TrimSpace(value))
if clean == "" || seen[clean] {
continue
}
seen[clean] = true
result = append(result, clean)
}
return result
}
func cloneGatewayDescriptors(src []*model.GatewayInstanceDescriptor) []*model.GatewayInstanceDescriptor {
if len(src) == 0 {
return nil
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(src))
for _, item := range src {
if item == nil {
continue
}
if cloned := cloneGatewayDescriptor(item); cloned != nil {
result = append(result, cloned)
}
}
return result
}
func cloneGatewayDescriptor(src *model.GatewayInstanceDescriptor) *model.GatewayInstanceDescriptor {
if src == nil {
return nil
}
dst := *src
if src.Currencies != nil {
dst.Currencies = append([]string(nil), src.Currencies...)
}
dst.Limits = cloneLimits(src.Limits)
return &dst
}
func cloneLimits(src model.Limits) model.Limits {
dst := src
if src.VolumeLimit != nil {
dst.VolumeLimit = map[string]string{}
for key, value := range src.VolumeLimit {
dst.VolumeLimit[key] = value
}
}
if src.VelocityLimit != nil {
dst.VelocityLimit = map[string]int{}
for key, value := range src.VelocityLimit {
dst.VelocityLimit[key] = value
}
}
if src.CurrencyLimits != nil {
dst.CurrencyLimits = map[string]model.LimitsOverride{}
for key, value := range src.CurrencyLimits {
dst.CurrencyLimits[key] = value
}
}
return dst
}

View File

@@ -10,21 +10,26 @@ import (
"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"
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
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
submitCardPayout func(ctx context.Context, payment *model.Payment) error
resumePlan 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) *paymentEventHandler {
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger, submitCardPayout func(ctx context.Context, payment *model.Payment) error, resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error) *paymentEventHandler {
return &paymentEventHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
repo: repo,
ensureRepo: ensure,
logger: logger,
submitCardPayout: submitCardPayout,
resumePlan: resumePlan,
}
}
@@ -48,6 +53,128 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or
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) {
intent := payment.Intent
splitIdx := len(payment.PaymentPlan.Steps)
sourceRail, _, srcErr := railFromEndpoint(intent.Source, intent.Attributes, true)
destRail, _, dstErr := railFromEndpoint(intent.Destination, intent.Attributes, false)
if srcErr == nil && dstErr == nil {
splitIdx = planSplitIndex(payment.PaymentPlan, sourceRail, destRail)
}
ensureExecutionPlanForPlan(payment, payment.PaymentPlan, splitIdx)
}
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_CONFIRMED:
if h.resumePlan != nil {
if err := h.resumePlan(ctx, store, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
} else {
payment.State = model.PaymentStateSubmitted
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_SIGNING,
chainv1.TransferStatus_TRANSFER_PENDING,
chainv1.TransferStatus_TRANSFER_SUBMITTED:
if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled {
payment.State = model.PaymentStateSubmitted
}
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)})
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_CONFIRMED:
if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled {
if sourceStepsConfirmed(payment.ExecutionPlan) {
if payment.Execution.CardPayoutRef != "" {
payment.State = model.PaymentStateSubmitted
} else {
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, 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))
} else {
payment.State = model.PaymentStateSubmitted
}
}
} else {
payment.State = model.PaymentStateSubmitted
}
}
case chainv1.TransferStatus_TRANSFER_SIGNING,
chainv1.TransferStatus_TRANSFER_PENDING,
chainv1.TransferStatus_TRANSFER_SUBMITTED:
if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled {
payment.State = model.PaymentStateSubmitted
}
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)
@@ -137,3 +264,14 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *
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

@@ -7,6 +7,8 @@ import (
"github.com/shopspring/decimal"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/payments/rail"
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"
@@ -16,10 +18,14 @@ import (
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
func cloneMoney(input *moneyv1.Money) *moneyv1.Money {
type moneyGetter interface {
GetAmount() string
GetCurrency() string
}
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
if input == nil {
return nil
}
@@ -112,7 +118,7 @@ func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *money
func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) {
if fxQuote == nil {
return cloneMoney(intentAmount), cloneMoney(intentAmount)
return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount)
}
qSide := fxQuote.GetSide()
if qSide == fxv1.Side_SIDE_UNSPECIFIED {
@@ -121,27 +127,27 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s
switch qSide {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
pay := cloneMoney(fxQuote.GetQuoteAmount())
settle := cloneMoney(fxQuote.GetBaseAmount())
pay := cloneProtoMoney(fxQuote.GetQuoteAmount())
settle := cloneProtoMoney(fxQuote.GetBaseAmount())
if pay == nil {
pay = cloneMoney(intentAmount)
pay = cloneProtoMoney(intentAmount)
}
if settle == nil {
settle = cloneMoney(intentAmount)
settle = cloneProtoMoney(intentAmount)
}
return pay, settle
case fxv1.Side_SELL_BASE_BUY_QUOTE:
pay := cloneMoney(fxQuote.GetBaseAmount())
settle := cloneMoney(fxQuote.GetQuoteAmount())
pay := cloneProtoMoney(fxQuote.GetBaseAmount())
settle := cloneProtoMoney(fxQuote.GetQuoteAmount())
if pay == nil {
pay = cloneMoney(intentAmount)
pay = cloneProtoMoney(intentAmount)
}
if settle == nil {
settle = cloneMoney(intentAmount)
settle = cloneProtoMoney(intentAmount)
}
return pay, settle
default:
return cloneMoney(intentAmount), cloneMoney(intentAmount)
return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount)
}
}
@@ -151,7 +157,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
}
debitDecimal, err := decimalFromMoney(pay)
if err != nil {
return cloneMoney(pay), cloneMoney(settlement)
return cloneProtoMoney(pay), cloneProtoMoney(settlement)
}
settlementCurrency := pay.GetCurrency()
@@ -187,7 +193,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
}
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
// Sender pays the fee: keep settlement fixed, increase debit.
applyChargeToDebit(fee)
default:
@@ -197,7 +203,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
if network != nil && network.GetNetworkFee() != nil {
switch mode {
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
applyChargeToDebit(network.GetNetworkFee())
default:
applyChargeToSettlement(network.GetNetworkFee())
@@ -207,7 +213,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
}
func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) {
if m == nil {
return decimal.Zero, nil
}
@@ -226,7 +232,7 @@ func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quo
return nil, nil
}
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
return cloneMoney(m), nil
return cloneProtoMoney(m), nil
}
return convertWithQuote(m, quote, targetCurrency)
}
@@ -270,8 +276,8 @@ func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
Pair: src.Pair,
Side: src.Side,
Price: &moneyv1.Decimal{Value: src.Price},
BaseAmount: cloneMoney(src.BaseAmount),
QuoteAmount: cloneMoney(src.QuoteAmount),
BaseAmount: cloneProtoMoney(src.BaseAmount),
QuoteAmount: cloneProtoMoney(src.QuoteAmount),
ExpiresAtUnixMs: src.ExpiresAt.UnixMilli(),
Provider: src.Provider,
RateRef: src.RateRef,
@@ -288,7 +294,7 @@ func ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.P
if line == nil || strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
continue
}
money := cloneMoney(line.GetMoney())
money := cloneProtoMoney(line.GetMoney())
if money == nil {
continue
}
@@ -335,17 +341,17 @@ func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote
return expiry
}
func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown {
func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []rail.FeeBreakdown {
if quote == nil {
return nil
}
lines := quote.GetFeeLines()
breakdown := make([]*chainv1.ServiceFeeBreakdown, 0, len(lines)+1)
breakdown := make([]rail.FeeBreakdown, 0, len(lines)+1)
for _, line := range lines {
if line == nil {
continue
}
amount := cloneMoney(line.GetMoney())
amount := moneyFromProto(line.GetMoney())
if amount == nil {
continue
}
@@ -357,16 +363,16 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic
code = line.GetLineType().String()
}
desc := strings.TrimSpace(line.GetMeta()["description"])
breakdown = append(breakdown, &chainv1.ServiceFeeBreakdown{
breakdown = append(breakdown, rail.FeeBreakdown{
FeeCode: code,
Amount: amount,
Description: desc,
})
}
if quote.GetNetworkFee() != nil && quote.GetNetworkFee().GetNetworkFee() != nil {
networkAmount := cloneMoney(quote.GetNetworkFee().GetNetworkFee())
networkAmount := moneyFromProto(quote.GetNetworkFee().GetNetworkFee())
if networkAmount != nil {
breakdown = append(breakdown, &chainv1.ServiceFeeBreakdown{
breakdown = append(breakdown, rail.FeeBreakdown{
FeeCode: "network_fee",
Amount: networkAmount,
Description: strings.TrimSpace(quote.GetNetworkFee().GetEstimationContext()),
@@ -395,7 +401,7 @@ func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []
return lines
}
func moneyEquals(a, b *moneyv1.Money) bool {
func moneyEquals(a, b moneyGetter) bool {
if a == nil || b == nil {
return false
}

View File

@@ -48,7 +48,7 @@ func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
},
}
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED)
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED)
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
}
@@ -69,7 +69,7 @@ func TestComputeAggregatesRecipientPaysFee(t *testing.T) {
},
}
debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE)
debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE)
if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" {
t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount())
}

View File

@@ -0,0 +1,13 @@
package orchestrator
import paymenttypes "github.com/tech/sendico/pkg/payments/types"
func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money {
if input == nil {
return nil
}
return &paymenttypes.Money{
Currency: input.GetCurrency(),
Amount: input.GetAmount(),
}
}

View File

@@ -1,6 +1,8 @@
package orchestrator
import (
"context"
"sort"
"strings"
"time"
@@ -8,7 +10,10 @@ import (
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/orchestrator/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/payments/rail"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
)
@@ -25,7 +30,8 @@ func (f feesDependency) available() bool {
}
type ledgerDependency struct {
client ledgerclient.Client
client ledgerclient.Client
internal rail.InternalLedger
}
func (l ledgerDependency) available() bool {
@@ -40,6 +46,72 @@ func (g gatewayDependency) available() bool {
return g.client != nil
}
type railGatewayDependency struct {
byID map[string]rail.RailGateway
byRail map[model.Rail][]rail.RailGateway
registry GatewayRegistry
chainClient chainclient.Client
}
func (g railGatewayDependency) available() bool {
return len(g.byID) > 0 || len(g.byRail) > 0 || (g.registry != nil && g.chainClient != 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 != "" {
gw, ok := g.byID[id]
if !ok {
return nil, merrors.InvalidArgument("rail gateway: unknown gateway id")
}
return gw, nil
}
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 || g.chainClient == nil {
return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail")
}
items, err := g.registry.List(ctx)
if err != nil {
return nil, err
}
for _, entry := range items {
if entry == nil || !entry.IsEnabled {
continue
}
if entry.Rail != step.Rail {
continue
}
if step.GatewayID != "" && entry.ID != step.GatewayID {
continue
}
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,
},
}
return chainclient.NewRailGateway(g.chainClient, cfg), nil
}
return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail")
}
type oracleDependency struct {
client oracleclient.Client
}
@@ -56,6 +128,14 @@ func (m mntxDependency) available() bool {
return m.client != nil
}
type gatewayRegistryDependency struct {
registry GatewayRegistry
}
func (g gatewayRegistryDependency) available() bool {
return g.registry != nil
}
// CardGatewayRoute maps a gateway to its funding and fee destinations.
type CardGatewayRoute struct {
FundingAddress string
@@ -76,7 +156,10 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option
// WithLedgerClient wires the ledger client.
func WithLedgerClient(client ledgerclient.Client) Option {
return func(s *Service) {
s.deps.ledger = ledgerDependency{client: client}
s.deps.ledger = ledgerDependency{
client: client,
internal: client,
}
}
}
@@ -87,6 +170,16 @@ func WithChainGatewayClient(client chainclient.Client) Option {
}
}
// WithRailGateways wires rail gateway adapters by instance ID.
func WithRailGateways(gateways map[string]rail.RailGateway) Option {
return func(s *Service) {
if len(gateways) == 0 {
return
}
s.deps.railGateways = buildRailGatewayDependency(gateways, s.deps.gatewayRegistry, s.deps.gateway.client)
}
}
// WithOracleClient wires the FX oracle client.
func WithOracleClient(client oracleclient.Client) Option {
return func(s *Service) {
@@ -132,6 +225,29 @@ func WithFeeLedgerAccounts(routes map[string]string) Option {
}
}
// WithPlanBuilder wires a payment plan builder implementation.
func WithPlanBuilder(builder PlanBuilder) Option {
return func(s *Service) {
if builder != nil {
s.deps.planBuilder = builder
}
}
}
// WithGatewayRegistry wires a registry of gateway instances for routing.
func WithGatewayRegistry(registry GatewayRegistry) Option {
return func(s *Service) {
if registry != nil {
s.deps.gatewayRegistry = registry
s.deps.railGateways.registry = registry
s.deps.railGateways.chainClient = s.deps.gateway.client
if s.deps.planBuilder == nil {
s.deps.planBuilder = &defaultPlanBuilder{}
}
}
}
}
// WithClock overrides the default clock.
func WithClock(clock clockpkg.Clock) Option {
return func(s *Service) {
@@ -140,3 +256,45 @@ func WithClock(clock clockpkg.Clock) Option {
}
}
}
func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainClient chainclient.Client) railGatewayDependency {
result := railGatewayDependency{
byID: map[string]rail.RailGateway{},
byRail: map[model.Rail][]rail.RailGateway{},
registry: registry,
chainClient: chainClient,
}
if len(gateways) == 0 {
return result
}
type item struct {
id string
gw rail.RailGateway
}
itemsByRail := map[model.Rail][]item{}
for id, gw := range gateways {
cleanID := strings.TrimSpace(id)
if cleanID == "" || gw == nil {
continue
}
result.byID[cleanID] = gw
railID := parseRailValue(gw.Rail())
if railID == model.RailUnspecified {
continue
}
itemsByRail[railID] = append(itemsByRail[railID], item{id: cleanID, gw: gw})
}
for railID, items := range itemsByRail {
sort.Slice(items, func(i, j int) bool {
return items[i].id < items[j].id
})
for _, entry := range items {
result.byRail[railID] = append(result.byRail[railID], entry.gw)
}
}
return result
}

View File

@@ -12,7 +12,6 @@ import (
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.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
@@ -30,132 +29,30 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym
if store == nil {
return errStorageUnavailable
}
charges := ledgerChargesFromFeeLines(quote.GetFeeLines())
ledgerNeeded := requiresLedger(payment)
chainNeeded := requiresChain(payment)
cardNeeded := payment.Intent.Destination.Type == model.EndpointTypeCard
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
if p.svc == nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "service_unavailable", errStorageUnavailable)
}
if ledgerNeeded {
if !p.deps.ledger.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable"))
}
if err := p.performLedgerOperation(ctx, payment, quote, charges); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err)
}
payment.State = model.PaymentStateFundsReserved
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("ledger reservation completed", zap.String("payment_ref", payment.PaymentRef))
if p.svc.storage == nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
}
if chainNeeded {
if !p.deps.gateway.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable"))
}
resp, err := p.submitChainTransfer(ctx, payment, quote)
if err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
}
exec = payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
if resp != nil && resp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(resp.GetTransfer().GetTransferRef())
}
payment.Execution = exec
payment.State = model.PaymentStateSubmitted
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("chain transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
if !cardNeeded {
return nil
}
routeStore := p.svc.storage.Routes()
if routeStore == nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
}
if cardNeeded {
if !p.deps.mntx.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "card_gateway_unavailable", merrors.Internal("card_gateway_unavailable"))
}
if err := p.svc.submitCardFundingTransfers(ctx, payment, quote); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
}
if err := p.svc.submitCardPayout(ctx, payment); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
payment.State = model.PaymentStateSubmitted
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("card_payout_ref", payment.Execution.CardPayoutRef))
return nil
builder := p.svc.deps.planBuilder
if builder == nil {
builder = &defaultPlanBuilder{}
}
payment.State = model.PaymentStateSettled
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
plan, err := builder.Build(ctx, payment, quote, routeStore, p.svc.deps.gatewayRegistry)
if err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
p.logger.Info("payment settled without chain", zap.String("payment_ref", payment.PaymentRef))
return nil
}
func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error {
intent := payment.Intent
if payment.OrganizationRef == primitive.NilObjectID {
return merrors.InvalidArgument("ledger: organization_ref is required")
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
amount := cloneMoney(intent.Amount)
if amount == nil {
return merrors.InvalidArgument("ledger: amount is required")
}
description := paymentDescription(payment)
metadata := cloneMetadata(payment.Metadata)
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
switch intent.Kind {
case model.PaymentKindFXConversion:
if err := p.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil {
return err
}
case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified:
from, to, err := resolveLedgerAccounts(intent)
if err != nil {
return err
}
req := &ledgerv1.TransferRequest{
IdempotencyKey: payment.IdempotencyKey,
OrganizationRef: payment.OrganizationRef.Hex(),
FromLedgerAccountRef: from,
ToLedgerAccountRef: to,
Money: amount,
Description: description,
Charges: charges,
Metadata: metadata,
}
resp, err := p.deps.ledger.client.TransferInternal(ctx, req)
if err != nil {
return err
}
exec.DebitEntryRef = strings.TrimSpace(resp.GetJournalEntryRef())
payment.Execution = exec
default:
return merrors.InvalidArgument("ledger: unsupported payment kind")
}
return nil
return p.executePaymentPlan(ctx, store, payment, quote)
}
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 {
@@ -171,14 +68,14 @@ func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, q
}
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.FX != nil {
fxSide = intent.FX.Side
fxSide = fxSideToProto(intent.FX.Side)
}
fromMoney, toMoney := resolveTradeAmounts(intent.Amount, fq, fxSide)
fromMoney, toMoney := resolveTradeAmounts(protoMoney(intent.Amount), fq, fxSide)
if fromMoney == nil {
fromMoney = cloneMoney(intent.Amount)
fromMoney = protoMoney(intent.Amount)
}
if toMoney == nil {
toMoney = cloneMoney(quote.GetExpectedSettlementAmount())
toMoney = cloneProtoMoney(quote.GetExpectedSettlementAmount())
}
rate := ""
if fq.GetPrice() != nil {
@@ -205,35 +102,6 @@ func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, q
return nil
}
func (p *paymentExecutor) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) {
intent := payment.Intent
source := intent.Source.ManagedWallet
destination := intent.Destination
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
return nil, merrors.InvalidArgument("chain: source managed wallet is required")
}
dest, err := toGatewayDestination(destination)
if err != nil {
return nil, err
}
amount := cloneMoney(intent.Amount)
if amount == nil {
return nil, merrors.InvalidArgument("chain: amount is required")
}
fees := feeBreakdownFromQuote(quote)
req := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey,
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: dest,
Amount: amount,
Fees: fees,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
return p.deps.gateway.client.SubmitTransfer(ctx, req)
}
func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if store == nil {
return errStorageUnavailable
@@ -271,79 +139,6 @@ func paymentDescription(payment *model.Payment) string {
return payment.PaymentRef
}
func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
source := intent.Source.Ledger
destination := intent.Destination.Ledger
if source == nil || strings.TrimSpace(source.LedgerAccountRef) == "" {
return "", "", merrors.InvalidArgument("ledger: source account is required")
}
to := ""
if destination != nil && strings.TrimSpace(destination.LedgerAccountRef) != "" {
to = strings.TrimSpace(destination.LedgerAccountRef)
} else if strings.TrimSpace(source.ContraLedgerAccountRef) != "" {
to = strings.TrimSpace(source.ContraLedgerAccountRef)
}
if to == "" {
return "", "", merrors.InvalidArgument("ledger: destination account is required")
}
return strings.TrimSpace(source.LedgerAccountRef), to, nil
}
func requiresLedger(payment *model.Payment) bool {
if payment == nil {
return false
}
if payment.Intent.Kind == model.PaymentKindFXConversion {
return true
}
return hasLedgerEndpoint(payment.Intent.Source) || hasLedgerEndpoint(payment.Intent.Destination)
}
func requiresChain(payment *model.Payment) bool {
if payment == nil {
return false
}
if !hasManagedWallet(payment.Intent.Source) {
return false
}
switch payment.Intent.Destination.Type {
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
return true
default:
return false
}
}
func hasLedgerEndpoint(endpoint model.PaymentEndpoint) bool {
return endpoint.Type == model.EndpointTypeLedger && endpoint.Ledger != nil && strings.TrimSpace(endpoint.Ledger.LedgerAccountRef) != ""
}
func hasManagedWallet(endpoint model.PaymentEndpoint) bool {
return endpoint.Type == model.EndpointTypeManagedWallet && endpoint.ManagedWallet != nil && strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef) != ""
}
func toGatewayDestination(endpoint model.PaymentEndpoint) (*chainv1.TransferDestination, error) {
switch endpoint.Type {
case model.EndpointTypeManagedWallet:
if endpoint.ManagedWallet == nil || strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef) == "" {
return nil, merrors.InvalidArgument("chain: destination managed wallet is required")
}
return &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef)},
}, nil
case model.EndpointTypeExternalChain:
if endpoint.ExternalChain == nil || strings.TrimSpace(endpoint.ExternalChain.Address) == "" {
return nil, merrors.InvalidArgument("chain: external address is required")
}
return &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(endpoint.ExternalChain.Address)},
Memo: strings.TrimSpace(endpoint.ExternalChain.Memo),
}, nil
default:
return nil, merrors.InvalidArgument("chain: unsupported destination type")
}
}
func applyTransferStatus(event *chainv1.TransferStatusChangedEvent, payment *model.Payment) {
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}

View File

@@ -0,0 +1,170 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
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, amount *moneyv1.Money) (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)
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,
}
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,
}
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 (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

@@ -0,0 +1,106 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
"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 string, quote *orchestratorv1.PaymentQuote) (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
}
req := rail.TransferRequest{
OrganizationRef: payment.OrganizationRef.Hex(),
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),
ClientReference: payment.PaymentRef,
DestinationMemo: memo,
}
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")
}
if action == model.RailOperationSend && quote != nil {
req.Fees = feeBreakdownFromQuote(quote)
}
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

@@ -0,0 +1,94 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if store == nil {
return errStorageUnavailable
}
if payment == nil {
return merrors.InvalidArgument("payment plan: payment is required")
}
plan := payment.PaymentPlan
if plan == nil || len(plan.Steps) == 0 {
return merrors.InvalidArgument("payment plan: steps are required")
}
intent := payment.Intent
sourceRail, _, err := railFromEndpoint(intent.Source, intent.Attributes, true)
if err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
destRail, _, err := railFromEndpoint(intent.Destination, intent.Attributes, false)
if err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
execQuote := executionQuote(payment, quote)
charges := ledgerChargesFromFeeLines(execQuote.GetFeeLines())
splitIdx := planSplitIndex(plan, sourceRail, destRail)
execPlan := ensureExecutionPlanForPlan(payment, plan, splitIdx)
asyncSubmitted := false
for idx, step := range plan.Steps {
if step == nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment plan: step is required", merrors.InvalidArgument("payment plan: step is required"))
}
execStep := execPlan.Steps[idx]
status := executionStepStatus(execStep)
switch status {
case executionStepStatusConfirmed, executionStepStatusSkipped:
continue
case executionStepStatusFailed:
payment.State = model.PaymentStateFailed
payment.FailureCode = failureCodeForStep(step)
return p.persistPayment(ctx, store, payment)
case executionStepStatusCancelled:
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
return p.persistPayment(ctx, store, payment)
case executionStepStatusSubmitted:
asyncSubmitted = true
if isConsumerExecutionStep(execStep) || step.Action == model.RailOperationObserveConfirm {
payment.State = model.PaymentStateSubmitted
return p.persistPayment(ctx, store, payment)
}
continue
}
if isConsumerExecutionStep(execStep) && !sourceStepsConfirmed(execPlan) {
payment.State = model.PaymentStateSubmitted
return p.persistPayment(ctx, store, payment)
}
async, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, charges, idx)
if err != nil {
return p.failPayment(ctx, store, payment, failureCodeForStep(step), strings.TrimSpace(err.Error()), err)
}
if async {
asyncSubmitted = true
if isConsumerExecutionStep(execStep) || step.Action == model.RailOperationObserveConfirm {
payment.State = model.PaymentStateSubmitted
return p.persistPayment(ctx, store, payment)
}
}
}
if asyncSubmitted && !executionPlanComplete(execPlan) {
payment.State = model.PaymentStateSubmitted
return p.persistPayment(ctx, store, payment)
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
return p.persistPayment(ctx, store, payment)
}

View File

@@ -0,0 +1,184 @@
package orchestrator
import (
"context"
"strings"
"testing"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mo "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/payments/rail"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) {
ctx := context.Background()
store := newStubPaymentsStore()
repo := &stubRepository{store: store}
transferRefs := []string{"send-1", "fee-1"}
sendCalls := 0
railGateway := &fakeRailGateway{
rail: "CRYPTO",
sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) {
ref := transferRefs[sendCalls]
sendCalls++
return rail.RailResult{ReferenceID: ref, Status: rail.TransferStatusPending}, nil
},
}
debitCalls := 0
creditCalls := 0
ledgerFake := &ledgerclient.Fake{
CreateTransactionFn: func(ctx context.Context, tx rail.LedgerTx) (string, error) {
if strings.EqualFold(tx.FromRail, "LEDGER") {
debitCalls++
return "debit-1", nil
}
if strings.EqualFold(tx.ToRail, "LEDGER") {
creditCalls++
return "credit-1", nil
}
return "", nil
},
}
payoutCalls := 0
mntxFake := &mntxclient.Fake{
CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
payoutCalls++
return &mntxv1.CardPayoutResponse{Payout: &mntxv1.CardPayoutState{PayoutId: "payout-1"}}, nil
},
}
svc := &Service{
logger: zap.NewNop(),
storage: repo,
deps: serviceDependencies{
railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{
"crypto-default": railGateway,
}, nil, nil),
ledger: ledgerDependency{
client: ledgerFake,
internal: ledgerFake,
},
mntx: mntxDependency{client: mntxFake},
cardRoutes: map[string]CardGatewayRoute{
defaultCardGateway: {
FundingAddress: "funding-address",
FeeWalletRef: "fee-wallet",
},
},
},
}
executor := newPaymentExecutor(&svc.deps, svc.logger, svc)
payment := &model.Payment{
PaymentRef: "pay-plan-1",
IdempotencyKey: "pay-plan-1",
OrganizationBoundBase: mo.OrganizationBoundBase{
OrganizationRef: primitive.NewObjectID(),
},
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Pan: "4111111111111111",
Cardholder: "Ada",
CardholderSurname: "Lovelace",
ExpMonth: 1,
ExpYear: 2030,
MaskedPan: "4111",
},
},
Attributes: map[string]string{
"ledger_credit_account_ref": "ledger:credit",
"ledger_debit_account_ref": "ledger:debit",
},
Customer: &model.Customer{
ID: "cust-1",
FirstName: "Ada",
LastName: "Lovelace",
IP: "1.2.3.4",
},
},
PaymentPlan: &model.PaymentPlan{
ID: "pay-plan-1",
IdempotencyKey: "pay-plan-1",
Steps: []*model.PaymentStep{
{Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}},
{Rail: model.RailCrypto, Action: model.RailOperationFee, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}},
{Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm},
{Rail: model.RailLedger, Action: model.RailOperationCredit, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}},
{Rail: model.RailLedger, Action: model.RailOperationDebit, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}},
{Rail: model.RailCardPayout, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}},
},
},
}
store.payments[payment.PaymentRef] = payment
if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil {
t.Fatalf("executePaymentPlan error: %v", err)
}
if sendCalls != 2 {
t.Fatalf("expected 2 rail sends, got %d", sendCalls)
}
if debitCalls != 0 || creditCalls != 0 {
t.Fatalf("unexpected ledger calls: debit=%d credit=%d", debitCalls, creditCalls)
}
if payoutCalls != 0 {
t.Fatalf("expected no payout before source confirmation, got %d", payoutCalls)
}
if payment.State != model.PaymentStateSubmitted {
t.Fatalf("expected submitted state, got %s", payment.State)
}
if payment.Execution == nil || payment.Execution.ChainTransferRef == "" || payment.Execution.FeeTransferRef == "" {
t.Fatalf("expected chain and fee transfer refs set")
}
if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != 6 {
t.Fatalf("expected execution plan with 6 steps")
}
if executionStepStatus(payment.ExecutionPlan.Steps[0]) != executionStepStatusSubmitted {
t.Fatalf("expected send step submitted")
}
if executionStepStatus(payment.ExecutionPlan.Steps[1]) != executionStepStatusSubmitted {
t.Fatalf("expected fee step submitted")
}
if executionStepStatus(payment.ExecutionPlan.Steps[2]) != executionStepStatusSubmitted {
t.Fatalf("expected observe step submitted")
}
setExecutionStepStatus(payment.ExecutionPlan.Steps[0], executionStepStatusConfirmed)
setExecutionStepStatus(payment.ExecutionPlan.Steps[1], executionStepStatusConfirmed)
setExecutionStepStatus(payment.ExecutionPlan.Steps[2], executionStepStatusConfirmed)
if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil {
t.Fatalf("executePaymentPlan resume error: %v", err)
}
if debitCalls != 1 || creditCalls != 1 {
t.Fatalf("expected ledger calls after source confirmation, debit=%d credit=%d", debitCalls, creditCalls)
}
if payoutCalls != 1 {
t.Fatalf("expected card payout submitted, got %d", payoutCalls)
}
if payment.Execution == nil || payment.Execution.CardPayoutRef == "" {
t.Fatalf("expected card payout ref set")
}
}

View File

@@ -0,0 +1,165 @@
package orchestrator
import (
"fmt"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage/model"
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 planSplitIndex(plan *model.PaymentPlan, sourceRail, destRail model.Rail) int {
if plan == nil {
return 0
}
if sourceRail == model.RailLedger {
for idx, step := range plan.Steps {
if step == nil {
continue
}
if step.Rail != model.RailLedger {
return idx
}
}
return len(plan.Steps)
}
for idx, step := range plan.Steps {
if step == nil {
continue
}
if step.Rail == model.RailLedger && step.Action == model.RailOperationCredit {
return idx
}
}
for idx, step := range plan.Steps {
if step == nil {
continue
}
if step.Rail == destRail && step.Action == model.RailOperationSend {
return idx
}
}
return len(plan.Steps)
}
func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan, splitIdx int) *model.ExecutionPlan {
if payment == nil || plan == nil {
return nil
}
execPlan := payment.ExecutionPlan
if execPlan == nil {
execPlan = &model.ExecutionPlan{}
payment.ExecutionPlan = execPlan
}
existing := map[string]*model.ExecutionStep{}
for _, step := range execPlan.Steps {
if step == nil || strings.TrimSpace(step.Code) == "" {
continue
}
existing[strings.TrimSpace(step.Code)] = step
}
steps := make([]*model.ExecutionStep, len(plan.Steps))
for idx, planStep := range plan.Steps {
code := planStepCode(idx)
step := existing[code]
if step == nil {
step = &model.ExecutionStep{Code: code}
}
if step.Description == "" {
step.Description = describePlanStep(planStep)
}
step.Amount = cloneMoney(planStep.Amount)
if idx < splitIdx {
setExecutionStepRole(step, executionStepRoleSource)
} else {
setExecutionStepRole(step, executionStepRoleConsumer)
}
if step.Metadata == nil || strings.TrimSpace(step.Metadata[executionStepMetadataStatus]) == "" {
setExecutionStepStatus(step, executionStepStatusPlanned)
}
steps[idx] = step
}
execPlan.Steps = steps
return execPlan
}
func executionPlanComplete(plan *model.ExecutionPlan) bool {
if plan == nil || len(plan.Steps) == 0 {
return false
}
for _, step := range plan.Steps {
if step == nil {
continue
}
status := executionStepStatus(step)
if status == executionStepStatusSkipped {
continue
}
if status != executionStepStatusConfirmed {
return false
}
}
return true
}
func planStepCode(idx int) string {
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)
}
return fmt.Sprintf("%s:plan:%d:%s:%s", base, idx, 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

@@ -0,0 +1,220 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/payments/rail"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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/bson/primitive"
)
func (p *paymentExecutor) postLedgerDebit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, quote *orchestratorv1.PaymentQuote) (string, error) {
if p.deps.ledger.internal == nil {
return "", merrors.Internal("ledger_client_unavailable")
}
tx, err := p.ledgerTxForAction(payment, amount, charges, idempotencyKey, idx, model.RailOperationDebit, quote)
if err != nil {
return "", err
}
return p.deps.ledger.internal.CreateTransaction(ctx, tx)
}
func (p *paymentExecutor) postLedgerCredit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, idempotencyKey string, idx int, quote *orchestratorv1.PaymentQuote) (string, error) {
if p.deps.ledger.internal == nil {
return "", merrors.Internal("ledger_client_unavailable")
}
tx, err := p.ledgerTxForAction(payment, amount, nil, idempotencyKey, idx, model.RailOperationCredit, quote)
if err != nil {
return "", err
}
return p.deps.ledger.internal.CreateTransaction(ctx, tx)
}
func (p *paymentExecutor) ledgerTxForAction(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 == primitive.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 := ""
switch action {
case model.RailOperationDebit:
fromRail = model.RailLedger
toRail = ledgerStepToRail(payment.PaymentPlan, idx, destRail)
accountRef, contraRef, err = ledgerDebitAccount(payment)
case model.RailOperationCredit:
fromRail = ledgerStepFromRail(payment.PaymentPlan, idx, sourceRail)
toRail = model.RailLedger
accountRef, contraRef, err = ledgerCreditAccount(payment)
externalRef = ledgerExternalReference(payment.ExecutionPlan, idx)
default:
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: unsupported action")
}
if err != nil {
return rail.LedgerTx{}, err
}
if action == model.RailOperationDebit && toRail == model.RailLedger {
toRail = model.RailUnspecified
}
if action == model.RailOperationCredit && 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 action == model.RailOperationDebit {
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,
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 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 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

@@ -0,0 +1,141 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/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"
)
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")
}
switch step.Action {
case model.RailOperationDebit:
amount, err := requireMoney(cloneMoney(step.Amount), "ledger debit amount")
if err != nil {
return false, err
}
ref, err := p.postLedgerDebit(ctx, payment, protoMoney(amount), charges, planStepIdempotencyKey(payment, idx, step), idx, quote)
if err != nil {
return false, err
}
ensureExecutionRefs(payment).DebitEntryRef = ref
setExecutionStepStatus(execStep, executionStepStatusConfirmed)
return false, nil
case model.RailOperationCredit:
amount, err := requireMoney(cloneMoney(step.Amount), "ledger credit amount")
if err != nil {
return false, err
}
ref, err := p.postLedgerCredit(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx, quote)
if err != nil {
return false, err
}
ensureExecutionRefs(payment).CreditEntryRef = ref
setExecutionStepStatus(execStep, executionStepStatusConfirmed)
return false, nil
case model.RailOperationFXConvert:
if err := p.applyFX(ctx, payment, quote, charges, paymentDescription(payment), cloneMetadata(payment.Metadata), ensureExecutionRefs(payment)); err != nil {
return false, err
}
setExecutionStepStatus(execStep, executionStepStatusConfirmed)
return false, nil
case model.RailOperationObserveConfirm:
setExecutionStepStatus(execStep, executionStepStatusSubmitted)
return true, nil
case model.RailOperationSend:
return p.executeSendStep(ctx, payment, step, execStep, quote, idx)
case model.RailOperationFee:
return p.executeFeeStep(ctx, payment, step, execStep, idx)
default:
return false, merrors.InvalidArgument("payment plan: unsupported action")
}
}
func (p *paymentExecutor) executeSendStep(ctx context.Context, payment *model.Payment, step *model.PaymentStep, execStep *model.ExecutionStep, quote *orchestratorv1.PaymentQuote, idx int) (bool, error) {
switch step.Rail {
case model.RailCrypto:
amount, err := requireMoney(cloneMoney(step.Amount), "crypto send amount")
if err != nil {
return false, err
}
if !p.deps.railGateways.available() {
return false, merrors.Internal("rail gateway unavailable")
}
req, err := p.buildCryptoTransferRequest(payment, amount, model.RailOperationSend, planStepIdempotencyKey(payment, idx, step), quote)
if err != nil {
return false, err
}
gw, err := p.deps.railGateways.resolve(ctx, step)
if err != nil {
return false, err
}
result, err := gw.Send(ctx, req)
if err != nil {
return false, err
}
execStep.TransferRef = strings.TrimSpace(result.ReferenceID)
exec := ensureExecutionRefs(payment)
if exec.ChainTransferRef == "" && execStep.TransferRef != "" {
exec.ChainTransferRef = execStep.TransferRef
}
setExecutionStepStatus(execStep, executionStepStatusSubmitted)
return true, nil
case model.RailCardPayout:
amount, err := requireMoney(cloneMoney(step.Amount), "card payout amount")
if err != nil {
return false, err
}
ref, err := p.submitCardPayoutPlan(ctx, payment, protoMoney(amount))
if err != nil {
return false, err
}
execStep.TransferRef = ref
ensureExecutionRefs(payment).CardPayoutRef = ref
setExecutionStepStatus(execStep, executionStepStatusSubmitted)
return true, nil
case model.RailFiatOnRamp:
return false, merrors.InvalidArgument("payment plan: fiat on-ramp execution not implemented")
default:
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) {
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")
}
req, err := p.buildCryptoTransferRequest(payment, amount, model.RailOperationFee, planStepIdempotencyKey(payment, idx, step), nil)
if err != nil {
return false, err
}
gw, err := p.deps.railGateways.resolve(ctx, step)
if err != nil {
return false, err
}
result, err := gw.Send(ctx, req)
if err != nil {
return false, err
}
execStep.TransferRef = strings.TrimSpace(result.ReferenceID)
if execStep.TransferRef != "" {
ensureExecutionRefs(payment).FeeTransferRef = execStep.TransferRef
}
setExecutionStepStatus(execStep, executionStepStatusSubmitted)
return true, nil
default:
return false, merrors.InvalidArgument("payment plan: unsupported fee rail")
}
}

View File

@@ -0,0 +1,23 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/orchestrator/storage/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
// RouteStore exposes routing definitions for plan construction.
type RouteStore interface {
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
}
// GatewayRegistry exposes gateway instances for capability-based selection.
type GatewayRegistry interface {
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
}
// PlanBuilder constructs ordered payment plans from intents, quotes, and routing policy.
type PlanBuilder interface {
Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
}

View File

@@ -0,0 +1,51 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
type defaultPlanBuilder struct{}
func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
if payment == nil {
return nil, merrors.InvalidArgument("plan builder: payment is required")
}
if routes == nil {
return nil, merrors.InvalidArgument("plan builder: routes store is required")
}
intent := payment.Intent
if intent.Kind == model.PaymentKindFXConversion {
return buildFXConversionPlan(payment)
}
sourceRail, sourceNetwork, err := railFromEndpoint(intent.Source, intent.Attributes, true)
if err != nil {
return nil, err
}
destRail, destNetwork, err := railFromEndpoint(intent.Destination, intent.Attributes, false)
if err != nil {
return nil, err
}
if sourceRail == model.RailUnspecified || destRail == model.RailUnspecified {
return nil, merrors.InvalidArgument("plan builder: source and destination rails are required")
}
if sourceRail == destRail {
if sourceRail == model.RailLedger {
return buildLedgerTransferPlan(payment)
}
return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment")
}
path, err := buildRoutePath(ctx, routes, sourceRail, destRail, sourceNetwork, destNetwork)
if err != nil {
return nil, err
}
return b.buildPlanFromRoutePath(ctx, payment, quote, path, sourceRail, destRail, sourceNetwork, destNetwork, gateways)
}

View File

@@ -0,0 +1,202 @@
package orchestrator
import (
"context"
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
ctx := context.Background()
builder := &defaultPlanBuilder{}
payment := &model.Payment{
PaymentRef: "pay-1",
IdempotencyKey: "idem-1",
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-1",
Asset: &paymenttypes.Asset{
Chain: "TRON",
TokenSymbol: "USDT",
},
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{MaskedPan: "4111"},
},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"},
},
LastQuote: &model.PaymentQuoteSnapshot{
ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USDT", Amount: "95"},
ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "5"},
},
}
quote := &orchestratorv1.PaymentQuote{
ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "95"},
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"},
}
routes := &stubRouteStore{
routes: []*model.PaymentRoute{
{FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON", RequiresObserve: true, IsEnabled: true},
{FromRail: model.RailProviderSettlement, ToRail: model.RailLedger, IsEnabled: true},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout, IsEnabled: true},
},
}
registry := &stubGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-tron",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
CanSendFee: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
{
ID: "settlement",
Rail: model.RailProviderSettlement,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
RequiresObserveConfirm: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
{
ID: "card",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
},
}
plan, err := builder.Build(ctx, payment, quote, routes, registry)
if err != nil {
t.Fatalf("expected plan, got error: %v", err)
}
if plan == nil {
t.Fatal("expected plan")
}
if len(plan.Steps) != 6 {
t.Fatalf("expected 6 steps, got %d", len(plan.Steps))
}
assertPlanStep(t, plan.Steps[0], model.RailCrypto, model.RailOperationSend, "crypto-tron", "USDT", "100")
assertPlanStep(t, plan.Steps[1], model.RailCrypto, model.RailOperationFee, "crypto-tron", "USDT", "5")
assertPlanStep(t, plan.Steps[2], model.RailProviderSettlement, model.RailOperationObserveConfirm, "settlement", "", "")
assertPlanStep(t, plan.Steps[3], model.RailLedger, model.RailOperationCredit, "", "USDT", "95")
assertPlanStep(t, plan.Steps[4], model.RailLedger, model.RailOperationDebit, "", "USDT", "95")
assertPlanStep(t, plan.Steps[5], model.RailCardPayout, model.RailOperationSend, "card", "USDT", "95")
}
func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
ctx := context.Background()
builder := &defaultPlanBuilder{}
payment := &model.Payment{
PaymentRef: "pay-1",
IdempotencyKey: "idem-1",
Intent: model.PaymentIntent{
Kind: model.PaymentKindPayout,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-1",
Asset: &paymenttypes.Asset{Chain: "TRON"},
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{MaskedPan: "4111"},
},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "10"},
},
}
routes := &stubRouteStore{}
registry := &stubGatewayRegistry{}
plan, err := builder.Build(ctx, payment, &orchestratorv1.PaymentQuote{}, routes, registry)
if err == nil {
t.Fatalf("expected error, got plan: %#v", plan)
}
}
// --- test doubles ---
type stubRouteStore struct {
routes []*model.PaymentRoute
}
func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) {
items := make([]*model.PaymentRoute, 0, len(s.routes))
for _, route := range s.routes {
if route == nil {
continue
}
if filter != nil && filter.IsEnabled != nil {
if route.IsEnabled != *filter.IsEnabled {
continue
}
}
items = append(items, route)
}
return &model.PaymentRouteList{Items: items}, nil
}
type stubGatewayRegistry struct {
items []*model.GatewayInstanceDescriptor
}
func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) {
return s.items, nil
}
func assertPlanStep(t *testing.T, step *model.PaymentStep, rail model.Rail, action model.RailOperation, gatewayID, currency, amount string) {
t.Helper()
if step == nil {
t.Fatal("expected step")
}
if step.Rail != rail {
t.Fatalf("expected rail %s, got %s", rail, step.Rail)
}
if step.Action != action {
t.Fatalf("expected action %s, got %s", action, step.Action)
}
if step.GatewayID != gatewayID {
t.Fatalf("expected gateway %q, got %q", gatewayID, step.GatewayID)
}
if currency == "" && amount == "" {
if step.Amount != nil && step.Amount.Amount != "" {
t.Fatalf("expected empty amount, got %v", step.Amount)
}
return
}
if step.Amount == nil {
t.Fatalf("expected amount %s %s, got nil", currency, amount)
}
if step.Amount.GetCurrency() != currency || step.Amount.GetAmount() != amount {
t.Fatalf("expected amount %s %s, got %s %s", currency, amount, step.Amount.GetCurrency(), step.Amount.GetAmount())
}
}

View File

@@ -0,0 +1,135 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
override := railOverrideFromAttributes(attrs, isSource)
if override != model.RailUnspecified {
return override, networkFromEndpoint(endpoint), nil
}
switch endpoint.Type {
case model.EndpointTypeLedger:
return model.RailLedger, "", nil
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
return model.RailCrypto, networkFromEndpoint(endpoint), nil
case model.EndpointTypeCard:
return model.RailCardPayout, "", nil
default:
return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint")
}
}
func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail {
if len(attrs) == 0 {
return model.RailUnspecified
}
keys := []string{"source_rail", "sourceRail"}
if !isSource {
keys = []string{"destination_rail", "destinationRail"}
}
lookup := map[string]struct{}{}
for _, key := range keys {
lookup[strings.ToLower(key)] = struct{}{}
}
for key, value := range attrs {
if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok {
continue
}
rail := parseRailValue(value)
if rail != model.RailUnspecified {
return rail
}
}
return model.RailUnspecified
}
func parseRailValue(value string) model.Rail {
val := strings.ToUpper(strings.TrimSpace(value))
switch val {
case string(model.RailCrypto):
return model.RailCrypto
case string(model.RailProviderSettlement):
return model.RailProviderSettlement
case string(model.RailLedger):
return model.RailLedger
case string(model.RailCardPayout):
return model.RailCardPayout
case string(model.RailFiatOnRamp):
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func gatewayNetworkForRail(rail model.Rail, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string {
switch rail {
case model.RailCrypto:
if sourceRail == model.RailCrypto {
return strings.ToUpper(strings.TrimSpace(sourceNetwork))
}
if destRail == model.RailCrypto {
return strings.ToUpper(strings.TrimSpace(destNetwork))
}
case model.RailFiatOnRamp:
if sourceRail == model.RailFiatOnRamp {
return strings.ToUpper(strings.TrimSpace(sourceNetwork))
}
if destRail == model.RailFiatOnRamp {
return strings.ToUpper(strings.TrimSpace(destNetwork))
}
}
return ""
}
func networkFromEndpoint(endpoint model.PaymentEndpoint) string {
switch endpoint.Type {
case model.EndpointTypeManagedWallet:
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()))
}
case model.EndpointTypeExternalChain:
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain()))
}
}
return ""
}
func chainNetworkName(network chainv1.ChainNetwork) string {
switch network {
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
return "ETH"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
return "TRON"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
return "TRON_NILE"
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
return "ARBITRUM"
default:
name := strings.TrimSpace(network.String())
name = strings.TrimPrefix(name, "CHAIN_NETWORK_")
return strings.ToUpper(name)
}
}
func chainNetworkFromName(network string) chainv1.ChainNetwork {
name := strings.ToUpper(strings.TrimSpace(network))
switch name {
case "ETH", "ETHEREUM", "ETHEREUM_MAINNET":
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET
case "TRON", "TRON_MAINNET":
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET
case "TRON_NILE":
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE
case "ARBITRUM", "ARBITRUM_ONE":
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
}

View File

@@ -0,0 +1,213 @@
package orchestrator
import (
"context"
"sort"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func ensureGatewayForAction(ctx context.Context, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) (*model.GatewayInstanceDescriptor, error) {
if registry == nil {
return nil, merrors.InvalidArgument("plan builder: gateway registry is required")
}
if gw, ok := cache[rail]; ok && gw != nil {
if err := validateGatewayAction(gw, network, amount, action, dir); err != nil {
return nil, err
}
return gw, nil
}
gw, err := selectGateway(ctx, registry, rail, network, amount, action, dir)
if err != nil {
return nil, err
}
cache[rail] = gw
return gw, nil
}
func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) error {
if gw == nil {
return merrors.InvalidArgument("plan builder: gateway instance is required")
}
currency := ""
amt := decimal.Zero
if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" {
value, err := decimalFromMoney(amount)
if err != nil {
return err
}
amt = value
currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
}
if !isGatewayEligible(gw, gw.Rail, network, currency, action, dir, amt) {
return merrors.InvalidArgument("plan builder: gateway instance is not eligible")
}
return nil
}
type sendDirection int
const (
sendDirectionAny sendDirection = iota
sendDirectionOut
sendDirectionIn
)
func sendDirectionForRail(rail model.Rail) sendDirection {
switch rail {
case model.RailFiatOnRamp:
return sendDirectionIn
default:
return sendDirectionOut
}
}
func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) (*model.GatewayInstanceDescriptor, error) {
if registry == nil {
return nil, merrors.InvalidArgument("plan builder: gateway registry is required")
}
all, err := registry.List(ctx)
if err != nil {
return nil, err
}
if len(all) == 0 {
return nil, merrors.InvalidArgument("plan builder: no gateway instances available")
}
currency := ""
amt := decimal.Zero
if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" {
amt, err = decimalFromMoney(amount)
if err != nil {
return nil, err
}
currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
}
network = strings.ToUpper(strings.TrimSpace(network))
eligible := make([]*model.GatewayInstanceDescriptor, 0)
for _, gw := range all {
if !isGatewayEligible(gw, rail, network, currency, action, dir, amt) {
continue
}
eligible = append(eligible, gw)
}
if len(eligible) == 0 {
return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found")
}
sort.Slice(eligible, func(i, j int) bool {
return eligible[i].ID < eligible[j].ID
})
return eligible[0], nil
}
func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) bool {
if gw == nil || !gw.IsEnabled {
return false
}
if gw.Rail != rail {
return false
}
if network != "" && gw.Network != "" && !strings.EqualFold(gw.Network, network) {
return false
}
if currency != "" && len(gw.Currencies) > 0 {
found := false
for _, c := range gw.Currencies {
if strings.EqualFold(c, currency) {
found = true
break
}
}
if !found {
return false
}
}
if !capabilityAllowsAction(gw.Capabilities, action, dir) {
return false
}
if currency != "" {
if !amountWithinLimits(gw.Limits, currency, amount, action) {
return false
}
}
return true
}
func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool {
switch action {
case model.RailOperationSend:
switch dir {
case sendDirectionOut:
return cap.CanPayOut
case sendDirectionIn:
return cap.CanPayIn
default:
return cap.CanPayIn || cap.CanPayOut
}
case model.RailOperationFee:
return cap.CanSendFee
case model.RailOperationObserveConfirm:
return cap.RequiresObserveConfirm
default:
return true
}
}
func amountWithinLimits(limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) bool {
min := firstLimitValue(limits.MinAmount, "")
max := firstLimitValue(limits.MaxAmount, "")
perTxMin := firstLimitValue(limits.PerTxMinAmount, "")
perTxMax := firstLimitValue(limits.PerTxMaxAmount, "")
maxFee := firstLimitValue(limits.PerTxMaxFee, "")
if override, ok := limits.CurrencyLimits[currency]; ok {
min = firstLimitValue(override.MinAmount, min)
max = firstLimitValue(override.MaxAmount, max)
if action == model.RailOperationFee {
maxFee = firstLimitValue(override.MaxFee, maxFee)
}
}
if min != "" {
if val, err := decimal.NewFromString(min); err == nil && amount.LessThan(val) {
return false
}
}
if perTxMin != "" {
if val, err := decimal.NewFromString(perTxMin); err == nil && amount.LessThan(val) {
return false
}
}
if max != "" {
if val, err := decimal.NewFromString(max); err == nil && amount.GreaterThan(val) {
return false
}
}
if perTxMax != "" {
if val, err := decimal.NewFromString(perTxMax); err == nil && amount.GreaterThan(val) {
return false
}
}
if action == model.RailOperationFee && maxFee != "" {
if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) {
return false
}
}
return true
}
func firstLimitValue(primary, fallback string) string {
val := strings.TrimSpace(primary)
if val != "" {
return val
}
return strings.TrimSpace(fallback)
}

View File

@@ -0,0 +1,90 @@
package orchestrator
import (
"time"
"github.com/tech/sendico/payments/orchestrator/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"
)
func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
if payment == nil {
return nil, merrors.InvalidArgument("plan builder: payment is required")
}
step := &model.PaymentStep{
Rail: model.RailLedger,
Action: model.RailOperationFXConvert,
Amount: cloneMoney(payment.Intent.Amount),
}
return &model.PaymentPlan{
ID: payment.PaymentRef,
Steps: []*model.PaymentStep{step},
IdempotencyKey: payment.IdempotencyKey,
CreatedAt: planTimestamp(payment),
}, nil
}
func buildLedgerTransferPlan(payment *model.Payment) (*model.PaymentPlan, error) {
if payment == nil {
return nil, merrors.InvalidArgument("plan builder: payment is required")
}
amount := cloneMoney(payment.Intent.Amount)
steps := []*model.PaymentStep{
{
Rail: model.RailLedger,
Action: model.RailOperationDebit,
Amount: cloneMoney(amount),
},
{
Rail: model.RailLedger,
Action: model.RailOperationCredit,
Amount: cloneMoney(amount),
},
}
return &model.PaymentPlan{
ID: payment.PaymentRef,
Steps: steps,
IdempotencyKey: payment.IdempotencyKey,
CreatedAt: planTimestamp(payment),
}, nil
}
func resolveSettlementAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money {
if quote != nil && quote.GetExpectedSettlementAmount() != nil {
return moneyFromProto(quote.GetExpectedSettlementAmount())
}
if payment != nil && payment.LastQuote != nil {
return cloneMoney(payment.LastQuote.ExpectedSettlementAmount)
}
return cloneMoney(fallback)
}
func resolveFeeAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *paymenttypes.Money {
if quote != nil && quote.GetExpectedFeeTotal() != nil {
return moneyFromProto(quote.GetExpectedFeeTotal())
}
if payment != nil && payment.LastQuote != nil {
return cloneMoney(payment.LastQuote.ExpectedFeeTotal)
}
return nil
}
func isPositiveMoney(amount *paymenttypes.Money) bool {
if amount == nil {
return false
}
val, err := decimalFromMoney(amount)
if err != nil {
return false
}
return val.IsPositive()
}
func planTimestamp(payment *model.Payment) time.Time {
if payment != nil && !payment.CreatedAt.IsZero() {
return payment.CreatedAt.UTC()
}
return time.Now().UTC()
}

View File

@@ -0,0 +1,149 @@
package orchestrator
import (
"context"
"sort"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func buildRoutePath(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) ([]*model.PaymentRoute, error) {
if routes == nil {
return nil, merrors.InvalidArgument("plan builder: routes store is required")
}
enabled := true
result, err := routes.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled})
if err != nil {
return nil, err
}
if result == nil || len(result.Items) == 0 {
return nil, merrors.InvalidArgument("plan builder: route not allowed")
}
network := routeNetworkForPath(sourceRail, destRail, sourceNetwork, destNetwork)
path, err := routePath(result.Items, sourceRail, destRail, network)
if err != nil {
return nil, err
}
return path, nil
}
func routePath(routes []*model.PaymentRoute, sourceRail, destRail model.Rail, network string) ([]*model.PaymentRoute, error) {
if sourceRail == destRail {
return nil, nil
}
adjacency := map[model.Rail][]*model.PaymentRoute{}
for _, route := range routes {
if route == nil || !route.IsEnabled {
continue
}
from := route.FromRail
to := route.ToRail
if from == "" || to == "" || from == model.RailUnspecified || to == model.RailUnspecified {
continue
}
adjacency[from] = append(adjacency[from], route)
}
for rail, edges := range adjacency {
sort.Slice(edges, func(i, j int) bool {
pi := routePriority(edges[i], network)
pj := routePriority(edges[j], network)
if pi != pj {
return pi < pj
}
if edges[i].ToRail != edges[j].ToRail {
return edges[i].ToRail < edges[j].ToRail
}
if edges[i].Network != edges[j].Network {
return edges[i].Network < edges[j].Network
}
return edges[i].ID.Hex() < edges[j].ID.Hex()
})
adjacency[rail] = edges
}
queue := []model.Rail{sourceRail}
visited := map[model.Rail]bool{sourceRail: true}
parents := map[model.Rail]*model.PaymentRoute{}
found := false
for len(queue) > 0 && !found {
current := queue[0]
queue = queue[1:]
for _, route := range adjacency[current] {
if !routeMatchesNetwork(route, network) {
continue
}
next := route.ToRail
if visited[next] {
continue
}
visited[next] = true
parents[next] = route
if next == destRail {
found = true
break
}
queue = append(queue, next)
}
}
if !found {
return nil, merrors.InvalidArgument("plan builder: route not allowed")
}
path := make([]*model.PaymentRoute, 0)
for current := destRail; current != sourceRail; {
edge := parents[current]
if edge == nil {
return nil, merrors.InvalidArgument("plan builder: route not allowed")
}
path = append(path, edge)
current = edge.FromRail
}
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return path, nil
}
func routeNetworkForPath(sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string {
if sourceRail == model.RailCrypto || sourceRail == model.RailFiatOnRamp {
return strings.ToUpper(strings.TrimSpace(sourceNetwork))
}
if destRail == model.RailCrypto || destRail == model.RailFiatOnRamp {
return strings.ToUpper(strings.TrimSpace(destNetwork))
}
return ""
}
func routeMatchesNetwork(route *model.PaymentRoute, network string) bool {
if route == nil {
return false
}
routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network))
if strings.TrimSpace(network) == "" {
return routeNetwork == ""
}
if routeNetwork == "" {
return true
}
return strings.EqualFold(routeNetwork, network)
}
func routePriority(route *model.PaymentRoute, network string) int {
if route == nil {
return 2
}
routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network))
if network != "" && strings.EqualFold(routeNetwork, network) {
return 0
}
if routeNetwork == "" {
return 1
}
return 2
}

View File

@@ -0,0 +1,257 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/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"
)
func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, path []*model.PaymentRoute, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) {
if len(path) == 0 {
return nil, merrors.InvalidArgument("plan builder: route path is required")
}
sourceAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount")
if err != nil {
return nil, err
}
settlementAmount, err := requireMoney(resolveSettlementAmount(payment, quote, sourceAmount), "settlement amount")
if err != nil {
return nil, err
}
feeAmount := resolveFeeAmount(payment, quote)
feeRequired := isPositiveMoney(feeAmount)
var payoutAmount *paymenttypes.Money
if destRail == model.RailCardPayout {
payoutAmount, err = cardPayoutAmount(payment)
if err != nil {
return nil, err
}
}
ledgerCreditAmount := settlementAmount
ledgerDebitAmount := settlementAmount
if destRail == model.RailCardPayout && payoutAmount != nil {
ledgerDebitAmount = payoutAmount
}
observeRequired := observeRailsFromPath(path)
intermediate := intermediateRailsFromPath(path, sourceRail, destRail)
steps := make([]*model.PaymentStep, 0)
gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{}
observeAdded := map[model.Rail]bool{}
useSourceSend := isSendSourceRail(sourceRail)
useDestSend := isSendDestinationRail(destRail)
for idx, edge := range path {
if edge == nil {
continue
}
from := edge.FromRail
to := edge.ToRail
if from == model.RailLedger {
if _, err := requireMoney(ledgerDebitAmount, "ledger debit amount"); err != nil {
return nil, err
}
steps = append(steps, &model.PaymentStep{
Rail: model.RailLedger,
Action: model.RailOperationDebit,
Amount: cloneMoney(ledgerDebitAmount),
})
}
if idx == 0 && useSourceSend && from == sourceRail {
network := gatewayNetworkForRail(from, sourceRail, destRail, sourceNetwork, destNetwork)
gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, from, network, sourceAmount, model.RailOperationSend, sendDirectionForRail(from))
if err != nil {
return nil, err
}
steps = append(steps, &model.PaymentStep{
Rail: from,
GatewayID: gw.ID,
Action: model.RailOperationSend,
Amount: cloneMoney(sourceAmount),
})
if feeRequired {
if err := validateGatewayAction(gw, network, feeAmount, model.RailOperationFee, sendDirectionForRail(from)); err != nil {
return nil, err
}
steps = append(steps, &model.PaymentStep{
Rail: from,
GatewayID: gw.ID,
Action: model.RailOperationFee,
Amount: cloneMoney(feeAmount),
})
}
if shouldObserveRail(from, observeRequired, gw) && !observeAdded[from] {
observeAmount := observeAmountForRail(from, sourceAmount, settlementAmount, payoutAmount)
if err := validateGatewayAction(gw, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny); err != nil {
return nil, err
}
steps = append(steps, &model.PaymentStep{
Rail: from,
GatewayID: gw.ID,
Action: model.RailOperationObserveConfirm,
})
observeAdded[from] = true
}
}
if intermediate[to] && !observeAdded[to] {
observeAmount := observeAmountForRail(to, sourceAmount, settlementAmount, payoutAmount)
network := gatewayNetworkForRail(to, sourceRail, destRail, sourceNetwork, destNetwork)
gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, to, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny)
if err != nil {
return nil, err
}
steps = append(steps, &model.PaymentStep{
Rail: to,
GatewayID: gw.ID,
Action: model.RailOperationObserveConfirm,
})
observeAdded[to] = true
}
if to == model.RailLedger {
if _, err := requireMoney(ledgerCreditAmount, "ledger credit amount"); err != nil {
return nil, err
}
steps = append(steps, &model.PaymentStep{
Rail: model.RailLedger,
Action: model.RailOperationCredit,
Amount: cloneMoney(ledgerCreditAmount),
})
}
if idx == len(path)-1 && useDestSend && to == destRail {
if payoutAmount == nil {
payoutAmount = settlementAmount
}
network := gatewayNetworkForRail(to, sourceRail, destRail, sourceNetwork, destNetwork)
gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, to, network, payoutAmount, model.RailOperationSend, sendDirectionForRail(to))
if err != nil {
return nil, err
}
steps = append(steps, &model.PaymentStep{
Rail: to,
GatewayID: gw.ID,
Action: model.RailOperationSend,
Amount: cloneMoney(payoutAmount),
})
if shouldObserveRail(to, observeRequired, gw) && !observeAdded[to] {
observeAmount := observeAmountForRail(to, sourceAmount, settlementAmount, payoutAmount)
if err := validateGatewayAction(gw, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny); err != nil {
return nil, err
}
steps = append(steps, &model.PaymentStep{
Rail: to,
GatewayID: gw.ID,
Action: model.RailOperationObserveConfirm,
})
observeAdded[to] = true
}
}
}
if len(steps) == 0 {
return nil, merrors.InvalidArgument("plan builder: empty payment plan")
}
return &model.PaymentPlan{
ID: payment.PaymentRef,
Steps: steps,
IdempotencyKey: payment.IdempotencyKey,
CreatedAt: planTimestamp(payment),
}, nil
}
func observeRailsFromPath(path []*model.PaymentRoute) map[model.Rail]bool {
observe := map[model.Rail]bool{}
for _, edge := range path {
if edge == nil || !edge.RequiresObserve {
continue
}
rail := edge.ToRail
if rail == model.RailLedger || rail == model.RailUnspecified {
rail = edge.FromRail
}
if rail == model.RailLedger || rail == model.RailUnspecified {
continue
}
observe[rail] = true
}
return observe
}
func intermediateRailsFromPath(path []*model.PaymentRoute, sourceRail, destRail model.Rail) map[model.Rail]bool {
intermediate := map[model.Rail]bool{}
for _, edge := range path {
if edge == nil {
continue
}
rail := edge.ToRail
if rail == model.RailLedger || rail == sourceRail || rail == destRail || rail == model.RailUnspecified {
continue
}
intermediate[rail] = true
}
return intermediate
}
func isSendSourceRail(rail model.Rail) bool {
switch rail {
case model.RailCrypto, model.RailFiatOnRamp:
return true
default:
return false
}
}
func isSendDestinationRail(rail model.Rail) bool {
return rail == model.RailCardPayout
}
func shouldObserveRail(rail model.Rail, observeRequired map[model.Rail]bool, gw *model.GatewayInstanceDescriptor) bool {
if observeRequired[rail] {
return true
}
if gw != nil && gw.Capabilities.RequiresObserveConfirm {
return true
}
return false
}
func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money {
switch rail {
case model.RailCrypto, model.RailFiatOnRamp:
if source != nil {
return source
}
case model.RailProviderSettlement:
if settlement != nil {
return settlement
}
case model.RailCardPayout:
if payout != nil {
return payout
}
}
if settlement != nil {
return settlement
}
return source
}
func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) {
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("plan builder: " + label + " is required")
}
return amount, nil
}

View File

@@ -39,7 +39,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
feeBaseAmount = cloneProtoMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
@@ -87,9 +87,9 @@ func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestrato
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
amount := cloneProtoMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
amount = cloneProtoMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
@@ -123,7 +123,7 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
Amount: cloneProtoMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
@@ -191,14 +191,14 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
params.BaseAmount = cloneProtoMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
params.QuoteAmount = cloneProtoMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
params.BaseAmount = cloneProtoMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
params.BaseAmount = cloneProtoMoney(amount)
}
}

View File

@@ -0,0 +1,41 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/pkg/payments/rail"
)
type fakeRailGateway struct {
rail string
network string
capabilities rail.RailCapabilities
sendFn func(context.Context, rail.TransferRequest) (rail.RailResult, error)
observeFn func(context.Context, string) (rail.ObserveResult, error)
}
func (f *fakeRailGateway) Rail() string {
return f.rail
}
func (f *fakeRailGateway) Network() string {
return f.network
}
func (f *fakeRailGateway) Capabilities() rail.RailCapabilities {
return f.capabilities
}
func (f *fakeRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) {
if f.sendFn != nil {
return f.sendFn(ctx, req)
}
return rail.RailResult{ReferenceID: "transfer-1", Status: rail.TransferStatusPending}, nil
}
func (f *fakeRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) {
if f.observeFn != nil {
return f.observeFn(ctx, referenceID)
}
return rail.ObserveResult{ReferenceID: referenceID, Status: rail.TransferStatusPending}, nil
}

View File

@@ -44,10 +44,13 @@ type serviceDependencies struct {
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
railGateways railGatewayDependency
oracle oracleDependency
mntx mntxDependency
gatewayRegistry GatewayRegistry
cardRoutes map[string]CardGatewayRoute
feeLedgerAccounts map[string]string
planBuilder PlanBuilder
}
type handlerSet struct {
@@ -83,7 +86,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
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.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"), svc.submitCardPayout, svc.resumePaymentPlan)
svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc)
return svc
@@ -97,7 +100,7 @@ func (s *Service) ensureHandlers() {
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.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"), s.submitCardPayout, s.resumePaymentPlan)
}
if s.comp.executor == nil {
s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s)
@@ -181,3 +184,11 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
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)
}

View File

@@ -5,11 +5,13 @@ import (
"testing"
"time"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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/bson/primitive"
)
@@ -49,7 +51,7 @@ func TestNewPayment(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED,
SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
}
quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"}
p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote)
@@ -59,7 +61,7 @@ func TestNewPayment(t *testing.T) {
if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" {
t.Fatalf("intent not copied")
}
if p.Intent.SettlementMode != orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED {
if p.Intent.SettlementMode != model.SettlementModeFixReceived {
t.Fatalf("settlement mode not preserved")
}
if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" {
@@ -143,12 +145,27 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
store := newHelperPaymentStore()
ledgerFake := &ledgerclient.Fake{
PostDebitWithChargesFn: func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "debit-1"}, nil
},
PostCreditWithChargesFn: func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil
},
}
svc := NewService(logger, stubRepo{
payments: store,
}, WithClock(clockpkg.NewSystem()))
routes: &stubRoutesStore{},
}, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake))
svc.ensureHandlers()
intent := &orchestratorv1.PaymentIntent{
Source: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}},
},
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}},
},
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
}
req := &orchestratorv1.InitiatePaymentRequest{
@@ -173,16 +190,33 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
store := newHelperPaymentStore()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
intent := &orchestratorv1.PaymentIntent{
Source: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}},
},
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}},
},
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
}
ledgerFake := &ledgerclient.Fake{
PostDebitWithChargesFn: func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "debit-1"}, nil
},
PostCreditWithChargesFn: func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil
},
}
svc := NewService(logger, stubRepo{
payments: store,
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
}, WithClock(clockpkg.NewSystem()))
routes: &stubRoutesStore{},
}, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake))
svc.ensureHandlers()
req := &orchestratorv1.InitiatePaymentRequest{
@@ -210,12 +244,14 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) {
type stubRepo struct {
payments storage.PaymentsStore
quotes storage.QuotesStore
routes storage.RoutesStore
pingErr error
}
func (s stubRepo) Ping(context.Context) error { return s.pingErr }
func (s stubRepo) Payments() storage.PaymentsStore { return s.payments }
func (s stubRepo) Quotes() storage.QuotesStore { return s.quotes }
func (s stubRepo) Routes() storage.RoutesStore { return s.routes }
type helperPaymentStore struct {
byRef map[string]*model.Payment

View File

@@ -7,13 +7,14 @@ import (
"testing"
"time"
chainclient "github.com/tech/sendico/gateway/chain/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
mo "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/payments/rail"
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"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
@@ -33,11 +34,17 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) {
clock: testClock{now: time.Now()},
storage: repo,
deps: serviceDependencies{
ledger: ledgerDependency{client: &ledgerclient.Fake{
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
},
}},
ledger: func() ledgerDependency {
fake := &ledgerclient.Fake{
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
},
}
return ledgerDependency{
client: fake,
internal: fake,
}
}(),
},
}
@@ -55,7 +62,7 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) {
Type: model.EndpointTypeLedger,
Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"},
},
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"},
},
}
store.payments[payment.PaymentRef] = payment
@@ -85,17 +92,56 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
ctx := context.Background()
store := newStubPaymentsStore()
repo := &stubRepository{store: store}
routes := &stubRoutesStore{
routes: []*model.PaymentRoute{
{FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON", RequiresObserve: true, IsEnabled: true},
{FromRail: model.RailProviderSettlement, ToRail: model.RailLedger, IsEnabled: true},
},
}
repo := &stubRepository{store: store, routes: routes}
svc := &Service{
logger: zap.NewNop(),
clock: testClock{now: time.Now()},
storage: repo,
deps: serviceDependencies{
gateway: gatewayDependency{client: &chainclient.Fake{
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
return nil, errors.New("chain failure")
railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{
"crypto-tron": &fakeRailGateway{
rail: "CRYPTO",
sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) {
return rail.RailResult{}, errors.New("chain failure")
},
},
}},
}, nil, nil),
gatewayRegistry: &stubGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-tron",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
{
ID: "settlement",
Rail: model.RailProviderSettlement,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
RequiresObserveConfirm: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
},
},
cardRoutes: map[string]CardGatewayRoute{
defaultCardGateway: {
FundingAddress: "funding-address",
},
},
},
}
@@ -109,15 +155,17 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
Asset: &paymenttypes.Asset{
Chain: "TRON",
TokenSymbol: "USDT",
},
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-dst",
},
Type: model.EndpointTypeLedger,
Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"},
},
Amount: &moneyv1.Money{Currency: "USD", Amount: "50"},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"},
},
}
store.payments[payment.PaymentRef] = payment
@@ -150,7 +198,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
clock: testClock{now: time.Now()},
storage: &stubRepository{store: store},
}
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, nil, nil)
req := &orchestratorv1.ProcessTransferUpdateRequest{
Event: &chainv1.TransferStatusChangedEvent{
@@ -170,6 +218,107 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
}
}
func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) {
ctx := context.Background()
payment := &model.Payment{
PaymentRef: "pay-card",
State: model.PaymentStateSubmitted,
Intent: model.PaymentIntent{
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{MaskedPan: "4111"},
},
},
Execution: &model.ExecutionRefs{ChainTransferRef: "fund-1"},
}
plan := ensureExecutionPlan(payment)
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
fundStep.TransferRef = "fund-1"
setExecutionStepRole(fundStep, executionStepRoleSource)
setExecutionStepStatus(fundStep, executionStepStatusSubmitted)
feeStep := ensureExecutionStep(plan, stepCodeFeeTransfer)
feeStep.TransferRef = "fee-1"
setExecutionStepRole(feeStep, executionStepRoleSource)
setExecutionStepStatus(feeStep, executionStepStatusSubmitted)
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
setExecutionStepRole(cardStep, executionStepRoleConsumer)
setExecutionStepStatus(cardStep, executionStepStatusPlanned)
store := newStubPaymentsStore()
store.payments[payment.PaymentRef] = payment
store.indexTransfers(payment)
svc := &Service{
logger: zap.NewNop(),
clock: testClock{now: time.Now()},
storage: &stubRepository{store: store},
}
payoutCalls := 0
submit := func(ctx context.Context, payment *model.Payment) error {
payoutCalls++
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
payment.Execution.CardPayoutRef = "payout-1"
plan := ensureExecutionPlan(payment)
step := ensureExecutionStep(plan, stepCodeCardPayout)
setExecutionStepRole(step, executionStepRoleConsumer)
step.TransferRef = "payout-1"
setExecutionStepStatus(step, executionStepStatusSubmitted)
return nil
}
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, submit, nil)
req := &orchestratorv1.ProcessTransferUpdateRequest{
Event: &chainv1.TransferStatusChangedEvent{
Transfer: &chainv1.Transfer{
TransferRef: "fund-1",
Status: chainv1.TransferStatus_TRANSFER_CONFIRMED,
},
},
}
resp, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req))
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
if payoutCalls != 0 {
t.Fatalf("expected no payout on first confirmation, got %d", payoutCalls)
}
if executionStepStatus(fundStep) != executionStepStatusConfirmed {
t.Fatalf("expected funding step confirmed, got %s", executionStepStatus(fundStep))
}
if resp.GetPayment().GetState() != orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED {
t.Fatalf("expected submitted state, got %s", resp.GetPayment().GetState())
}
req = &orchestratorv1.ProcessTransferUpdateRequest{
Event: &chainv1.TransferStatusChangedEvent{
Transfer: &chainv1.Transfer{
TransferRef: "fee-1",
Status: chainv1.TransferStatus_TRANSFER_CONFIRMED,
},
},
}
resp, err = gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req))
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
if payoutCalls != 1 {
t.Fatalf("expected payout after all sources confirmed, got %d", payoutCalls)
}
if executionStepStatus(feeStep) != executionStepStatusConfirmed {
t.Fatalf("expected fee step confirmed, got %s", executionStepStatus(feeStep))
}
if resp.GetPayment().GetExecution().GetCardPayoutRef() != "payout-1" {
t.Fatalf("expected card payout ref set, got %s", resp.GetPayment().GetExecution().GetCardPayoutRef())
}
}
func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
ctx := context.Background()
payment := &model.Payment{
@@ -182,7 +331,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
ManagedWalletRef: "wallet-dst",
},
},
Amount: &moneyv1.Money{Currency: "USD", Amount: "40"},
Amount: &paymenttypes.Money{Currency: "USD", Amount: "40"},
},
}
store := newStubPaymentsStore()
@@ -194,7 +343,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
clock: testClock{now: time.Now()},
storage: &stubRepository{store: store},
}
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, nil, nil)
req := &orchestratorv1.ProcessDepositObservedRequest{
Event: &chainv1.WalletDepositObservedEvent{
@@ -217,6 +366,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
type stubRepository struct {
store *stubPaymentsStore
quotes storage.QuotesStore
routes storage.RoutesStore
}
func (r *stubRepository) Ping(context.Context) error { return nil }
@@ -227,6 +377,12 @@ func (r *stubRepository) Quotes() storage.QuotesStore {
}
return &stubQuotesStore{}
}
func (r *stubRepository) Routes() storage.RoutesStore {
if r.routes != nil {
return r.routes
}
return &stubRoutesStore{}
}
type stubQuotesStore struct {
quotes map[string]*model.PaymentQuoteRecord
@@ -253,6 +409,38 @@ func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectI
return nil, storage.ErrQuoteNotFound
}
type stubRoutesStore struct {
routes []*model.PaymentRoute
}
func (s *stubRoutesStore) Create(ctx context.Context, route *model.PaymentRoute) error {
return merrors.InvalidArgument("routes store not implemented")
}
func (s *stubRoutesStore) Update(ctx context.Context, route *model.PaymentRoute) error {
return merrors.InvalidArgument("routes store not implemented")
}
func (s *stubRoutesStore) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error) {
return nil, storage.ErrRouteNotFound
}
func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) {
items := make([]*model.PaymentRoute, 0, len(s.routes))
for _, route := range s.routes {
if route == nil {
continue
}
if filter != nil && filter.IsEnabled != nil {
if route.IsEnabled != *filter.IsEnabled {
continue
}
}
items = append(items, route)
}
return &model.PaymentRouteList{Items: items}, nil
}
type stubPaymentsStore struct {
payments map[string]*model.Payment
byChain map[string]*model.Payment
@@ -271,9 +459,7 @@ func (s *stubPaymentsStore) Create(ctx context.Context, payment *model.Payment)
return storage.ErrDuplicatePayment
}
s.payments[payment.PaymentRef] = payment
if payment.Execution != nil && payment.Execution.ChainTransferRef != "" {
s.byChain[payment.Execution.ChainTransferRef] = payment
}
s.indexTransfers(payment)
return nil
}
@@ -282,9 +468,7 @@ func (s *stubPaymentsStore) Update(ctx context.Context, payment *model.Payment)
return storage.ErrPaymentNotFound
}
s.payments[payment.PaymentRef] = payment
if payment.Execution != nil && payment.Execution.ChainTransferRef != "" {
s.byChain[payment.Execution.ChainTransferRef] = payment
}
s.indexTransfers(payment)
return nil
}
@@ -318,6 +502,24 @@ func (s *stubPaymentsStore) List(ctx context.Context, filter *model.PaymentFilte
return &model.PaymentList{}, nil
}
func (s *stubPaymentsStore) indexTransfers(payment *model.Payment) {
if payment == nil {
return
}
if payment.Execution != nil && payment.Execution.ChainTransferRef != "" {
s.byChain[payment.Execution.ChainTransferRef] = payment
}
if payment.ExecutionPlan == nil {
return
}
for _, step := range payment.ExecutionPlan.Steps {
if step == nil || strings.TrimSpace(step.TransferRef) == "" {
continue
}
s.byChain[strings.TrimSpace(step.TransferRef)] = payment
}
}
var _ storage.PaymentsStore = (*stubPaymentsStore)(nil)
// testClock satisfies clock.Clock

View File

@@ -2,16 +2,12 @@ package model
import (
"strings"
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"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"
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"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
// PaymentKind captures the orchestrator intent type.
@@ -24,6 +20,15 @@ const (
PaymentKindFXConversion PaymentKind = "fx_conversion"
)
// SettlementMode defines how fees/FX variance is handled.
type SettlementMode string
const (
SettlementModeUnspecified SettlementMode = "unspecified"
SettlementModeFixSource SettlementMode = "fix_source"
SettlementModeFixReceived SettlementMode = "fix_received"
)
// PaymentState enumerates lifecycle phases.
type PaymentState string
@@ -50,6 +55,73 @@ const (
PaymentFailureCodePolicy PaymentFailureCode = "policy"
)
// Rail identifies a payment rail for orchestration.
type Rail string
const (
RailUnspecified Rail = "UNSPECIFIED"
RailCrypto Rail = "CRYPTO"
RailProviderSettlement Rail = "PROVIDER_SETTLEMENT"
RailLedger Rail = "LEDGER"
RailCardPayout Rail = "CARD_PAYOUT"
RailFiatOnRamp Rail = "FIAT_ONRAMP"
)
// RailOperation identifies an explicit action within a payment plan.
type RailOperation string
const (
RailOperationUnspecified RailOperation = "UNSPECIFIED"
RailOperationDebit RailOperation = "DEBIT"
RailOperationCredit RailOperation = "CREDIT"
RailOperationSend RailOperation = "SEND"
RailOperationFee RailOperation = "FEE"
RailOperationObserveConfirm RailOperation = "OBSERVE_CONFIRM"
RailOperationFXConvert RailOperation = "FX_CONVERT"
)
// RailCapabilities are declared per gateway instance.
type RailCapabilities struct {
CanPayIn bool `bson:"canPayIn,omitempty" json:"canPayIn,omitempty"`
CanPayOut bool `bson:"canPayOut,omitempty" json:"canPayOut,omitempty"`
CanReadBalance bool `bson:"canReadBalance,omitempty" json:"canReadBalance,omitempty"`
CanSendFee bool `bson:"canSendFee,omitempty" json:"canSendFee,omitempty"`
RequiresObserveConfirm bool `bson:"requiresObserveConfirm,omitempty" json:"requiresObserveConfirm,omitempty"`
}
// LimitsOverride applies per-currency overrides for limits.
type LimitsOverride struct {
MaxVolume string `bson:"maxVolume,omitempty" json:"maxVolume,omitempty"`
MinAmount string `bson:"minAmount,omitempty" json:"minAmount,omitempty"`
MaxAmount string `bson:"maxAmount,omitempty" json:"maxAmount,omitempty"`
MaxFee string `bson:"maxFee,omitempty" json:"maxFee,omitempty"`
MaxOps int `bson:"maxOps,omitempty" json:"maxOps,omitempty"`
}
// Limits define time-bucketed and per-tx constraints.
type Limits struct {
MinAmount string `bson:"minAmount,omitempty" json:"minAmount,omitempty"`
MaxAmount string `bson:"maxAmount,omitempty" json:"maxAmount,omitempty"`
PerTxMaxFee string `bson:"perTxMaxFee,omitempty" json:"perTxMaxFee,omitempty"`
PerTxMinAmount string `bson:"perTxMinAmount,omitempty" json:"perTxMinAmount,omitempty"`
PerTxMaxAmount string `bson:"perTxMaxAmount,omitempty" json:"perTxMaxAmount,omitempty"`
VolumeLimit map[string]string `bson:"volumeLimit,omitempty" json:"volumeLimit,omitempty"`
VelocityLimit map[string]int `bson:"velocityLimit,omitempty" json:"velocityLimit,omitempty"`
CurrencyLimits map[string]LimitsOverride `bson:"currencyLimits,omitempty" json:"currencyLimits,omitempty"`
}
// GatewayInstanceDescriptor standardizes gateway instance self-declaration.
type GatewayInstanceDescriptor struct {
ID string `bson:"id" json:"id"`
Rail Rail `bson:"rail" json:"rail"`
Network string `bson:"network,omitempty" json:"network,omitempty"`
Currencies []string `bson:"currencies,omitempty" json:"currencies,omitempty"`
Capabilities RailCapabilities `bson:"capabilities,omitempty" json:"capabilities,omitempty"`
Limits Limits `bson:"limits,omitempty" json:"limits,omitempty"`
Version string `bson:"version,omitempty" json:"version,omitempty"`
IsEnabled bool `bson:"isEnabled" json:"isEnabled"`
}
// PaymentEndpointType indicates how value should be routed.
type PaymentEndpointType string
@@ -69,15 +141,15 @@ type LedgerEndpoint struct {
// ManagedWalletEndpoint describes managed wallet routing.
type ManagedWalletEndpoint struct {
ManagedWalletRef string `bson:"managedWalletRef" json:"managedWalletRef"`
Asset *chainv1.Asset `bson:"asset,omitempty" json:"asset,omitempty"`
ManagedWalletRef string `bson:"managedWalletRef" json:"managedWalletRef"`
Asset *paymenttypes.Asset `bson:"asset,omitempty" json:"asset,omitempty"`
}
// ExternalChainEndpoint describes an external address.
type ExternalChainEndpoint struct {
Asset *chainv1.Asset `bson:"asset,omitempty" json:"asset,omitempty"`
Address string `bson:"address" json:"address"`
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
Asset *paymenttypes.Asset `bson:"asset,omitempty" json:"asset,omitempty"`
Address string `bson:"address" json:"address"`
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
}
// CardEndpoint describes a card payout destination.
@@ -116,26 +188,26 @@ type PaymentEndpoint struct {
// FXIntent captures FX conversion preferences.
type FXIntent struct {
Pair *fxv1.CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"`
Side fxv1.Side `bson:"side,omitempty" json:"side,omitempty"`
Firm bool `bson:"firm,omitempty" json:"firm,omitempty"`
TTLMillis int64 `bson:"ttlMillis,omitempty" json:"ttlMillis,omitempty"`
PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"`
MaxAgeMillis int32 `bson:"maxAgeMillis,omitempty" json:"maxAgeMillis,omitempty"`
Pair *paymenttypes.CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"`
Side paymenttypes.FXSide `bson:"side,omitempty" json:"side,omitempty"`
Firm bool `bson:"firm,omitempty" json:"firm,omitempty"`
TTLMillis int64 `bson:"ttlMillis,omitempty" json:"ttlMillis,omitempty"`
PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"`
MaxAgeMillis int32 `bson:"maxAgeMillis,omitempty" json:"maxAgeMillis,omitempty"`
}
// PaymentIntent models the requested payment operation.
type PaymentIntent struct {
Kind PaymentKind `bson:"kind" json:"kind"`
Source PaymentEndpoint `bson:"source" json:"source"`
Destination PaymentEndpoint `bson:"destination" json:"destination"`
Amount *moneyv1.Money `bson:"amount" json:"amount"`
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"`
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"`
Kind PaymentKind `bson:"kind" json:"kind"`
Source PaymentEndpoint `bson:"source" json:"source"`
Destination PaymentEndpoint `bson:"destination" json:"destination"`
Amount *paymenttypes.Money `bson:"amount" json:"amount"`
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
SettlementMode SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"`
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"`
}
// Customer captures payer/recipient identity details for downstream processing.
@@ -154,14 +226,14 @@ type Customer struct {
// PaymentQuoteSnapshot stores the latest quote info.
type PaymentQuoteSnapshot struct {
DebitAmount *moneyv1.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"`
ExpectedSettlementAmount *moneyv1.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *moneyv1.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"`
FeeLines []*feesv1.DerivedPostingLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"`
FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"`
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
DebitAmount *paymenttypes.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"`
ExpectedSettlementAmount *paymenttypes.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *paymenttypes.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"`
FeeLines []*paymenttypes.FeeLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"`
FeeRules []*paymenttypes.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"`
FXQuote *paymenttypes.FXQuote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
NetworkFee *paymenttypes.NetworkFeeEstimate `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
}
// ExecutionRefs links to downstream systems.
@@ -174,22 +246,39 @@ type ExecutionRefs struct {
FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"`
}
// PaymentStep is an explicit action within a payment plan.
type PaymentStep struct {
Rail Rail `bson:"rail" json:"rail"`
GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"`
Action RailOperation `bson:"action" json:"action"`
Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"`
Ref string `bson:"ref,omitempty" json:"ref,omitempty"`
}
// PaymentPlan captures the ordered list of steps to execute a payment.
type PaymentPlan struct {
ID string `bson:"id,omitempty" json:"id,omitempty"`
Steps []*PaymentStep `bson:"steps,omitempty" json:"steps,omitempty"`
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"`
CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt,omitempty"`
}
// ExecutionStep describes a planned or executed payment step for reporting.
type ExecutionStep struct {
Code string `bson:"code,omitempty" json:"code,omitempty"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
Amount *moneyv1.Money `bson:"amount,omitempty" json:"amount,omitempty"`
NetworkFee *moneyv1.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"`
DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"`
TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
Code string `bson:"code,omitempty" json:"code,omitempty"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"`
NetworkFee *paymenttypes.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"`
DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"`
TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// ExecutionPlan captures the ordered list of steps to execute a payment.
type ExecutionPlan struct {
Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"`
TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"`
Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"`
TotalNetworkFee *paymenttypes.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"`
}
// Payment persists orchestrated payment lifecycle.
@@ -206,6 +295,7 @@ type Payment struct {
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
ExecutionPlan *ExecutionPlan `bson:"executionPlan,omitempty" json:"executionPlan,omitempty"`
PaymentPlan *PaymentPlan `bson:"paymentPlan,omitempty" json:"paymentPlan,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"`
}
@@ -282,6 +372,19 @@ func (p *Payment) Normalize() {
}
}
}
if p.PaymentPlan != nil {
p.PaymentPlan.ID = strings.TrimSpace(p.PaymentPlan.ID)
p.PaymentPlan.IdempotencyKey = strings.TrimSpace(p.PaymentPlan.IdempotencyKey)
for _, step := range p.PaymentPlan.Steps {
if step == nil {
continue
}
step.Rail = Rail(strings.TrimSpace(string(step.Rail)))
step.GatewayID = strings.TrimSpace(step.GatewayID)
step.Action = RailOperation(strings.TrimSpace(string(step.Action)))
step.Ref = strings.TrimSpace(step.Ref)
}
}
}
func normalizeEndpoint(ep *PaymentEndpoint) {
@@ -303,6 +406,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
if ep.ManagedWallet != nil {
ep.ManagedWallet.ManagedWalletRef = strings.TrimSpace(ep.ManagedWallet.ManagedWalletRef)
if ep.ManagedWallet.Asset != nil {
ep.ManagedWallet.Asset.Chain = strings.TrimSpace(strings.ToUpper(ep.ManagedWallet.Asset.Chain))
ep.ManagedWallet.Asset.TokenSymbol = strings.TrimSpace(strings.ToUpper(ep.ManagedWallet.Asset.TokenSymbol))
ep.ManagedWallet.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ManagedWallet.Asset.ContractAddress))
}
@@ -312,6 +416,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
ep.ExternalChain.Address = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Address))
ep.ExternalChain.Memo = strings.TrimSpace(ep.ExternalChain.Memo)
if ep.ExternalChain.Asset != nil {
ep.ExternalChain.Asset.Chain = strings.TrimSpace(strings.ToUpper(ep.ExternalChain.Asset.Chain))
ep.ExternalChain.Asset.TokenSymbol = strings.TrimSpace(strings.ToUpper(ep.ExternalChain.Asset.TokenSymbol))
ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress))
}

View File

@@ -0,0 +1,47 @@
package model
import (
"strings"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
)
// PaymentRoute defines an allowed rail transition for orchestration.
type PaymentRoute struct {
storable.Base `bson:",inline" json:",inline"`
FromRail Rail `bson:"fromRail" json:"fromRail"`
ToRail Rail `bson:"toRail" json:"toRail"`
Network string `bson:"network,omitempty" json:"network,omitempty"`
RequiresObserve bool `bson:"requiresObserve,omitempty" json:"requiresObserve,omitempty"`
IsEnabled bool `bson:"isEnabled" json:"isEnabled"`
}
// Collection implements storable.Storable.
func (*PaymentRoute) Collection() string {
return mservice.PaymentRoutes
}
// Normalize standardizes route fields for consistent indexing and matching.
func (r *PaymentRoute) Normalize() {
if r == nil {
return
}
r.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.FromRail))))
r.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.ToRail))))
r.Network = strings.ToUpper(strings.TrimSpace(r.Network))
}
// PaymentRouteFilter selects routes for lookup.
type PaymentRouteFilter struct {
FromRail Rail
ToRail Rail
Network string
IsEnabled *bool
}
// PaymentRouteList holds route results.
type PaymentRouteList struct {
Items []*PaymentRoute
}

View File

@@ -19,6 +19,7 @@ type Store struct {
payments storage.PaymentsStore
quotes storage.QuotesStore
routes storage.RoutesStore
}
// New constructs a Mongo-backed payments repository from a Mongo connection.
@@ -28,11 +29,12 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
}
paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo)
routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection())
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo)
}
// NewWithRepository constructs a payments repository using the provided primitives.
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository) (*Store, error) {
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository) (*Store, error) {
if ping == nil {
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
}
@@ -42,6 +44,9 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error,
if quotesRepo == nil {
return nil, merrors.InvalidArgument("payments.storage.mongo: quotes repository is nil")
}
if routesRepo == nil {
return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil")
}
childLogger := logger.Named("storage").Named("mongo")
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
@@ -52,11 +57,16 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error,
if err != nil {
return nil, err
}
routesStore, err := store.NewRoutes(childLogger, routesRepo)
if err != nil {
return nil, err
}
result := &Store{
logger: childLogger,
ping: ping,
payments: paymentsStore,
quotes: quotesStore,
routes: routesStore,
}
return result, nil
@@ -80,4 +90,9 @@ func (s *Store) Quotes() storage.QuotesStore {
return s.quotes
}
// Routes returns the routing store.
func (s *Store) Routes() storage.RoutesStore {
return s.routes
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -54,6 +54,9 @@ func NewPayments(logger mlogger.Logger, repo repository.Repository) (*Payments,
{
Keys: []ri.Key{{Field: "execution.chainTransferRef", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "executionPlan.steps.transferRef", Sort: ri.Asc}},
},
}
for _, def := range indexes {
@@ -160,7 +163,11 @@ func (p *Payments) GetByChainTransferRef(ctx context.Context, transferRef string
return nil, merrors.InvalidArgument("paymentsStore: empty chain transfer reference")
}
entity := &model.Payment{}
if err := p.repo.FindOneByFilter(ctx, repository.Filter("execution.chainTransferRef", transferRef), entity); err != nil {
query := repository.Query().Or(
repository.Filter("execution.chainTransferRef", transferRef),
repository.Filter("executionPlan.steps.transferRef", transferRef),
)
if err := p.repo.FindOneByFilter(ctx, query, entity); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrPaymentNotFound
}

View File

@@ -0,0 +1,165 @@
package store
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type Routes struct {
logger mlogger.Logger
repo repository.Repository
}
// NewRoutes constructs a Mongo-backed routes store.
func NewRoutes(logger mlogger.Logger, repo repository.Repository) (*Routes, error) {
if repo == nil {
return nil, merrors.InvalidArgument("routesStore: repository is nil")
}
indexes := []*ri.Definition{
{
Keys: []ri.Key{
{Field: "fromRail", Sort: ri.Asc},
{Field: "toRail", Sort: ri.Asc},
{Field: "network", Sort: ri.Asc},
},
Unique: true,
},
{
Keys: []ri.Key{{Field: "fromRail", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "toRail", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "isEnabled", Sort: ri.Asc}},
},
}
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure routes index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err
}
}
return &Routes{
logger: logger.Named("routes"),
repo: repo,
}, nil
}
func (r *Routes) Create(ctx context.Context, route *model.PaymentRoute) error {
if route == nil {
return merrors.InvalidArgument("routesStore: nil route")
}
route.Normalize()
if route.FromRail == "" || route.FromRail == model.RailUnspecified {
return merrors.InvalidArgument("routesStore: from_rail is required")
}
if route.ToRail == "" || route.ToRail == model.RailUnspecified {
return merrors.InvalidArgument("routesStore: to_rail is required")
}
if route.ID.IsZero() {
route.SetID(primitive.NewObjectID())
} else {
route.Update()
}
filter := repository.Filter("fromRail", route.FromRail).And(
repository.Filter("toRail", route.ToRail),
repository.Filter("network", route.Network),
)
if err := r.repo.Insert(ctx, route, filter); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateRoute
}
return err
}
return nil
}
func (r *Routes) Update(ctx context.Context, route *model.PaymentRoute) error {
if route == nil {
return merrors.InvalidArgument("routesStore: nil route")
}
if route.ID.IsZero() {
return merrors.InvalidArgument("routesStore: missing route id")
}
route.Normalize()
route.Update()
if err := r.repo.Update(ctx, route); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return storage.ErrRouteNotFound
}
return err
}
return nil
}
func (r *Routes) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error) {
if id == primitive.NilObjectID {
return nil, merrors.InvalidArgument("routesStore: route id is required")
}
entity := &model.PaymentRoute{}
if err := r.repo.Get(ctx, id, entity); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrRouteNotFound
}
return nil, err
}
return entity, nil
}
func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) {
if filter == nil {
filter = &model.PaymentRouteFilter{}
}
query := repository.Query()
if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" {
query = query.Filter(repository.Field("fromRail"), from)
}
if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" {
query = query.Filter(repository.Field("toRail"), to)
}
if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" {
query = query.Filter(repository.Field("network"), network)
}
if filter.IsEnabled != nil {
query = query.Filter(repository.Field("isEnabled"), *filter.IsEnabled)
}
routes := make([]*model.PaymentRoute, 0)
decoder := func(cur *mongo.Cursor) error {
item := &model.PaymentRoute{}
if err := cur.Decode(item); err != nil {
return err
}
routes = append(routes, item)
return nil
}
if err := r.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
return &model.PaymentRouteList{
Items: routes,
}, nil
}
var _ storage.RoutesStore = (*Routes)(nil)

View File

@@ -22,6 +22,10 @@ var (
ErrQuoteNotFound = storageError("payments.orchestrator.storage: quote not found")
// ErrDuplicateQuote signals that a quote reference already exists.
ErrDuplicateQuote = storageError("payments.orchestrator.storage: duplicate quote")
// ErrRouteNotFound signals that a payment route record does not exist.
ErrRouteNotFound = storageError("payments.orchestrator.storage: route not found")
// ErrDuplicateRoute signals that a route already exists for the same transition.
ErrDuplicateRoute = storageError("payments.orchestrator.storage: duplicate route")
)
// Repository exposes persistence primitives for the orchestrator domain.
@@ -29,6 +33,7 @@ type Repository interface {
Ping(ctx context.Context) error
Payments() PaymentsStore
Quotes() QuotesStore
Routes() RoutesStore
}
// PaymentsStore manages payment lifecycle state.
@@ -46,3 +51,11 @@ type QuotesStore interface {
Create(ctx context.Context, quote *model.PaymentQuoteRecord) error
GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
}
// RoutesStore manages allowed routing transitions.
type RoutesStore interface {
Create(ctx context.Context, route *model.PaymentRoute) error
Update(ctx context.Context, route *model.PaymentRoute) error
GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error)
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
}