unified gateway interface
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
258
api/gateway/chain/client/rail_gateway.go
Normal file
258
api/gateway/chain/client/rail_gateway.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
63
api/gateway/mntx/internal/service/gateway/instances.go
Normal file
63
api/gateway/mntx/internal/service/gateway/instances.go
Normal 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package orchestrator
|
||||
|
||||
const (
|
||||
defaultCardGateway = "monetix"
|
||||
|
||||
stepCodeGasTopUp = "gas_top_up"
|
||||
stepCodeFundingTransfer = "funding_transfer"
|
||||
stepCodeCardPayout = "card_payout"
|
||||
stepCodeFeeTransfer = "fee_transfer"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
47
api/payments/orchestrator/storage/model/route.go
Normal file
47
api/payments/orchestrator/storage/model/route.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
165
api/payments/orchestrator/storage/mongo/store/routes.go
Normal file
165
api/payments/orchestrator/storage/mongo/store/routes.go
Normal 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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
157
api/pkg/discovery/announcer.go
Normal file
157
api/pkg/discovery/announcer.go
Normal 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
137
api/pkg/discovery/client.go
Normal 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...)
|
||||
}
|
||||
31
api/pkg/discovery/events.go
Normal file
31
api/pkg/discovery/events.go
Normal 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)
|
||||
}
|
||||
69
api/pkg/discovery/lookup.go
Normal file
69
api/pkg/discovery/lookup.go
Normal 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
|
||||
}
|
||||
56
api/pkg/discovery/messages.go
Normal file
56
api/pkg/discovery/messages.go
Normal 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)
|
||||
}
|
||||
258
api/pkg/discovery/registry.go
Normal file
258
api/pkg/discovery/registry.go
Normal 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
|
||||
}
|
||||
188
api/pkg/discovery/service.go
Normal file
188
api/pkg/discovery/service.go
Normal 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...)
|
||||
}
|
||||
10
api/pkg/discovery/subjects.go
Normal file
10
api/pkg/discovery/subjects.go
Normal 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"
|
||||
)
|
||||
40
api/pkg/discovery/types.go
Normal file
40
api/pkg/discovery/types.go
Normal 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"`
|
||||
}
|
||||
66
api/pkg/messaging/consumer/consumer.go
Normal file
66
api/pkg/messaging/consumer/consumer.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
83
api/pkg/payments/rail/gateway.go
Normal file
83
api/pkg/payments/rail/gateway.go
Normal 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)
|
||||
}
|
||||
38
api/pkg/payments/rail/ledger.go
Normal file
38
api/pkg/payments/rail/ledger.go
Normal 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
|
||||
}
|
||||
49
api/pkg/payments/types/chain.go
Normal file
49
api/pkg/payments/types/chain.go
Normal 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
|
||||
}
|
||||
94
api/pkg/payments/types/fees.go
Normal file
94
api/pkg/payments/types/fees.go
Normal 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"`
|
||||
}
|
||||
107
api/pkg/payments/types/fx.go
Normal file
107
api/pkg/payments/types/fx.go
Normal 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
|
||||
}
|
||||
33
api/pkg/payments/types/money.go
Normal file
33
api/pkg/payments/types/money.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user