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

@@ -26,6 +26,7 @@ type Imp struct {
config *config
app *grpcapp.App[storage.Repository]
oracleClient oracleclient.Client
service *fees.Service
}
type config struct {
@@ -65,6 +66,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
func (i *Imp) Shutdown() {
if i.app == nil {
if i.service != nil {
i.service.Shutdown()
}
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
@@ -76,6 +80,10 @@ func (i *Imp) Shutdown() {
timeout = i.config.Runtime.ShutdownTimeout()
}
if i.service != nil {
i.service.Shutdown()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
@@ -121,7 +129,9 @@ func (i *Imp) Start() error {
if oracleClient != nil {
opts = append(opts, fees.WithOracleClient(oracleClient))
}
return fees.NewService(logger, repo, producer, opts...), nil
svc := fees.NewService(logger, repo, producer, opts...)
i.service = svc
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/tech/sendico/billing/fees/internal/appversion"
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
"github.com/tech/sendico/billing/fees/storage"
@@ -15,9 +16,11 @@ import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/api/routers"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
@@ -36,6 +39,7 @@ type Service struct {
calculator Calculator
oracle oracleclient.Client
resolver FeeResolver
announcer *discovery.Announcer
feesv1.UnimplementedFeeEngineServer
}
@@ -62,6 +66,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.resolver = resolver.New(repo.Plans(), svc.logger)
}
svc.startDiscoveryAnnouncer()
return svc
}
@@ -71,6 +77,28 @@ func (s *Service) Register(router routers.GRPC) error {
})
}
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "BILLING_FEES",
Operations: []string{"fee.calc"},
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
s.announcer.Start()
}
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
var (
meta *feesv1.RequestMeta

View File

@@ -12,7 +12,10 @@ import (
mongostorage "github.com/tech/sendico/fx/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
@@ -68,6 +71,24 @@ func (a *App) Run(ctx context.Context) error {
return err
}
var announcer *discovery.Announcer
if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" {
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg)
if err != nil {
a.logger.Warn("Failed to initialize discovery broker", zap.Error(err))
} else {
producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker)
announce := discovery.Announcement{
Service: "FX_INGESTOR",
Operations: []string{"fx.ingest"},
Version: appversion.Create().Short(),
}
announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce)
announcer.Start()
defer announcer.Stop()
}
}
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
metricsSrv.SetStatus(health.SSRunning)

View File

@@ -8,16 +8,18 @@ import (
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/messaging"
"gopkg.in/yaml.v3"
)
const defaultPollInterval = 30 * time.Second
type Config struct {
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
Market MarketConfig `yaml:"market"`
Database *db.Config `yaml:"database"`
Metrics *MetricsConfig `yaml:"metrics"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
Market MarketConfig `yaml:"market"`
Database *db.Config `yaml:"database"`
Metrics *MetricsConfig `yaml:"metrics"`
Messaging *messaging.Config `yaml:"messaging"`
pairs []Pair
pairsBySource map[mmodel.Driver][]PairConfig

View File

@@ -22,8 +22,9 @@ type Imp struct {
file string
debug bool
config *grpcapp.Config
app *grpcapp.App[storage.Repository]
config *grpcapp.Config
app *grpcapp.App[storage.Repository]
service *oracle.Service
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
@@ -38,6 +39,9 @@ func (i *Imp) Shutdown() {
if i.app == nil {
return
}
if i.service != nil {
i.service.Shutdown()
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
@@ -59,10 +63,12 @@ func (i *Imp) Start() error {
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
return oracle.NewService(logger, repo, producer), nil
svc := oracle.NewService(logger, repo, producer)
i.service = svc
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory)
app, err := grpcapp.NewApp(i.logger, "fx", cfg, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}

View File

@@ -6,10 +6,12 @@ import (
"strings"
"time"
"github.com/tech/sendico/fx/oracle/internal/appversion"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
pmessaging "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
@@ -36,19 +38,22 @@ var (
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
announcer *discovery.Announcer
oraclev1.UnimplementedOracleServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
initMetrics()
return &Service{
svc := &Service{
logger: logger.Named("oracle"),
storage: repo,
producer: prod,
}
svc.startDiscoveryAnnouncer()
return svc
}
func (s *Service) Register(router routers.GRPC) error {
@@ -57,6 +62,28 @@ func (s *Service) Register(router routers.GRPC) error {
})
}
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "FX_ORACLE",
Operations: []string{"fx.quote"},
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FXOracle), announce)
s.announcer.Start()
}
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
start := time.Now()
responder := s.getQuoteResponder(ctx, req)

View File

@@ -0,0 +1,258 @@
package client
import (
"context"
"strings"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/payments/rail"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// RailGatewayConfig defines metadata for the rail gateway adapter.
type RailGatewayConfig struct {
Rail string
Network string
Capabilities rail.RailCapabilities
}
type chainRailGateway struct {
client Client
rail string
network string
capabilities rail.RailCapabilities
}
// NewRailGateway wraps a chain gateway client into a rail gateway adapter.
func NewRailGateway(client Client, cfg RailGatewayConfig) rail.RailGateway {
railName := strings.ToUpper(strings.TrimSpace(cfg.Rail))
if railName == "" {
railName = "CRYPTO"
}
return &chainRailGateway{
client: client,
rail: railName,
network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
capabilities: cfg.Capabilities,
}
}
func (g *chainRailGateway) Rail() string {
return g.rail
}
func (g *chainRailGateway) Network() string {
return g.network
}
func (g *chainRailGateway) Capabilities() rail.RailCapabilities {
return g.capabilities
}
func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) {
if g.client == nil {
return rail.RailResult{}, merrors.Internal("chain gateway: client is required")
}
orgRef := strings.TrimSpace(req.OrganizationRef)
if orgRef == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: organization_ref is required")
}
source := strings.TrimSpace(req.FromAccountID)
if source == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: from_account_id is required")
}
destRef := strings.TrimSpace(req.ToAccountID)
if destRef == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: to_account_id is required")
}
currency := strings.TrimSpace(req.Currency)
amountValue := strings.TrimSpace(req.Amount)
if currency == "" || amountValue == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: amount is required")
}
reqNetwork := strings.TrimSpace(req.Network)
if g.network != "" && reqNetwork != "" && !strings.EqualFold(g.network, reqNetwork) {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: network mismatch")
}
if strings.TrimSpace(req.IdempotencyKey) == "" {
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: idempotency_key is required")
}
dest, err := g.resolveDestination(ctx, destRef, strings.TrimSpace(req.DestinationMemo))
if err != nil {
return rail.RailResult{}, err
}
fees := toServiceFees(req.Fees)
if len(fees) == 0 && req.Fee != nil {
if amt := moneyFromRail(req.Fee); amt != nil {
fees = []*chainv1.ServiceFeeBreakdown{{
FeeCode: "fee",
Amount: amt,
}}
}
}
resp, err := g.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
OrganizationRef: orgRef,
SourceWalletRef: source,
Destination: dest,
Amount: &moneyv1.Money{
Currency: currency,
Amount: amountValue,
},
Fees: fees,
Metadata: cloneMetadata(req.Metadata),
ClientReference: strings.TrimSpace(req.ClientReference),
})
if err != nil {
return rail.RailResult{}, err
}
if resp == nil || resp.GetTransfer() == nil {
return rail.RailResult{}, merrors.Internal("chain gateway: missing transfer response")
}
transfer := resp.GetTransfer()
return rail.RailResult{
ReferenceID: strings.TrimSpace(transfer.GetTransferRef()),
Status: statusFromTransfer(transfer.GetStatus()),
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
}, nil
}
func (g *chainRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) {
if g.client == nil {
return rail.ObserveResult{}, merrors.Internal("chain gateway: client is required")
}
ref := strings.TrimSpace(referenceID)
if ref == "" {
return rail.ObserveResult{}, merrors.InvalidArgument("chain gateway: reference_id is required")
}
resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref})
if err != nil {
return rail.ObserveResult{}, err
}
if resp == nil || resp.GetTransfer() == nil {
return rail.ObserveResult{}, merrors.Internal("chain gateway: missing transfer response")
}
transfer := resp.GetTransfer()
return rail.ObserveResult{
ReferenceID: ref,
Status: statusFromTransfer(transfer.GetStatus()),
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
}, nil
}
func (g *chainRailGateway) resolveDestination(ctx context.Context, destRef, memo string) (*chainv1.TransferDestination, error) {
managed, err := g.isManagedWallet(ctx, destRef)
if err != nil {
return nil, err
}
if managed {
return &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: destRef},
}, nil
}
return &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef},
Memo: memo,
}, nil
}
func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string) (bool, error) {
resp, err := g.client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
if err != nil {
if status.Code(err) == codes.NotFound {
return false, nil
}
return false, err
}
if resp == nil || resp.GetWallet() == nil {
return false, nil
}
return true, nil
}
func statusFromTransfer(status chainv1.TransferStatus) string {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return rail.TransferStatusSuccess
case chainv1.TransferStatus_TRANSFER_FAILED:
return rail.TransferStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return rail.TransferStatusRejected
case chainv1.TransferStatus_TRANSFER_SIGNING,
chainv1.TransferStatus_TRANSFER_PENDING,
chainv1.TransferStatus_TRANSFER_SUBMITTED:
return rail.TransferStatusPending
default:
return rail.TransferStatusPending
}
}
func toServiceFees(fees []rail.FeeBreakdown) []*chainv1.ServiceFeeBreakdown {
if len(fees) == 0 {
return nil
}
result := make([]*chainv1.ServiceFeeBreakdown, 0, len(fees))
for _, fee := range fees {
amount := moneyFromRail(fee.Amount)
if amount == nil {
continue
}
result = append(result, &chainv1.ServiceFeeBreakdown{
FeeCode: strings.TrimSpace(fee.FeeCode),
Amount: amount,
Description: strings.TrimSpace(fee.Description),
})
}
if len(result) == 0 {
return nil
}
return result
}
func moneyFromRail(m *rail.Money) *moneyv1.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
amount := strings.TrimSpace(m.GetAmount())
if currency == "" || amount == "" {
return nil
}
return &moneyv1.Money{
Currency: currency,
Amount: amount,
}
}
func railMoneyFromProto(m *moneyv1.Money) *rail.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
amount := strings.TrimSpace(m.GetAmount())
if currency == "" || amount == "" {
return nil
}
return &rail.Money{
Currency: currency,
Amount: amount,
}
}
func cloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
result := make(map[string]string, len(input))
for key, value := range input {
result[key] = value
}
return result
}

View File

@@ -22,7 +22,7 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f h1:a5PUgHGinaD6XrLmIDLQmGHocjIjBsBAcR5gALjZvMU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

View File

@@ -36,6 +36,7 @@ type Imp struct {
app *grpcapp.App[storage.Repository]
rpcClients *rpcclient.Clients
service *gatewayservice.Service
}
type config struct {
@@ -100,6 +101,10 @@ func (i *Imp) Shutdown() {
timeout = i.config.Runtime.ShutdownTimeout()
}
if i.service != nil {
i.service.Shutdown()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
@@ -151,7 +156,9 @@ func (i *Imp) Start() error {
gatewayservice.WithDriverRegistry(driverRegistry),
gatewayservice.WithSettings(cfg.Settings),
}
return gatewayservice.NewService(logger, repo, producer, opts...), nil
svc := gatewayservice.NewService(logger, repo, producer, opts...)
i.service = svc
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)

View File

@@ -3,6 +3,7 @@ package gateway
import (
"context"
"github.com/tech/sendico/gateway/chain/internal/appversion"
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
@@ -14,6 +15,7 @@ import (
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
@@ -47,6 +49,7 @@ type Service struct {
networkRegistry *rpcclient.Registry
drivers *drivers.Registry
commands commands.Registry
announcers []*discovery.Announcer
chainv1.UnimplementedChainGatewayServiceServer
}
@@ -83,6 +86,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
Wallet: commandsWalletDeps(svc),
Transfer: commandsTransferDeps(svc),
})
svc.startDiscoveryAnnouncers()
return svc
}
@@ -94,6 +98,17 @@ func (s *Service) Register(router routers.GRPC) error {
})
}
func (s *Service) Shutdown() {
if s == nil {
return
}
for _, announcer := range s.announcers {
if announcer != nil {
announcer.Stop()
}
}
}
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
}
@@ -174,3 +189,29 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
observeRPC(method, err, svc.clock.Now().Sub(start))
return resp, err
}
func (s *Service) startDiscoveryAnnouncers() {
if s == nil || s.producer == nil || len(s.networks) == 0 {
return
}
version := appversion.Create().Short()
for _, network := range s.networks {
currencies := []string{shared.NativeCurrency(network)}
for _, token := range network.TokenConfigs {
if token.Symbol != "" {
currencies = append(currencies, token.Symbol)
}
}
announce := discovery.Announcement{
Service: "CRYPTO_RAIL_GATEWAY",
Rail: "CRYPTO",
Network: network.Name,
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send"},
Currencies: currencies,
Version: version,
}
announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce)
announcer.Start()
s.announcers = append(s.announcers, announcer)
}
}

View File

@@ -3,7 +3,7 @@
This service now supports Monetix “payout by card”.
## Runtime entry points
- gRPC: `MntxGatewayService.CreateCardPayout` and `GetCardPayoutStatus`.
- gRPC: `MntxGatewayService.CreateCardPayout`, `CreateCardTokenPayout`, `GetCardPayoutStatus`, `ListGatewayInstances`.
- Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications.
- Metrics: Prometheus on `:9404/metrics`.
@@ -13,6 +13,7 @@ This service now supports Monetix “payout by card”.
- `MONETIX_PROJECT_ID` integer project ID
- `MONETIX_SECRET_KEY` signature secret
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
- Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits`
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
## Outbound request (CreateCardPayout)
@@ -39,7 +40,8 @@ Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_
- `sendico_mntx_gateway_card_payout_requests_total{outcome}`
- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}`
- `sendico_mntx_gateway_card_payout_callbacks_total{status}`
- Existing RPC/payout counters remain for compatibility.
- `sendico_mntx_gateway_rpc_requests_total{method,status}`
- `sendico_mntx_gateway_rpc_latency_seconds{method}`
## Notes / PCI
- PAN is only logged in masked form; do not persist raw PAN.

View File

@@ -17,6 +17,7 @@ type Client interface {
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
Close() error
}
@@ -96,3 +97,9 @@ func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.Get
defer cancel()
return g.client.GetCardPayoutStatus(ctx, req)
}
func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
ctx, cancel := g.callContext(ctx, "ListGatewayInstances")
defer cancel()
return g.client.ListGatewayInstances(ctx, req)
}

View File

@@ -11,6 +11,7 @@ type Fake struct {
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
ListGatewayInstancesFn func(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
}
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
@@ -34,4 +35,11 @@ func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayou
return &mntxv1.GetCardPayoutStatusResponse{}, nil
}
func (f *Fake) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
if f.ListGatewayInstancesFn != nil {
return f.ListGatewayInstancesFn(ctx, req)
}
return &mntxv1.ListGatewayInstancesResponse{}, nil
}
func (f *Fake) Close() error { return nil }

View File

@@ -32,6 +32,14 @@ monetix:
status_success: "success"
status_processing: "processing"
gateway:
id: "monetix"
is_enabled: true
# network: "VISA_DIRECT"
# currencies: ["RUB"]
# limits:
# min_amount: "0"
http:
callback:
address: ":8084"

View File

@@ -6,7 +6,6 @@ replace github.com/tech/sendico/pkg => ../../pkg
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
@@ -23,6 +22,7 @@ require (
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect

View File

@@ -40,6 +40,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -57,8 +59,6 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=

View File

@@ -12,12 +12,14 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/gateway/mntx/internal/appversion"
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
@@ -28,14 +30,16 @@ type Imp struct {
file string
debug bool
config *config
app *grpcapp.App[struct{}]
http *http.Server
config *config
app *grpcapp.App[struct{}]
http *http.Server
service *mntxservice.Service
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Monetix monetixConfig `yaml:"monetix"`
Gateway gatewayConfig `yaml:"gateway"`
HTTP httpConfig `yaml:"http"`
}
@@ -53,6 +57,33 @@ type monetixConfig struct {
StatusProcessing string `yaml:"status_processing"`
}
type gatewayConfig struct {
ID string `yaml:"id"`
Network string `yaml:"network"`
Currencies []string `yaml:"currencies"`
IsEnabled *bool `yaml:"is_enabled"`
Limits limitsConfig `yaml:"limits"`
}
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"`
}
type httpConfig struct {
Callback callbackConfig `yaml:"callback"`
}
@@ -86,6 +117,9 @@ func (i *Imp) Shutdown() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if i.service != nil {
i.service.Shutdown()
}
if i.http != nil {
_ = i.http.Shutdown(ctx)
i.http = nil
@@ -131,6 +165,17 @@ func (i *Imp) Start() error {
zap.String("status_processing", monetixCfg.ProcessingStatus()),
)
gatewayDescriptor := resolveGatewayDescriptor(cfg.Gateway, monetixCfg)
if gatewayDescriptor != nil {
i.logger.Info("Gateway descriptor resolved",
zap.String("id", gatewayDescriptor.GetId()),
zap.String("rail", gatewayDescriptor.GetRail().String()),
zap.String("network", gatewayDescriptor.GetNetwork()),
zap.Int("currencies", len(gatewayDescriptor.GetCurrencies())),
zap.Bool("enabled", gatewayDescriptor.GetIsEnabled()),
)
}
i.logger.Info("Callback configuration resolved",
zap.String("address", callbackCfg.Address),
zap.String("path", callbackCfg.Path),
@@ -142,8 +187,10 @@ func (i *Imp) Start() error {
svc := mntxservice.NewService(logger,
mntxservice.WithProducer(producer),
mntxservice.WithMonetixConfig(monetixCfg),
mntxservice.WithGatewayDescriptor(gatewayDescriptor),
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
)
i.service = svc
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
return nil, err
@@ -243,6 +290,129 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) {
}, nil
}
func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gatewayv1.GatewayInstanceDescriptor {
id := strings.TrimSpace(cfg.ID)
if id == "" {
id = "monetix"
}
network := strings.ToUpper(strings.TrimSpace(cfg.Network))
currencies := normalizeCurrencies(cfg.Currencies)
if len(currencies) == 0 {
currencies = normalizeCurrencies(monetixCfg.AllowedCurrencies)
}
enabled := true
if cfg.IsEnabled != nil {
enabled = *cfg.IsEnabled
}
limits := buildGatewayLimits(cfg.Limits)
if limits == nil {
limits = &gatewayv1.Limits{MinAmount: "0"}
}
version := strings.TrimSpace(appversion.Version)
return &gatewayv1.GatewayInstanceDescriptor{
Id: id,
Rail: gatewayv1.Rail_RAIL_CARD_PAYOUT,
Network: network,
Currencies: currencies,
Capabilities: &gatewayv1.RailCapabilities{
CanPayOut: true,
CanPayIn: false,
CanReadBalance: false,
CanSendFee: false,
RequiresObserveConfirm: false,
},
Limits: limits,
Version: version,
IsEnabled: enabled,
}
}
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) *gatewayv1.Limits {
hasValue := strings.TrimSpace(cfg.MinAmount) != "" ||
strings.TrimSpace(cfg.MaxAmount) != "" ||
strings.TrimSpace(cfg.PerTxMaxFee) != "" ||
strings.TrimSpace(cfg.PerTxMinAmount) != "" ||
strings.TrimSpace(cfg.PerTxMaxAmount) != "" ||
len(cfg.VolumeLimit) > 0 ||
len(cfg.VelocityLimit) > 0 ||
len(cfg.CurrencyLimits) > 0
if !hasValue {
return nil
}
limits := &gatewayv1.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]int32{}
for key, value := range cfg.VelocityLimit {
bucket := strings.TrimSpace(key)
if bucket == "" {
continue
}
limits.VelocityLimit[bucket] = int32(value)
}
}
if len(cfg.CurrencyLimits) > 0 {
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
for key, override := range cfg.CurrencyLimits {
currency := strings.ToUpper(strings.TrimSpace(key))
if currency == "" {
continue
}
limits.CurrencyLimits[currency] = &gatewayv1.LimitsOverride{
MaxVolume: strings.TrimSpace(override.MaxVolume),
MinAmount: strings.TrimSpace(override.MinAmount),
MaxAmount: strings.TrimSpace(override.MaxAmount),
MaxFee: strings.TrimSpace(override.MaxFee),
MaxOps: int32(override.MaxOps),
}
}
}
return limits
}
type callbackRuntimeConfig struct {
Address string
Path string

View File

@@ -0,0 +1,63 @@
package gateway
import (
"context"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
// ListGatewayInstances exposes the Monetix gateway instance descriptors.
func (s *Service) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
return executeUnary(ctx, s, "ListGatewayInstances", s.handleListGatewayInstances, req)
}
func (s *Service) handleListGatewayInstances(_ context.Context, _ *mntxv1.ListGatewayInstancesRequest) gsresponse.Responder[mntxv1.ListGatewayInstancesResponse] {
items := make([]*gatewayv1.GatewayInstanceDescriptor, 0, 1)
if s.gatewayDescriptor != nil {
items = append(items, cloneGatewayDescriptor(s.gatewayDescriptor))
}
return gsresponse.Success(&mntxv1.ListGatewayInstancesResponse{Items: items})
}
func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1.GatewayInstanceDescriptor {
if src == nil {
return nil
}
cp := *src
if src.Currencies != nil {
cp.Currencies = append([]string(nil), src.Currencies...)
}
if src.Capabilities != nil {
cap := *src.Capabilities
cp.Capabilities = &cap
}
if src.Limits != nil {
limits := *src.Limits
if src.Limits.VolumeLimit != nil {
limits.VolumeLimit = map[string]string{}
for key, value := range src.Limits.VolumeLimit {
limits.VolumeLimit[key] = value
}
}
if src.Limits.VelocityLimit != nil {
limits.VelocityLimit = map[string]int32{}
for key, value := range src.Limits.VelocityLimit {
limits.VelocityLimit[key] = value
}
}
if src.Limits.CurrencyLimits != nil {
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
for key, value := range src.Limits.CurrencyLimits {
if value == nil {
continue
}
clone := *value
limits.CurrencyLimits[key] = &clone
}
}
cp.Limits = &limits
}
return &cp
}

View File

@@ -2,15 +2,12 @@ package gateway
import (
"errors"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
var (
@@ -18,11 +15,6 @@ var (
rpcLatency *prometheus.HistogramVec
rpcStatus *prometheus.CounterVec
payoutCounter *prometheus.CounterVec
payoutAmountTotal *prometheus.CounterVec
payoutErrorCount *prometheus.CounterVec
payoutMissedAmounts *prometheus.CounterVec
)
func initMetrics() {
@@ -42,33 +34,6 @@ func initMetrics() {
Help: "Total number of RPC invocations grouped by method and status.",
}, []string{"method", "status"})
payoutCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "payouts_total",
Help: "Total payouts processed grouped by outcome.",
}, []string{"status"})
payoutAmountTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "payout_amount_total",
Help: "Total payout amount grouped by outcome and currency.",
}, []string{"status", "currency"})
payoutErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "payout_errors_total",
Help: "Payout failures grouped by reason.",
}, []string{"reason"})
payoutMissedAmounts = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "mntx_gateway",
Name: "payout_missed_amount_total",
Help: "Total payout volume that failed grouped by reason and currency.",
}, []string{"reason", "currency"})
})
}
@@ -81,71 +46,6 @@ func observeRPC(method string, err error, duration time.Duration) {
}
}
func observePayoutSuccess(amount *moneyv1.Money) {
if payoutCounter != nil {
payoutCounter.WithLabelValues("processed").Inc()
}
value, currency := monetaryValue(amount)
if value > 0 && payoutAmountTotal != nil {
payoutAmountTotal.WithLabelValues("processed", currency).Add(value)
}
}
func observePayoutError(reason string, amount *moneyv1.Money) {
reason = reasonLabel(reason)
if payoutCounter != nil {
payoutCounter.WithLabelValues("failed").Inc()
}
if payoutErrorCount != nil {
payoutErrorCount.WithLabelValues(reason).Inc()
}
value, currency := monetaryValue(amount)
if value <= 0 {
return
}
if payoutAmountTotal != nil {
payoutAmountTotal.WithLabelValues("failed", currency).Add(value)
}
if payoutMissedAmounts != nil {
payoutMissedAmounts.WithLabelValues(reason, currency).Add(value)
}
}
func monetaryValue(amount *moneyv1.Money) (float64, string) {
if amount == nil {
return 0, "unknown"
}
val := strings.TrimSpace(amount.Amount)
if val == "" {
return 0, currencyLabel(amount.Currency)
}
dec, err := decimal.NewFromString(val)
if err != nil {
return 0, currencyLabel(amount.Currency)
}
f, _ := dec.Float64()
if f < 0 {
return 0, currencyLabel(amount.Currency)
}
return f, currencyLabel(amount.Currency)
}
func currencyLabel(code string) string {
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return "unknown"
}
return code
}
func reasonLabel(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return "unknown"
}
return strings.ToLower(reason)
}
func statusLabel(err error) string {
switch {
case err == nil:

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/pkg/clock"
msg "github.com/tech/sendico/pkg/messaging"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
)
// Option configures optional service dependencies.
@@ -42,3 +43,12 @@ func WithMonetixConfig(cfg monetix.Config) Option {
s.config = cfg
}
}
// WithGatewayDescriptor sets the self-declared gateway instance descriptor.
func WithGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) Option {
return func(s *Service) {
if descriptor != nil {
s.gatewayDescriptor = descriptor
}
}
}

View File

@@ -1,36 +0,0 @@
package gateway
import (
"context"
"fmt"
"strings"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
)
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
return executeUnary(ctx, s, "GetPayout", s.handleGetPayout, req)
}
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
ref := strings.TrimSpace(req.GetPayoutRef())
log := s.logger.Named("payout")
log.Info("Get payout request received", zap.String("payout_ref", ref))
if ref == "" {
log.Warn("Get payout request missing payout_ref")
return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
}
payout, ok := s.store.Get(ref)
if !ok {
log.Warn("Payout not found", zap.String("payout_ref", ref))
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
}
log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String()))
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
}

View File

@@ -1,46 +0,0 @@
package gateway
import (
"sync"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/proto"
)
type payoutStore struct {
mu sync.RWMutex
payouts map[string]*mntxv1.Payout
}
func newPayoutStore() *payoutStore {
return &payoutStore{
payouts: make(map[string]*mntxv1.Payout),
}
}
func (s *payoutStore) Save(p *mntxv1.Payout) {
if p == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.payouts[p.GetPayoutRef()] = clonePayout(p)
}
func (s *payoutStore) Get(ref string) (*mntxv1.Payout, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.payouts[ref]
return clonePayout(p), ok
}
func clonePayout(p *mntxv1.Payout) *mntxv1.Payout {
if p == nil {
return nil
}
cloned := proto.Clone(p)
if cp, ok := cloned.(*mntxv1.Payout); ok {
return cp
}
return nil
}

View File

@@ -1,144 +0,0 @@
package gateway
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequest) (*mntxv1.SubmitPayoutResponse, error) {
return executeUnary(ctx, s, "SubmitPayout", s.handleSubmitPayout, req)
}
func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] {
log := s.logger.Named("payout")
log.Info("Submit payout request received",
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
zap.String("currency", strings.TrimSpace(req.GetAmount().GetCurrency())),
zap.String("amount", strings.TrimSpace(req.GetAmount().GetAmount())),
)
payout, err := s.buildPayout(req)
if err != nil {
log.Warn("Submit payout validation failed", zap.Error(err))
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
}
s.store.Save(payout)
s.emitEvent(payout, nm.NAPending)
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason()))
log.Info("Payout accepted", zap.String("payout_ref", payout.GetPayoutRef()), zap.String("status", payout.GetStatus().String()))
return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout})
}
func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout, error) {
if req == nil {
return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
}
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
if idempotencyKey == "" {
return nil, newPayoutError("missing_idempotency_key", merrors.InvalidArgument("idempotency_key is required", "idempotency_key"))
}
orgRef := strings.TrimSpace(req.OrganizationRef)
if orgRef == "" {
return nil, newPayoutError("missing_organization_ref", merrors.InvalidArgument("organization_ref is required", "organization_ref"))
}
if err := validateAmount(req.Amount); err != nil {
return nil, err
}
if err := validateDestination(req.Destination); err != nil {
return nil, err
}
if reason := strings.TrimSpace(req.SimulatedFailureReason); reason != "" {
return nil, newPayoutError(normalizeReason(reason), merrors.InvalidArgument("simulated payout failure requested"))
}
now := timestamppb.New(s.clock.Now())
payout := &mntxv1.Payout{
PayoutRef: newPayoutRef(),
IdempotencyKey: idempotencyKey,
OrganizationRef: orgRef,
Destination: req.Destination,
Amount: req.Amount,
Description: strings.TrimSpace(req.Description),
Metadata: req.Metadata,
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
CreatedAt: now,
UpdatedAt: now,
}
return payout, nil
}
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
log := s.logger.Named("payout")
outcome := clonePayout(original)
if outcome == nil {
return
}
// Simulate async processing delay for realism.
time.Sleep(150 * time.Millisecond)
outcome.UpdatedAt = timestamppb.New(s.clock.Now())
if simulatedFailure != "" {
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
outcome.FailureReason = simulatedFailure
observePayoutError(simulatedFailure, outcome.Amount)
s.store.Save(outcome)
s.emitEvent(outcome, nm.NAUpdated)
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()), zap.String("failure_reason", simulatedFailure))
return
}
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
observePayoutSuccess(outcome.Amount)
s.store.Save(outcome)
s.emitEvent(outcome, nm.NAUpdated)
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()))
}
func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) {
if payout == nil || s.producer == nil {
return
}
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
if err != nil {
s.logger.Warn("Failed to marshal payout event", zapError(err))
return
}
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
if _, err := env.Wrap(payload); err != nil {
s.logger.Warn("Failed to wrap payout event payload", zapError(err))
return
}
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish payout event", zapError(err))
}
}
func zapError(err error) zap.Field {
return zap.Error(err)
}

View File

@@ -1,106 +0,0 @@
package gateway
import (
"strconv"
"strings"
"github.com/shopspring/decimal"
"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 validateAmount(amount *moneyv1.Money) error {
if amount == nil {
return newPayoutError("missing_amount", merrors.InvalidArgument("amount is required", "amount"))
}
if strings.TrimSpace(amount.Currency) == "" {
return newPayoutError("missing_currency", merrors.InvalidArgument("amount currency is required", "amount.currency"))
}
val := strings.TrimSpace(amount.Amount)
if val == "" {
return newPayoutError("missing_amount_value", merrors.InvalidArgument("amount value is required", "amount.amount"))
}
dec, err := decimal.NewFromString(val)
if err != nil {
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount must be a decimal value", "amount.amount"))
}
if dec.Sign() <= 0 {
return newPayoutError("non_positive_amount", merrors.InvalidArgument("amount must be positive", "amount.amount"))
}
return nil
}
func validateDestination(dest *mntxv1.PayoutDestination) error {
if dest == nil {
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
}
if bank := dest.GetBankAccount(); bank != nil {
return validateBankAccount(bank)
}
if card := dest.GetCard(); card != nil {
return validateCardDestination(card)
}
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include bank_account or card", "destination"))
}
func validateBankAccount(dest *mntxv1.BankAccount) error {
if dest == nil {
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
}
iban := strings.TrimSpace(dest.Iban)
holder := strings.TrimSpace(dest.AccountHolder)
if iban == "" && holder == "" {
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include iban or account_holder", "destination"))
}
return nil
}
func validateCardDestination(card *mntxv1.CardDestination) error {
if card == nil {
return newPayoutError("missing_destination", merrors.InvalidArgument("destination.card is required", "destination.card"))
}
pan := strings.TrimSpace(card.GetPan())
token := strings.TrimSpace(card.GetToken())
if pan == "" && token == "" {
return newPayoutError("invalid_card_destination", merrors.InvalidArgument("card destination must include pan or token", "destination.card"))
}
if strings.TrimSpace(card.GetCardholderName()) == "" {
return newPayoutError("missing_cardholder_name", merrors.InvalidArgument("cardholder_name is required", "destination.card.cardholder_name"))
}
month := strings.TrimSpace(card.GetExpMonth())
year := strings.TrimSpace(card.GetExpYear())
if pan != "" {
if err := validateExpiry(month, year); err != nil {
return err
}
}
return nil
}
func validateExpiry(month, year string) error {
if month == "" || year == "" {
return newPayoutError("missing_expiry", merrors.InvalidArgument("exp_month and exp_year are required for card payouts", "destination.card.expiry"))
}
m, err := strconv.Atoi(month)
if err != nil || m < 1 || m > 12 {
return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("exp_month must be between 01 and 12", "destination.card.exp_month"))
}
if _, err := strconv.Atoi(year); err != nil || len(year) < 2 {
return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("exp_year must be numeric", "destination.card.exp_year"))
}
return nil
}

View File

@@ -5,28 +5,31 @@ import (
"net/http"
"strings"
"github.com/google/uuid"
"github.com/tech/sendico/gateway/mntx/internal/appversion"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
)
type Service struct {
logger mlogger.Logger
clock clockpkg.Clock
producer msg.Producer
store *payoutStore
cardStore *cardPayoutStore
config monetix.Config
httpClient *http.Client
card *cardPayoutProcessor
logger mlogger.Logger
clock clockpkg.Clock
producer msg.Producer
cardStore *cardPayoutStore
config monetix.Config
httpClient *http.Client
card *cardPayoutProcessor
gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor
announcer *discovery.Announcer
mntxv1.UnimplementedMntxGatewayServiceServer
}
@@ -58,7 +61,6 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("service"),
clock: clockpkg.NewSystem(),
store: newPayoutStore(),
cardStore: newCardPayoutStore(),
config: monetix.DefaultConfig(),
}
@@ -86,6 +88,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
}
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer)
svc.startDiscoveryAnnouncer()
return svc
}
@@ -97,6 +100,15 @@ func (s *Service) Register(router routers.GRPC) error {
})
}
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
}
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
log := svc.logger.Named("rpc")
log.Info("RPC request started", zap.String("method", method))
@@ -114,10 +126,6 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
return resp, err
}
func newPayoutRef() string {
return "pyt_" + strings.ReplaceAll(uuid.New().String(), "-", "")
}
func normalizeReason(reason string) string {
return strings.ToLower(strings.TrimSpace(reason))
}
@@ -128,3 +136,59 @@ func newPayoutError(reason string, err error) error {
err: err,
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "CARD_PAYOUT_RAIL_GATEWAY",
Rail: "CARD_PAYOUT",
Operations: []string{"payout.card"},
Version: appversion.Create().Short(),
}
if s.gatewayDescriptor != nil {
if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" {
announce.ID = id
}
announce.Network = strings.TrimSpace(s.gatewayDescriptor.GetNetwork())
announce.Currencies = append([]string(nil), s.gatewayDescriptor.GetCurrencies()...)
announce.Limits = limitsFromDescriptor(s.gatewayDescriptor.GetLimits())
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
s.announcer.Start()
}
func limitsFromDescriptor(src *gatewayv1.Limits) *discovery.Limits {
if src == nil {
return nil
}
limits := &discovery.Limits{
MinAmount: strings.TrimSpace(src.GetMinAmount()),
MaxAmount: strings.TrimSpace(src.GetMaxAmount()),
VolumeLimit: map[string]string{},
VelocityLimit: map[string]int{},
}
for key, value := range src.GetVolumeLimit() {
k := strings.TrimSpace(key)
v := strings.TrimSpace(value)
if k == "" || v == "" {
continue
}
limits.VolumeLimit[k] = v
}
for key, value := range src.GetVelocityLimit() {
k := strings.TrimSpace(key)
if k == "" {
continue
}
limits.VelocityLimit[k] = int(value)
}
if len(limits.VolumeLimit) == 0 {
limits.VolumeLimit = nil
}
if len(limits.VelocityLimit) == 0 {
limits.VelocityLimit = nil
}
return limits
}

View File

@@ -8,6 +8,8 @@ import (
"time"
"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"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
@@ -16,6 +18,10 @@ import (
// Client exposes typed helpers around the ledger gRPC API.
type Client interface {
ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error)
CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error)
HoldBalance(ctx context.Context, accountID string, amount string) error
CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
@@ -95,6 +101,80 @@ func (c *ledgerClient) Close() error {
return nil
}
func (c *ledgerClient) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) {
if strings.TrimSpace(accountID) == "" {
return nil, merrors.InvalidArgument("ledger: account_id is required")
}
resp, err := c.GetBalance(ctx, &ledgerv1.GetBalanceRequest{LedgerAccountRef: strings.TrimSpace(accountID)})
if err != nil {
return nil, err
}
if resp == nil || resp.GetBalance() == nil {
return nil, merrors.Internal("ledger: balance response missing")
}
return cloneMoney(resp.GetBalance()), nil
}
func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) {
orgRef := strings.TrimSpace(tx.OrganizationRef)
if orgRef == "" {
return "", merrors.InvalidArgument("ledger: organization_ref is required")
}
accountRef := strings.TrimSpace(tx.LedgerAccountRef)
if accountRef == "" {
return "", merrors.InvalidArgument("ledger: ledger_account_ref is required")
}
money := &moneyv1.Money{
Currency: strings.TrimSpace(tx.Currency),
Amount: strings.TrimSpace(tx.Amount),
}
if money.GetCurrency() == "" || money.GetAmount() == "" {
return "", merrors.InvalidArgument("ledger: amount is required")
}
description := strings.TrimSpace(tx.Description)
metadata := ledgerTxMetadata(tx.Metadata, tx)
switch {
case isLedgerRail(tx.FromRail) && !isLedgerRail(tx.ToRail):
resp, err := c.PostDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey),
OrganizationRef: orgRef,
LedgerAccountRef: accountRef,
Money: money,
Description: description,
Charges: tx.Charges,
Metadata: metadata,
ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
})
if err != nil {
return "", err
}
return strings.TrimSpace(resp.GetJournalEntryRef()), nil
case isLedgerRail(tx.ToRail) && !isLedgerRail(tx.FromRail):
resp, err := c.PostCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey),
OrganizationRef: orgRef,
LedgerAccountRef: accountRef,
Money: money,
Description: description,
Charges: tx.Charges,
Metadata: metadata,
ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
})
if err != nil {
return "", err
}
return strings.TrimSpace(resp.GetJournalEntryRef()), nil
default:
return "", merrors.InvalidArgument("ledger: unsupported transaction direction")
}
}
func (c *ledgerClient) HoldBalance(ctx context.Context, accountID string, amount string) error {
return merrors.NotImplemented("ledger: hold balance not supported")
}
func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
@@ -156,3 +236,57 @@ func (c *ledgerClient) callContext(ctx context.Context) (context.Context, contex
}
return context.WithTimeout(ctx, timeout)
}
func isLedgerRail(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "LEDGER")
}
func cloneMoney(input *moneyv1.Money) *moneyv1.Money {
if input == nil {
return nil
}
return &moneyv1.Money{
Currency: input.GetCurrency(),
Amount: input.GetAmount(),
}
}
func cloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
out := make(map[string]string, len(input))
for k, v := range input {
out[k] = v
}
return out
}
func ledgerTxMetadata(base map[string]string, tx rail.LedgerTx) map[string]string {
meta := cloneMetadata(base)
if meta == nil {
meta = map[string]string{}
}
if val := strings.TrimSpace(tx.PaymentPlanID); val != "" {
meta["payment_plan_id"] = val
}
if val := strings.TrimSpace(tx.FromRail); val != "" {
meta["from_rail"] = val
}
if val := strings.TrimSpace(tx.ToRail); val != "" {
meta["to_rail"] = val
}
if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" {
meta["external_reference_id"] = val
}
if val := strings.TrimSpace(tx.FXRateUsed); val != "" {
meta["fx_rate_used"] = val
}
if val := strings.TrimSpace(tx.FeeAmount); val != "" {
meta["fee_amount"] = val
}
if len(meta) == 0 {
return nil
}
return meta
}

View File

@@ -3,13 +3,18 @@ package client
import (
"context"
"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"
)
// Fake implements Client for tests.
type Fake struct {
CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error)
CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error)
HoldBalanceFn func(ctx context.Context, accountID string, amount string) error
CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
@@ -20,6 +25,27 @@ type Fake struct {
CloseFn func() error
}
func (f *Fake) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) {
if f.ReadBalanceFn != nil {
return f.ReadBalanceFn(ctx, accountID)
}
return &moneyv1.Money{}, nil
}
func (f *Fake) CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) {
if f.CreateTransactionFn != nil {
return f.CreateTransactionFn(ctx, tx)
}
return "", nil
}
func (f *Fake) HoldBalance(ctx context.Context, accountID string, amount string) error {
if f.HoldBalanceFn != nil {
return f.HoldBalanceFn(ctx, accountID, amount)
}
return nil
}
func (f *Fake) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
if f.CreateAccountFn != nil {
return f.CreateAccountFn(ctx, req)

View File

@@ -16,11 +16,14 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"github.com/tech/sendico/ledger/internal/appversion"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
pmessaging "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
)
@@ -35,10 +38,11 @@ var (
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
fees feesDependency
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
fees feesDependency
announcer *discovery.Announcer
outbox struct {
once sync.Once
@@ -72,6 +76,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.
}
service.startOutboxPublisher()
service.startDiscoveryAnnouncer()
return service
}
@@ -184,11 +189,27 @@ func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
if s.outbox.cancel != nil {
s.outbox.cancel()
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "LEDGER",
Operations: []string{"balance.read", "ledger.debit", "ledger.credit"},
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.Ledger), announce)
s.announcer.Start()
}
func (s *Service) startOutboxPublisher() {
if s.storage == nil || s.producer == nil {
return

View File

@@ -5,7 +5,7 @@ go 1.25.3
replace github.com/tech/sendico/pkg => ../pkg
require (
github.com/amplitude/analytics-go v1.2.0
github.com/amplitude/analytics-go v1.3.0
github.com/go-chi/chi/v5 v5.2.3
github.com/mitchellh/mapstructure v1.5.0
github.com/nicksnyder/go-i18n/v2 v2.6.0

View File

@@ -6,8 +6,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/amplitude/analytics-go v1.2.0 h1:+WUKyAAKwlmSM8d03QWG+NjnrQIyc6VJRGPNkaa2ckI=
github.com/amplitude/analytics-go v1.2.0/go.mod h1:kAQG8OQ6aPOxZrEZ3+/NFCfxdYSyjqXZhgkjWFD3/vo=
github.com/amplitude/analytics-go v1.3.0 h1:Lgj31fWThQ6hdDHO0RPxQfy/D7d8K+aqWsBa+IGTxQk=
github.com/amplitude/analytics-go v1.3.0/go.mod h1:kAQG8OQ6aPOxZrEZ3+/NFCfxdYSyjqXZhgkjWFD3/vo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=

View File

@@ -4,8 +4,10 @@ import (
"context"
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/internal/appversion"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail"
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/merrors"
na "github.com/tech/sendico/pkg/messaging/notifications/account"
@@ -19,10 +21,11 @@ import (
)
type NotificationAPI struct {
logger mlogger.Logger
client mmail.Client
dp domainprovider.DomainProvider
tg telegram.Client
logger mlogger.Logger
client mmail.Client
dp domainprovider.DomainProvider
tg telegram.Client
announcer *discovery.Announcer
}
func (a *NotificationAPI) Name() mservice.Type {
@@ -30,6 +33,9 @@ func (a *NotificationAPI) Name() mservice.Type {
}
func (a *NotificationAPI) Finish(_ context.Context) error {
if a.announcer != nil {
a.announcer.Stop()
}
return nil
}
@@ -91,6 +97,14 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
return nil, err
}
announce := discovery.Announcement{
Service: "NOTIFICATIONS",
Operations: []string{"notify.send"},
Version: appversion.Create().Short(),
}
p.announcer = discovery.NewAnnouncer(p.logger, a.Register().Producer(), string(mservice.Notifications), announce)
p.announcer.Start()
return p, nil
}

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

View File

@@ -0,0 +1,157 @@
package discovery
import (
"os"
"strings"
"sync"
"time"
"github.com/google/uuid"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
)
type Announcer struct {
logger mlogger.Logger
producer msg.Producer
sender string
announce Announcement
startOnce sync.Once
stopOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
}
func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, announce Announcement) *Announcer {
if logger != nil {
logger = logger.Named("discovery")
}
announce = normalizeAnnouncement(announce)
if announce.Service == "" {
announce.Service = strings.TrimSpace(sender)
}
if announce.ID == "" {
announce.ID = DefaultInstanceID(announce.Service)
}
if announce.InvokeURI == "" && announce.Service != "" {
announce.InvokeURI = DefaultInvokeURI(announce.Service)
}
return &Announcer{
logger: logger,
producer: producer,
sender: strings.TrimSpace(sender),
announce: announce,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
func (a *Announcer) Start() {
if a == nil {
return
}
a.startOnce.Do(func() {
if a.producer == nil {
a.logWarn("Discovery announce skipped: producer not configured")
close(a.doneCh)
return
}
if strings.TrimSpace(a.announce.ID) == "" {
a.logWarn("Discovery announce skipped: missing instance id")
close(a.doneCh)
return
}
a.sendAnnouncement()
a.sendHeartbeat()
go a.heartbeatLoop()
})
}
func (a *Announcer) Stop() {
if a == nil {
return
}
a.stopOnce.Do(func() {
close(a.stopCh)
<-a.doneCh
})
}
func (a *Announcer) heartbeatLoop() {
defer close(a.doneCh)
interval := time.Duration(a.announce.Health.IntervalSec) * time.Second
if interval <= 0 {
interval = time.Duration(DefaultHealthIntervalSec) * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-a.stopCh:
return
case <-ticker.C:
a.sendHeartbeat()
}
}
}
func (a *Announcer) sendAnnouncement() {
env := NewServiceAnnounceEnvelope(a.sender, a.announce)
if a.announce.Rail != "" {
env = NewGatewayAnnounceEnvelope(a.sender, a.announce)
}
if err := a.producer.SendMessage(env); err != nil {
a.logWarn("Failed to publish discovery announce: " + err.Error())
return
}
a.logInfo("Discovery announce published")
}
func (a *Announcer) sendHeartbeat() {
hb := Heartbeat{
ID: a.announce.ID,
Status: "ok",
TS: time.Now().Unix(),
}
if err := a.producer.SendMessage(NewHeartbeatEnvelope(a.sender, hb)); err != nil {
a.logWarn("Failed to publish discovery heartbeat: " + err.Error())
}
}
func (a *Announcer) logInfo(message string) {
if a.logger == nil {
return
}
a.logger.Info(message)
}
func (a *Announcer) logWarn(message string) {
if a.logger == nil {
return
}
a.logger.Warn(message)
}
func DefaultInstanceID(service string) string {
clean := strings.ToLower(strings.TrimSpace(service))
if clean == "" {
clean = "service"
}
host, _ := os.Hostname()
host = strings.ToLower(strings.TrimSpace(host))
uid := uuid.NewString()
if host == "" {
return clean + "_" + uid
}
return clean + "_" + host + "_" + uid
}
func DefaultInvokeURI(service string) string {
clean := strings.ToLower(strings.TrimSpace(service))
if clean == "" {
return ""
}
return "grpc://" + clean
}

137
api/pkg/discovery/client.go Normal file
View File

@@ -0,0 +1,137 @@
package discovery
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"github.com/google/uuid"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer"
me "github.com/tech/sendico/pkg/messaging/envelope"
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Client struct {
logger mlogger.Logger
producer msg.Producer
consumer msg.Consumer
sender string
mu sync.Mutex
pending map[string]chan LookupResponse
}
func NewClient(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, sender string) (*Client, error) {
if broker == nil {
return nil, errors.New("discovery client: broker is nil")
}
if logger != nil {
logger = logger.Named("discovery_client")
}
if producer == nil {
producer = msgproducer.NewProducer(logger, broker)
}
sender = strings.TrimSpace(sender)
if sender == "" {
sender = "discovery_client"
}
consumer, err := cons.NewConsumer(logger, broker, LookupResponseEvent())
if err != nil {
return nil, err
}
client := &Client{
logger: logger,
producer: producer,
consumer: consumer,
sender: sender,
pending: map[string]chan LookupResponse{},
}
go func() {
if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil && client.logger != nil {
client.logger.Warn("Discovery lookup consumer stopped", zap.Error(err))
}
}()
return client, nil
}
func (c *Client) Close() {
if c == nil {
return
}
if c.consumer != nil {
c.consumer.Close()
}
c.mu.Lock()
for key, ch := range c.pending {
close(ch)
delete(c.pending, key)
}
c.mu.Unlock()
}
func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) {
if c == nil || c.producer == nil {
return LookupResponse{}, errors.New("discovery client: producer not configured")
}
requestID := uuid.NewString()
ch := make(chan LookupResponse, 1)
c.mu.Lock()
c.pending[requestID] = ch
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.pending, requestID)
c.mu.Unlock()
}()
req := LookupRequest{RequestID: requestID}
if err := c.producer.SendMessage(NewLookupRequestEnvelope(c.sender, req)); err != nil {
return LookupResponse{}, err
}
select {
case <-ctx.Done():
return LookupResponse{}, ctx.Err()
case resp := <-ch:
return resp, nil
}
}
func (c *Client) handleLookupResponse(_ context.Context, env me.Envelope) error {
var payload LookupResponse
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
c.logWarn("Failed to decode discovery lookup response", zap.Error(err))
return err
}
requestID := strings.TrimSpace(payload.RequestID)
if requestID == "" {
return nil
}
c.mu.Lock()
ch := c.pending[requestID]
c.mu.Unlock()
if ch != nil {
ch <- payload
}
return nil
}
func (c *Client) logWarn(message string, fields ...zap.Field) {
if c == nil || c.logger == nil {
return
}
c.logger.Warn(message, fields...)
}

View File

@@ -0,0 +1,31 @@
package discovery
import (
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
)
func ServiceAnnounceEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryServiceAnnounce)
}
func GatewayAnnounceEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryGatewayAnnounce)
}
func HeartbeatEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryHeartbeat)
}
func LookupRequestEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryLookupRequest)
}
func LookupResponseEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryLookupResponse)
}
func RefreshUIEvent() model.NotificationEvent {
return model.NewNotification(mservice.Discovery, nm.NADiscoveryRefreshUI)
}

View File

@@ -0,0 +1,69 @@
package discovery
type LookupRequest struct {
RequestID string `json:"requestId,omitempty"`
}
type LookupResponse struct {
RequestID string `json:"requestId,omitempty"`
Services []ServiceSummary `json:"services,omitempty"`
Gateways []GatewaySummary `json:"gateways,omitempty"`
}
type ServiceSummary struct {
ID string `json:"id"`
Service string `json:"service"`
Ops []string `json:"ops,omitempty"`
Version string `json:"version,omitempty"`
Healthy bool `json:"healthy,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
}
type GatewaySummary struct {
ID string `json:"id"`
Rail string `json:"rail"`
Network string `json:"network,omitempty"`
Currencies []string `json:"currencies,omitempty"`
Ops []string `json:"ops,omitempty"`
Limits *Limits `json:"limits,omitempty"`
Version string `json:"version,omitempty"`
Healthy bool `json:"healthy,omitempty"`
RoutingPriority int `json:"routingPriority,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
}
func (r *Registry) Lookup(now time.Time) LookupResponse {
entries := r.List(now, true)
resp := LookupResponse{
Services: make([]ServiceSummary, 0),
Gateways: make([]GatewaySummary, 0),
}
for _, entry := range entries {
if entry.Rail != "" {
resp.Gateways = append(resp.Gateways, GatewaySummary{
ID: entry.ID,
Rail: entry.Rail,
Network: entry.Network,
Currencies: cloneStrings(entry.Currencies),
Ops: cloneStrings(entry.Operations),
Limits: cloneLimits(entry.Limits),
Version: entry.Version,
Healthy: entry.Healthy,
RoutingPriority: entry.RoutingPriority,
InvokeURI: entry.InvokeURI,
})
continue
}
resp.Services = append(resp.Services, ServiceSummary{
ID: entry.ID,
Service: entry.Service,
Ops: cloneStrings(entry.Operations),
Version: entry.Version,
Healthy: entry.Healthy,
InvokeURI: entry.InvokeURI,
})
}
return resp
}

View File

@@ -0,0 +1,56 @@
package discovery
import (
"encoding/json"
"errors"
messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/model"
)
type jsonEnvelope struct {
messaging.Envelope
payload any
}
func (e *jsonEnvelope) Serialize() ([]byte, error) {
if e.payload == nil {
return nil, errors.New("discovery envelope payload is nil")
}
data, err := json.Marshal(e.payload)
if err != nil {
return nil, err
}
return e.Envelope.Wrap(data)
}
func newEnvelope(sender string, event model.NotificationEvent, payload any) messaging.Envelope {
return &jsonEnvelope{
Envelope: messaging.CreateEnvelope(sender, event),
payload: payload,
}
}
func NewServiceAnnounceEnvelope(sender string, payload Announcement) messaging.Envelope {
return newEnvelope(sender, ServiceAnnounceEvent(), payload)
}
func NewGatewayAnnounceEnvelope(sender string, payload Announcement) messaging.Envelope {
return newEnvelope(sender, GatewayAnnounceEvent(), payload)
}
func NewHeartbeatEnvelope(sender string, payload Heartbeat) messaging.Envelope {
return newEnvelope(sender, HeartbeatEvent(), payload)
}
func NewLookupRequestEnvelope(sender string, payload LookupRequest) messaging.Envelope {
return newEnvelope(sender, LookupRequestEvent(), payload)
}
func NewLookupResponseEnvelope(sender string, payload LookupResponse) messaging.Envelope {
return newEnvelope(sender, LookupResponseEvent(), payload)
}
func NewRefreshUIEnvelope(sender string, payload RefreshEvent) messaging.Envelope {
return newEnvelope(sender, RefreshUIEvent(), payload)
}

View File

@@ -0,0 +1,258 @@
package discovery
import (
"strings"
"sync"
"time"
)
const (
DefaultHealthIntervalSec = 10
DefaultHealthTimeoutSec = 30
)
type RegistryEntry struct {
ID string `json:"id"`
Service string `json:"service"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Operations []string `json:"operations,omitempty"`
Currencies []string `json:"currencies,omitempty"`
Limits *Limits `json:"limits,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
RoutingPriority int `json:"routingPriority,omitempty"`
Version string `json:"version,omitempty"`
Health HealthParams `json:"health,omitempty"`
LastHeartbeat time.Time `json:"lastHeartbeat,omitempty"`
Status string `json:"status,omitempty"`
Healthy bool `json:"healthy,omitempty"`
}
type Registry struct {
mu sync.RWMutex
entries map[string]*RegistryEntry
}
type UpdateResult struct {
Entry RegistryEntry
IsNew bool
WasHealthy bool
BecameHealthy bool
}
func NewRegistry() *Registry {
return &Registry{
entries: map[string]*RegistryEntry{},
}
}
func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time) UpdateResult {
entry := registryEntryFromAnnouncement(normalizeAnnouncement(announce), now)
r.mu.Lock()
defer r.mu.Unlock()
existing, ok := r.entries[entry.ID]
wasHealthy := false
if ok && existing != nil {
wasHealthy = existing.isHealthyAt(now)
}
entry.Healthy = entry.isHealthyAt(now)
r.entries[entry.ID] = &entry
return UpdateResult{
Entry: entry,
IsNew: !ok,
WasHealthy: wasHealthy,
BecameHealthy: !wasHealthy && entry.Healthy,
}
}
func (r *Registry) UpdateHeartbeat(id string, status string, ts time.Time, now time.Time) (UpdateResult, bool) {
id = strings.TrimSpace(id)
if id == "" {
return UpdateResult{}, false
}
if status == "" {
status = "ok"
}
if ts.IsZero() {
ts = now
}
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.entries[id]
if !ok || entry == nil {
return UpdateResult{}, false
}
wasHealthy := entry.isHealthyAt(now)
entry.Status = status
entry.LastHeartbeat = ts
entry.Healthy = entry.isHealthyAt(now)
return UpdateResult{
Entry: *entry,
IsNew: false,
WasHealthy: wasHealthy,
BecameHealthy: !wasHealthy && entry.Healthy,
}, true
}
func (r *Registry) List(now time.Time, onlyHealthy bool) []RegistryEntry {
r.mu.Lock()
defer r.mu.Unlock()
result := make([]RegistryEntry, 0, len(r.entries))
for _, entry := range r.entries {
if entry == nil {
continue
}
entry.Healthy = entry.isHealthyAt(now)
if onlyHealthy && !entry.Healthy {
continue
}
cp := *entry
result = append(result, cp)
}
return result
}
func registryEntryFromAnnouncement(announce Announcement, now time.Time) RegistryEntry {
status := "ok"
return RegistryEntry{
ID: strings.TrimSpace(announce.ID),
Service: strings.TrimSpace(announce.Service),
Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)),
Network: strings.ToUpper(strings.TrimSpace(announce.Network)),
Operations: cloneStrings(announce.Operations),
Currencies: cloneStrings(announce.Currencies),
Limits: cloneLimits(announce.Limits),
InvokeURI: strings.TrimSpace(announce.InvokeURI),
RoutingPriority: announce.RoutingPriority,
Version: strings.TrimSpace(announce.Version),
Health: normalizeHealth(announce.Health),
LastHeartbeat: now,
Status: status,
}
}
func normalizeAnnouncement(announce Announcement) Announcement {
announce.ID = strings.TrimSpace(announce.ID)
announce.Service = strings.TrimSpace(announce.Service)
announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail))
announce.Network = strings.ToUpper(strings.TrimSpace(announce.Network))
announce.Operations = normalizeStrings(announce.Operations, false)
announce.Currencies = normalizeStrings(announce.Currencies, true)
announce.InvokeURI = strings.TrimSpace(announce.InvokeURI)
announce.Version = strings.TrimSpace(announce.Version)
announce.Health = normalizeHealth(announce.Health)
if announce.Limits != nil {
announce.Limits = normalizeLimits(*announce.Limits)
}
return announce
}
func normalizeHealth(h HealthParams) HealthParams {
if h.IntervalSec <= 0 {
h.IntervalSec = DefaultHealthIntervalSec
}
if h.TimeoutSec <= 0 {
h.TimeoutSec = DefaultHealthTimeoutSec
}
if h.TimeoutSec < h.IntervalSec {
h.TimeoutSec = h.IntervalSec * 2
}
return h
}
func normalizeLimits(l Limits) *Limits {
res := l
if len(res.VolumeLimit) == 0 {
res.VolumeLimit = nil
}
if len(res.VelocityLimit) == 0 {
res.VelocityLimit = nil
}
return &res
}
func cloneLimits(src *Limits) *Limits {
if src == nil {
return nil
}
dst := *src
if src.VolumeLimit != nil {
dst.VolumeLimit = map[string]string{}
for key, value := range src.VolumeLimit {
if strings.TrimSpace(key) == "" {
continue
}
dst.VolumeLimit[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
if src.VelocityLimit != nil {
dst.VelocityLimit = map[string]int{}
for key, value := range src.VelocityLimit {
if strings.TrimSpace(key) == "" {
continue
}
dst.VelocityLimit[strings.TrimSpace(key)] = value
}
}
return &dst
}
func normalizeStrings(values []string, upper bool) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
clean := strings.TrimSpace(value)
if clean == "" {
continue
}
if upper {
clean = strings.ToUpper(clean)
}
if seen[clean] {
continue
}
seen[clean] = true
result = append(result, clean)
}
if len(result) == 0 {
return nil
}
return result
}
func cloneStrings(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, len(values))
copy(out, values)
return out
}
func (e *RegistryEntry) isHealthyAt(now time.Time) bool {
if e == nil {
return false
}
status := strings.ToLower(strings.TrimSpace(e.Status))
if status != "" && status != "ok" {
return false
}
if e.LastHeartbeat.IsZero() {
return false
}
timeout := time.Duration(e.Health.TimeoutSec) * time.Second
if timeout <= 0 {
timeout = time.Duration(DefaultHealthTimeoutSec) * time.Second
}
return now.Sub(e.LastHeartbeat) <= timeout
}

View File

@@ -0,0 +1,188 @@
package discovery
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type RegistryService struct {
logger mlogger.Logger
registry *Registry
producer msg.Producer
sender string
consumers []consumerHandler
startOnce sync.Once
stopOnce sync.Once
}
type consumerHandler struct {
consumer msg.Consumer
handler msg.MessageHandlerT
}
func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) {
if broker == nil {
return nil, errors.New("discovery registry: broker is nil")
}
if registry == nil {
registry = NewRegistry()
}
if logger != nil {
logger = logger.Named("discovery_registry")
}
sender = strings.TrimSpace(sender)
if sender == "" {
sender = "discovery"
}
serviceConsumer, err := cons.NewConsumer(logger, broker, ServiceAnnounceEvent())
if err != nil {
return nil, err
}
gatewayConsumer, err := cons.NewConsumer(logger, broker, GatewayAnnounceEvent())
if err != nil {
return nil, err
}
heartbeatConsumer, err := cons.NewConsumer(logger, broker, HeartbeatEvent())
if err != nil {
return nil, err
}
lookupConsumer, err := cons.NewConsumer(logger, broker, LookupRequestEvent())
if err != nil {
return nil, err
}
svc := &RegistryService{
logger: logger,
registry: registry,
producer: producer,
sender: sender,
consumers: []consumerHandler{
{consumer: serviceConsumer, handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env)
}},
{consumer: gatewayConsumer, handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env)
}},
{consumer: heartbeatConsumer, handler: svc.handleHeartbeat},
{consumer: lookupConsumer, handler: svc.handleLookup},
},
}
return svc, nil
}
func (s *RegistryService) Start() {
if s == nil {
return
}
s.startOnce.Do(func() {
for _, ch := range s.consumers {
ch := ch
go func() {
if err := ch.consumer.ConsumeMessages(ch.handler); err != nil && s.logger != nil {
s.logger.Warn("Discovery consumer stopped with error", zap.Error(err))
}
}()
}
})
}
func (s *RegistryService) Stop() {
if s == nil {
return
}
s.stopOnce.Do(func() {
for _, ch := range s.consumers {
if ch.consumer != nil {
ch.consumer.Close()
}
}
})
}
func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) error {
var payload Announcement
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery announce payload", zap.Error(err))
return err
}
now := time.Now()
result := s.registry.UpsertFromAnnouncement(payload, now)
if result.IsNew || result.BecameHealthy {
s.publishRefresh(result.Entry)
}
return nil
}
func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) error {
var payload Heartbeat
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery heartbeat payload", zap.Error(err))
return err
}
if payload.ID == "" {
return nil
}
ts := time.Unix(payload.TS, 0)
if ts.Unix() <= 0 {
ts = time.Now()
}
result, ok := s.registry.UpdateHeartbeat(payload.ID, strings.TrimSpace(payload.Status), ts, time.Now())
if ok && result.BecameHealthy {
s.publishRefresh(result.Entry)
}
return nil
}
func (s *RegistryService) handleLookup(_ context.Context, env me.Envelope) error {
if s.producer == nil {
s.logWarn("Discovery lookup request ignored: producer not configured")
return nil
}
var payload LookupRequest
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
s.logWarn("Failed to decode discovery lookup payload", zap.Error(err))
return err
}
resp := s.registry.Lookup(time.Now())
resp.RequestID = strings.TrimSpace(payload.RequestID)
if err := s.producer.SendMessage(NewLookupResponseEnvelope(s.sender, resp)); err != nil {
s.logWarn("Failed to publish discovery lookup response", zap.Error(err))
return err
}
return nil
}
func (s *RegistryService) publishRefresh(entry RegistryEntry) {
if s == nil || s.producer == nil {
return
}
payload := RefreshEvent{
Service: entry.Service,
Rail: entry.Rail,
Network: entry.Network,
Message: "new module available",
}
if err := s.producer.SendMessage(NewRefreshUIEnvelope(s.sender, payload)); err != nil {
s.logWarn("Failed to publish discovery refresh event", zap.Error(err))
}
}
func (s *RegistryService) logWarn(message string, fields ...zap.Field) {
if s.logger == nil {
return
}
s.logger.Warn(message, fields...)
}

View File

@@ -0,0 +1,10 @@
package discovery
const (
SubjectServiceAnnounce = "discovery.service.announce"
SubjectGatewayAnnounce = "discovery.gateway.announce"
SubjectHeartbeat = "discovery.service.heartbeat"
SubjectLookupRequest = "discovery.request.lookup"
SubjectLookupResponse = "discovery.response.lookup"
SubjectRefreshUI = "discovery.event.refresh_ui"
)

View File

@@ -0,0 +1,40 @@
package discovery
type HealthParams struct {
IntervalSec int `json:"intervalSec"`
TimeoutSec int `json:"timeoutSec"`
}
type Limits struct {
MinAmount string `json:"minAmount,omitempty"`
MaxAmount string `json:"maxAmount,omitempty"`
VolumeLimit map[string]string `json:"volumeLimit,omitempty"`
VelocityLimit map[string]int `json:"velocityLimit,omitempty"`
}
type Announcement struct {
ID string `json:"id"`
Service string `json:"service"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Operations []string `json:"operations,omitempty"`
Currencies []string `json:"currencies,omitempty"`
Limits *Limits `json:"limits,omitempty"`
InvokeURI string `json:"invokeURI,omitempty"`
RoutingPriority int `json:"routingPriority,omitempty"`
Version string `json:"version,omitempty"`
Health HealthParams `json:"health,omitempty"`
}
type Heartbeat struct {
ID string `json:"id"`
Status string `json:"status"`
TS int64 `json:"ts"`
}
type RefreshEvent struct {
Service string `json:"service,omitempty"`
Rail string `json:"rail,omitempty"`
Network string `json:"network,omitempty"`
Message string `json:"message,omitempty"`
}

View File

@@ -0,0 +1,66 @@
package consumer
import (
"context"
messaging "github.com/tech/sendico/pkg/messaging"
mb "github.com/tech/sendico/pkg/messaging/broker"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
type ChannelConsumer struct {
logger mlogger.Logger
broker mb.Broker
event model.NotificationEvent
ch <-chan me.Envelope
ctx context.Context
cancel context.CancelFunc
}
func (c *ChannelConsumer) ConsumeMessages(handleFunc messaging.MessageHandlerT) error {
c.logger.Info("Message consumer is ready")
for {
select {
case msg := <-c.ch:
if msg == nil { // nil message indicates the channel was closed
c.logger.Info("Consumer shutting down")
return nil
}
if err := handleFunc(c.ctx, msg); err != nil {
c.logger.Warn("Error processing message", zap.Error(err))
}
case <-c.ctx.Done():
c.logger.Info("Context done, shutting down")
return c.ctx.Err()
}
}
}
func (c *ChannelConsumer) Close() {
c.logger.Info("Shutting down...")
c.cancel()
if err := c.broker.Unsubscribe(c.event, c.ch); err != nil {
c.logger.Warn("Failed to unsubscribe", zap.Error(err))
}
}
func NewConsumer(logger mlogger.Logger, broker mb.Broker, event model.NotificationEvent) (*ChannelConsumer, error) {
ctx, cancel := context.WithCancel(context.Background())
ch, err := broker.Subscribe(event)
if err != nil {
logger.Warn("Failed to create channel consumer", zap.Error(err), zap.String("topic", event.ToString()))
cancel()
return nil, err
}
return &ChannelConsumer{
logger: logger.Named("consumer").Named(event.ToString()),
broker: broker,
event: event,
ch: ch,
ctx: ctx,
cancel: cancel,
}, nil
}

View File

@@ -11,4 +11,11 @@ const (
NAArchived NotificationAction = "archived"
NASent NotificationAction = "sent"
NAPasswordReset NotificationAction = "password_reset"
NADiscoveryServiceAnnounce NotificationAction = "service.announce"
NADiscoveryGatewayAnnounce NotificationAction = "gateway.announce"
NADiscoveryHeartbeat NotificationAction = "service.heartbeat"
NADiscoveryLookupRequest NotificationAction = "request.lookup"
NADiscoveryLookupResponse NotificationAction = "response.lookup"
NADiscoveryRefreshUI NotificationAction = "event.refresh_ui"
)

View File

@@ -36,7 +36,11 @@ func (ne *NotificationEventImp) Equals(other *NotificationEventImp) bool {
}
func (ne *NotificationEventImp) ToString() string {
return ne.StringType() + messageDelimiter + ne.StringAction()
action := ne.StringAction()
if strings.Contains(action, ".") {
return ne.StringType() + "." + action
}
return ne.StringType() + messageDelimiter + action
}
func (ne *NotificationEventImp) StringType() string {
@@ -76,7 +80,13 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) {
nm.NAUpdated,
nm.NADeleted,
nm.NAAssigned,
nm.NAPasswordReset:
nm.NAPasswordReset,
nm.NADiscoveryServiceAnnounce,
nm.NADiscoveryGatewayAnnounce,
nm.NADiscoveryHeartbeat,
nm.NADiscoveryLookupRequest,
nm.NADiscoveryLookupResponse,
nm.NADiscoveryRefreshUI:
return nm.NotificationAction(s), nil
default:
return "", merrors.DataConflict("invalid Notification action: " + s)

View File

@@ -8,6 +8,7 @@ const (
Accounts Type = "accounts" // Represents user accounts in the system
Confirmations Type = "confirmations" // Represents confirmation code flows
Amplitude Type = "amplitude" // Represents analytics integration with Amplitude
Discovery Type = "discovery" // Represents service discovery registry
Site Type = "site" // Represents public site endpoints
Changes Type = "changes" // Tracks changes made to resources
Clients Type = "clients" // Represents client information
@@ -34,6 +35,7 @@ const (
Notifications Type = "notifications" // Represents notifications sent to users
Organizations Type = "organizations" // Represents organizations in the system
Payments Type = "payments" // Represents payments service
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
PaymentMethods Type = "payment_methods" // Represents payment methods service
Permissions Type = "permissions" // Represents permissiosns service
Policies Type = "policies" // Represents access control policies
@@ -52,8 +54,8 @@ func StringToSType(s string) (Type, error) {
case Accounts, Confirmations, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, ChainWalletBalances,
ChainTransfers, ChainDeposits, MntxGateway, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger,
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
Organizations, Payments, PaymentOrchestrator, Permissions, Policies, PolicyAssignements,
RefreshTokens, Roles, Storage, Tenants, Workflows:
Organizations, Payments, PaymentRoutes, PaymentOrchestrator, Permissions, Policies, PolicyAssignements,
RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:
return Type(s), nil
default:
return "", merrors.InvalidArgument("invalid service type", s)

View File

@@ -0,0 +1,83 @@
package rail
import (
"context"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
// Money represents a currency amount using decimal-safe strings.
type Money = paymenttypes.Money
const (
TransferStatusUnspecified = "UNSPECIFIED"
TransferStatusSuccess = "SUCCESS"
TransferStatusFailed = "FAILED"
TransferStatusRejected = "REJECTED"
TransferStatusPending = "PENDING"
)
// RailCapabilities are declared per gateway instance.
type RailCapabilities struct {
CanPayIn bool
CanPayOut bool
CanReadBalance bool
CanSendFee bool
RequiresObserveConfirm bool
}
// FeeBreakdown provides a gateway-level fee description.
type FeeBreakdown struct {
FeeCode string
Amount *Money
Description string
}
// TransferRequest defines the inputs for sending value through a rail gateway.
type TransferRequest struct {
OrganizationRef string
FromAccountID string
ToAccountID string
Currency string
Network string
Amount string
Fee *Money
Fees []FeeBreakdown
IdempotencyKey string
Metadata map[string]string
ClientReference string
DestinationMemo string
}
// RailResult reports the outcome of a rail gateway operation.
type RailResult struct {
ReferenceID string
Status string
FinalAmount *Money
Error *RailError
}
// ObserveResult reports the outcome of a confirmation observation.
type ObserveResult struct {
ReferenceID string
Status string
FinalAmount *Money
Error *RailError
}
// RailError captures structured failure details from a gateway.
type RailError struct {
Code string
Message string
CanRetry bool
ShouldRollback bool
}
// RailGateway exposes unified gateway operations for external rails.
type RailGateway interface {
Rail() string
Network() string
Capabilities() RailCapabilities
Send(ctx context.Context, req TransferRequest) (RailResult, error)
Observe(ctx context.Context, referenceID string) (ObserveResult, error)
}

View File

@@ -0,0 +1,38 @@
package rail
import (
"context"
"time"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
)
// InternalLedger exposes unified ledger operations for orchestration.
type InternalLedger interface {
ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error)
CreateTransaction(ctx context.Context, tx LedgerTx) (string, error)
HoldBalance(ctx context.Context, accountID string, amount string) error
}
// LedgerTx captures ledger posting context used by orchestration.
type LedgerTx struct {
PaymentPlanID string
Currency string
Amount string
FeeAmount string
FromRail string
ToRail string
ExternalReferenceID string
FXRateUsed string
IdempotencyKey string
CreatedAt time.Time
// Internal fields required to map into the ledger API.
OrganizationRef string
LedgerAccountRef string
ContraLedgerAccountRef string
Description string
Charges []*ledgerv1.PostingLine
Metadata map[string]string
}

View File

@@ -0,0 +1,49 @@
package types
// Asset captures a chain and token identifier.
type Asset struct {
Chain string `bson:"chain,omitempty" json:"chain,omitempty"`
TokenSymbol string `bson:"tokenSymbol,omitempty" json:"tokenSymbol,omitempty"`
ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"`
}
func (a *Asset) GetChain() string {
if a == nil {
return ""
}
return a.Chain
}
func (a *Asset) GetTokenSymbol() string {
if a == nil {
return ""
}
return a.TokenSymbol
}
func (a *Asset) GetContractAddress() string {
if a == nil {
return ""
}
return a.ContractAddress
}
// NetworkFeeEstimate captures network fee estimation output.
type NetworkFeeEstimate struct {
NetworkFee *Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
EstimationContext string `bson:"estimationContext,omitempty" json:"estimationContext,omitempty"`
}
func (n *NetworkFeeEstimate) GetNetworkFee() *Money {
if n == nil {
return nil
}
return n.NetworkFee
}
func (n *NetworkFeeEstimate) GetEstimationContext() string {
if n == nil {
return ""
}
return n.EstimationContext
}

View File

@@ -0,0 +1,94 @@
package types
// InsufficientNetPolicy indicates how to handle insufficient net funds for fees.
type InsufficientNetPolicy string
const (
InsufficientNetUnspecified InsufficientNetPolicy = "UNSPECIFIED"
InsufficientNetBlockPosting InsufficientNetPolicy = "BLOCK_POSTING"
InsufficientNetSweepOrgCash InsufficientNetPolicy = "SWEEP_ORG_CASH"
InsufficientNetInvoiceLater InsufficientNetPolicy = "INVOICE_LATER"
)
// FeePolicy captures optional fee policy overrides.
type FeePolicy struct {
InsufficientNet InsufficientNetPolicy `bson:"insufficientNet,omitempty" json:"insufficientNet,omitempty"`
}
// EntrySide captures debit/credit semantics for fee lines.
type EntrySide string
const (
EntrySideUnspecified EntrySide = "UNSPECIFIED"
EntrySideDebit EntrySide = "DEBIT"
EntrySideCredit EntrySide = "CREDIT"
)
// PostingLineType captures the semantic type of a fee line.
type PostingLineType string
const (
PostingLineTypeUnspecified PostingLineType = "UNSPECIFIED"
PostingLineTypeFee PostingLineType = "FEE"
PostingLineTypeTax PostingLineType = "TAX"
PostingLineTypeSpread PostingLineType = "SPREAD"
PostingLineTypeReversal PostingLineType = "REVERSAL"
)
// RoundingMode captures rounding behavior for fee rules.
type RoundingMode string
const (
RoundingModeUnspecified RoundingMode = "UNSPECIFIED"
RoundingModeHalfEven RoundingMode = "HALF_EVEN"
RoundingModeHalfUp RoundingMode = "HALF_UP"
RoundingModeDown RoundingMode = "DOWN"
)
// FeeLine stores derived fee posting data.
type FeeLine struct {
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
Money *Money `bson:"money,omitempty" json:"money,omitempty"`
LineType PostingLineType `bson:"lineType,omitempty" json:"lineType,omitempty"`
Side EntrySide `bson:"side,omitempty" json:"side,omitempty"`
Meta map[string]string `bson:"meta,omitempty" json:"meta,omitempty"`
}
func (l *FeeLine) GetMoney() *Money {
if l == nil {
return nil
}
return l.Money
}
func (l *FeeLine) GetSide() EntrySide {
if l == nil {
return EntrySideUnspecified
}
return l.Side
}
func (l *FeeLine) GetLineType() PostingLineType {
if l == nil {
return PostingLineTypeUnspecified
}
return l.LineType
}
func (l *FeeLine) GetLedgerAccountRef() string {
if l == nil {
return ""
}
return l.LedgerAccountRef
}
// AppliedRule stores fee rule audit data.
type AppliedRule struct {
RuleID string `bson:"ruleId,omitempty" json:"ruleId,omitempty"`
RuleVersion string `bson:"ruleVersion,omitempty" json:"ruleVersion,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Rounding RoundingMode `bson:"rounding,omitempty" json:"rounding,omitempty"`
TaxCode string `bson:"taxCode,omitempty" json:"taxCode,omitempty"`
TaxRate string `bson:"taxRate,omitempty" json:"taxRate,omitempty"`
Parameters map[string]string `bson:"parameters,omitempty" json:"parameters,omitempty"`
}

View File

@@ -0,0 +1,107 @@
package types
// CurrencyPair describes base/quote currencies.
type CurrencyPair struct {
Base string `bson:"base,omitempty" json:"base,omitempty"`
Quote string `bson:"quote,omitempty" json:"quote,omitempty"`
}
func (p *CurrencyPair) GetBase() string {
if p == nil {
return ""
}
return p.Base
}
func (p *CurrencyPair) GetQuote() string {
if p == nil {
return ""
}
return p.Quote
}
// FXSide indicates the direction of an FX trade.
type FXSide string
const (
FXSideUnspecified FXSide = "UNSPECIFIED"
FXSideBuyBaseSellQuote FXSide = "BUY_BASE_SELL_QUOTE"
FXSideSellBaseBuyQuote FXSide = "SELL_BASE_BUY_QUOTE"
)
// FXQuote captures a priced FX quote.
type FXQuote struct {
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
Pair *CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"`
Side FXSide `bson:"side,omitempty" json:"side,omitempty"`
Price *Decimal `bson:"price,omitempty" json:"price,omitempty"`
BaseAmount *Money `bson:"baseAmount,omitempty" json:"baseAmount,omitempty"`
QuoteAmount *Money `bson:"quoteAmount,omitempty" json:"quoteAmount,omitempty"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs,omitempty" json:"expiresAtUnixMs,omitempty"`
Provider string `bson:"provider,omitempty" json:"provider,omitempty"`
RateRef string `bson:"rateRef,omitempty" json:"rateRef,omitempty"`
Firm bool `bson:"firm,omitempty" json:"firm,omitempty"`
}
func (q *FXQuote) GetPair() *CurrencyPair {
if q == nil {
return nil
}
return q.Pair
}
func (q *FXQuote) GetSide() FXSide {
if q == nil {
return FXSideUnspecified
}
return q.Side
}
func (q *FXQuote) GetPrice() *Decimal {
if q == nil {
return nil
}
return q.Price
}
func (q *FXQuote) GetBaseAmount() *Money {
if q == nil {
return nil
}
return q.BaseAmount
}
func (q *FXQuote) GetQuoteAmount() *Money {
if q == nil {
return nil
}
return q.QuoteAmount
}
func (q *FXQuote) GetExpiresAtUnixMs() int64 {
if q == nil {
return 0
}
return q.ExpiresAtUnixMs
}
func (q *FXQuote) GetProvider() string {
if q == nil {
return ""
}
return q.Provider
}
func (q *FXQuote) GetRateRef() string {
if q == nil {
return ""
}
return q.RateRef
}
func (q *FXQuote) GetFirm() bool {
if q == nil {
return false
}
return q.Firm
}

View File

@@ -0,0 +1,33 @@
package types
// Decimal holds a decimal value as a string.
type Decimal struct {
Value string `bson:"value,omitempty" json:"value,omitempty"`
}
func (d *Decimal) GetValue() string {
if d == nil {
return ""
}
return d.Value
}
// Money represents a currency amount using decimal-safe strings.
type Money struct {
Amount string `bson:"amount,omitempty" json:"amount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
}
func (m *Money) GetAmount() string {
if m == nil {
return ""
}
return m.Amount
}
func (m *Money) GetCurrency() string {
if m == nil {
return ""
}
return m.Currency
}

View File

@@ -26,6 +26,27 @@ enum PaymentMethodType {
PM_LOCAL_BANK = 7; // generic local rails, refine later if needed
}
// Rail identifiers for orchestration. Extend with new rails as needed.
enum Rail {
RAIL_UNSPECIFIED = 0;
RAIL_CRYPTO = 1;
RAIL_PROVIDER_SETTLEMENT = 2;
RAIL_LEDGER = 3;
RAIL_CARD_PAYOUT = 4;
RAIL_FIAT_ONRAMP = 5;
}
// Operations supported in a payment plan.
enum RailOperation {
RAIL_OPERATION_UNSPECIFIED = 0;
RAIL_OPERATION_DEBIT = 1;
RAIL_OPERATION_CREDIT = 2;
RAIL_OPERATION_SEND = 3;
RAIL_OPERATION_FEE = 4;
RAIL_OPERATION_OBSERVE_CONFIRM = 5;
RAIL_OPERATION_FX_CONVERT = 6;
}
// Limits in minor units, e.g. cents
message AmountLimits {
int64 min_minor = 1;
@@ -95,3 +116,44 @@ message GatewayDescriptor {
GatewayCapabilities capabilities = 6;
}
// Capabilities declared per gateway instance for orchestration.
message RailCapabilities {
bool can_pay_in = 1;
bool can_pay_out = 2;
bool can_read_balance = 3;
bool can_send_fee = 4;
bool requires_observe_confirm = 5;
}
message LimitsOverride {
string max_volume = 1;
string min_amount = 2;
string max_amount = 3;
string max_fee = 4;
int32 max_ops = 5;
}
// Limits are decimal-safe string amounts unless otherwise noted.
message Limits {
string min_amount = 1;
string max_amount = 2;
string per_tx_max_fee = 3;
string per_tx_min_amount = 4;
string per_tx_max_amount = 5;
map<string, string> volume_limit = 6; // bucket -> max volume
map<string, int32> velocity_limit = 7; // bucket -> max operations count
map<string, LimitsOverride> currency_limits = 8;
}
// A specific gateway instance for a given rail/network pairing.
message GatewayInstanceDescriptor {
string id = 1; // unique instance id
Rail rail = 2;
string network = 3;
repeated string currencies = 4;
RailCapabilities capabilities = 5;
Limits limits = 6;
string version = 7;
bool is_enabled = 8;
}

View File

@@ -5,7 +5,7 @@ package mntx.gateway.v1;
option go_package = "github.com/tech/sendico/pkg/proto/gateway/mntx/v1;mntxv1";
import "google/protobuf/timestamp.proto";
import "common/money/v1/money.proto";
import "common/gateway/v1/gateway.proto";
// Status of a payout request handled by Monetix.
enum PayoutStatus {
@@ -15,75 +15,6 @@ enum PayoutStatus {
PAYOUT_STATUS_FAILED = 3;
}
// Basic destination data for the payout.
message BankAccount {
string iban = 1;
string bic = 2;
string account_holder = 3;
string country = 4;
}
// Card destination for payouts (PAN-based or tokenized).
message CardDestination {
oneof card {
string pan = 1; // raw primary account number
string token = 2; // network or gateway-issued token
}
string cardholder_name = 3;
string exp_month = 4;
string exp_year = 5;
string country = 6;
}
// Wrapper allowing multiple payout destination types.
message PayoutDestination {
oneof destination {
BankAccount bank_account = 1;
CardDestination card = 2;
}
}
message Payout {
string payout_ref = 1;
string idempotency_key = 2;
string organization_ref = 3;
PayoutDestination destination = 4;
common.money.v1.Money amount = 5;
string description = 6;
map<string, string> metadata = 7;
PayoutStatus status = 8;
string failure_reason = 9;
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
}
message SubmitPayoutRequest {
string idempotency_key = 1;
string organization_ref = 2;
PayoutDestination destination = 3;
common.money.v1.Money amount = 4;
string description = 5;
map<string, string> metadata = 6;
string simulated_failure_reason = 7; // optional trigger to force a failed payout for testing
}
message SubmitPayoutResponse {
Payout payout = 1;
}
message GetPayoutRequest {
string payout_ref = 1;
}
message GetPayoutResponse {
Payout payout = 1;
}
// Event emitted over messaging when payout status changes.
message PayoutStatusChangedEvent {
Payout payout = 1;
}
// Request to initiate a Monetix card payout.
message CardPayoutRequest {
string payout_id = 1; // internal payout id, mapped to Monetix payment_id
@@ -144,6 +75,12 @@ message CardPayoutStatusChangedEvent {
CardPayoutState payout = 1;
}
message ListGatewayInstancesRequest {}
message ListGatewayInstancesResponse {
repeated common.gateway.v1.GatewayInstanceDescriptor items = 1;
}
// Request to initiate a token-based card payout.
message CardTokenPayoutRequest {
string payout_id = 1;
@@ -229,10 +166,9 @@ message CardTokenizeResponse {
}
service MntxGatewayService {
rpc SubmitPayout(SubmitPayoutRequest) returns (SubmitPayoutResponse);
rpc GetPayout(GetPayoutRequest) returns (GetPayoutResponse);
rpc CreateCardPayout(CardPayoutRequest) returns (CardPayoutResponse);
rpc GetCardPayoutStatus(GetCardPayoutStatusRequest) returns (GetCardPayoutStatusResponse);
rpc CreateCardTokenPayout(CardTokenPayoutRequest) returns (CardTokenPayoutResponse);
rpc CreateCardToken(CardTokenizeRequest) returns (CardTokenizeResponse);
rpc ListGatewayInstances(ListGatewayInstancesRequest) returns (ListGatewayInstancesResponse);
}

Some files were not shown because too many files have changed in this diff Show More