TG settlement service
This commit is contained in:
@@ -6,8 +6,10 @@ import (
|
||||
mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
@@ -20,6 +22,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
|
||||
func (i *Imp) Shutdown() {
|
||||
i.stopDiscovery()
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
i.shutdownApp()
|
||||
i.closeClients()
|
||||
}
|
||||
@@ -37,11 +42,24 @@ func (i *Imp) Start() error {
|
||||
return mongostorage.New(logger, conn)
|
||||
}
|
||||
|
||||
var broker mb.Broker
|
||||
if cfg.Messaging != nil && cfg.Messaging.Driver != "" {
|
||||
broker, err = msg.CreateMessagingBroker(i.logger, cfg.Messaging)
|
||||
if err != nil {
|
||||
i.logger.Warn("Failed to create messaging broker", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
deps := i.initDependencies(cfg)
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
opts := i.buildServiceOptions(cfg, deps)
|
||||
return orchestrator.NewService(logger, repo, opts...), nil
|
||||
if broker != nil {
|
||||
opts = append(opts, orchestrator.WithPaymentGatewayBroker(broker))
|
||||
}
|
||||
svc := orchestrator.NewService(logger, repo, opts...)
|
||||
i.service = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "payments_orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
|
||||
@@ -5,6 +5,7 @@ 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/service/orchestrator"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
@@ -22,6 +23,7 @@ type Imp struct {
|
||||
discoveryWatcher *discovery.RegistryWatcher
|
||||
discoveryReg *discovery.Registry
|
||||
discoveryAnnouncer *discovery.Announcer
|
||||
service *orchestrator.Service
|
||||
feesConn *grpc.ClientConn
|
||||
ledgerClient ledgerclient.Client
|
||||
gatewayClient chainclient.Client
|
||||
|
||||
@@ -22,15 +22,16 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
|
||||
return model.PaymentIntent{}
|
||||
}
|
||||
intent := model.PaymentIntent{
|
||||
Kind: modelKindFromProto(src.GetKind()),
|
||||
Source: endpointFromProto(src.GetSource()),
|
||||
Destination: endpointFromProto(src.GetDestination()),
|
||||
Amount: moneyFromProto(src.GetAmount()),
|
||||
RequiresFX: src.GetRequiresFx(),
|
||||
FeePolicy: feePolicyFromProto(src.GetFeePolicy()),
|
||||
SettlementMode: settlementModeFromProto(src.GetSettlementMode()),
|
||||
Attributes: cloneMetadata(src.GetAttributes()),
|
||||
Customer: customerFromProto(src.GetCustomer()),
|
||||
Kind: modelKindFromProto(src.GetKind()),
|
||||
Source: endpointFromProto(src.GetSource()),
|
||||
Destination: endpointFromProto(src.GetDestination()),
|
||||
Amount: moneyFromProto(src.GetAmount()),
|
||||
RequiresFX: src.GetRequiresFx(),
|
||||
FeePolicy: feePolicyFromProto(src.GetFeePolicy()),
|
||||
SettlementMode: settlementModeFromProto(src.GetSettlementMode()),
|
||||
SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()),
|
||||
Attributes: cloneMetadata(src.GetAttributes()),
|
||||
Customer: customerFromProto(src.GetCustomer()),
|
||||
}
|
||||
if src.GetFx() != nil {
|
||||
intent.FX = fxIntentFromProto(src.GetFx())
|
||||
@@ -43,8 +44,9 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
|
||||
return model.PaymentEndpoint{Type: model.EndpointTypeUnspecified}
|
||||
}
|
||||
result := model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeUnspecified,
|
||||
Metadata: cloneMetadata(src.GetMetadata()),
|
||||
Type: model.EndpointTypeUnspecified,
|
||||
InstanceID: strings.TrimSpace(src.GetInstanceId()),
|
||||
Metadata: cloneMetadata(src.GetMetadata()),
|
||||
}
|
||||
if ledger := src.GetLedger(); ledger != nil {
|
||||
result.Type = model.EndpointTypeLedger
|
||||
@@ -160,15 +162,16 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
|
||||
|
||||
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
|
||||
intent := &orchestratorv1.PaymentIntent{
|
||||
Kind: protoKindFromModel(src.Kind),
|
||||
Source: protoEndpointFromModel(src.Source),
|
||||
Destination: protoEndpointFromModel(src.Destination),
|
||||
Amount: protoMoney(src.Amount),
|
||||
RequiresFx: src.RequiresFX,
|
||||
FeePolicy: feePolicyToProto(src.FeePolicy),
|
||||
SettlementMode: settlementModeToProto(src.SettlementMode),
|
||||
Attributes: cloneMetadata(src.Attributes),
|
||||
Customer: protoCustomerFromModel(src.Customer),
|
||||
Kind: protoKindFromModel(src.Kind),
|
||||
Source: protoEndpointFromModel(src.Source),
|
||||
Destination: protoEndpointFromModel(src.Destination),
|
||||
Amount: protoMoney(src.Amount),
|
||||
RequiresFx: src.RequiresFX,
|
||||
FeePolicy: feePolicyToProto(src.FeePolicy),
|
||||
SettlementMode: settlementModeToProto(src.SettlementMode),
|
||||
SettlementCurrency: strings.TrimSpace(src.SettlementCurrency),
|
||||
Attributes: cloneMetadata(src.Attributes),
|
||||
Customer: protoCustomerFromModel(src.Customer),
|
||||
}
|
||||
if src.FX != nil {
|
||||
intent.Fx = protoFXIntentFromModel(src.FX)
|
||||
@@ -214,7 +217,8 @@ func protoCustomerFromModel(src *model.Customer) *orchestratorv1.Customer {
|
||||
|
||||
func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint {
|
||||
endpoint := &orchestratorv1.PaymentEndpoint{
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
InstanceId: strings.TrimSpace(src.InstanceID),
|
||||
}
|
||||
switch src.Type {
|
||||
case model.EndpointTypeLedger:
|
||||
@@ -337,11 +341,16 @@ func protoPaymentStepFromModel(src *model.PaymentStep) *orchestratorv1.PaymentSt
|
||||
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),
|
||||
Rail: protoRailFromModel(src.Rail),
|
||||
GatewayId: strings.TrimSpace(src.GatewayID),
|
||||
Action: protoRailOperationFromModel(src.Action),
|
||||
Amount: protoMoney(src.Amount),
|
||||
Ref: strings.TrimSpace(src.Ref),
|
||||
StepId: strings.TrimSpace(src.StepID),
|
||||
InstanceId: strings.TrimSpace(src.InstanceID),
|
||||
DependsOn: cloneStringList(src.DependsOn),
|
||||
CommitPolicy: strings.TrimSpace(string(src.CommitPolicy)),
|
||||
CommitAfter: cloneStringList(src.CommitAfter),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +371,8 @@ func protoPaymentPlanFromModel(src *model.PaymentPlan) *orchestratorv1.PaymentPl
|
||||
Id: strings.TrimSpace(src.ID),
|
||||
Steps: steps,
|
||||
IdempotencyKey: strings.TrimSpace(src.IdempotencyKey),
|
||||
FxQuote: fxQuoteToProto(src.FXQuote),
|
||||
Fees: feeLinesToProto(src.Fees),
|
||||
}
|
||||
if !src.CreatedAt.IsZero() {
|
||||
plan.CreatedAt = timestamppb.New(src.CreatedAt.UTC())
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
paymodel "github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
cons "github.com/tech/sendico/pkg/messaging/consumer"
|
||||
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
|
||||
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) startGatewayConsumers() {
|
||||
if s == nil || s.gatewayBroker == nil {
|
||||
return
|
||||
}
|
||||
processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution)
|
||||
s.consumeGatewayProcessor(processor)
|
||||
}
|
||||
|
||||
func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) {
|
||||
consumer, err := cons.NewConsumer(s.logger, s.gatewayBroker, processor.GetSubject())
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to create payment gateway consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
|
||||
return
|
||||
}
|
||||
s.gatewayConsumers = append(s.gatewayConsumers, consumer)
|
||||
go func() {
|
||||
if err := consumer.ConsumeMessages(processor.Process); err != nil {
|
||||
s.logger.Warn("Payment gateway consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error {
|
||||
if exec == nil {
|
||||
return merrors.InvalidArgument("payment gateway execution is nil", "execution")
|
||||
}
|
||||
paymentRef := strings.TrimSpace(exec.PaymentIntentID)
|
||||
if paymentRef == "" {
|
||||
return merrors.InvalidArgument("payment_intent_id is required", "payment_intent_id")
|
||||
}
|
||||
if s.storage == nil || s.storage.Payments() == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
payment, err := s.storage.Payments().GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payment.Metadata == nil {
|
||||
payment.Metadata = map[string]string{}
|
||||
}
|
||||
if exec.RequestID != "" {
|
||||
payment.Metadata["gateway_request_id"] = exec.RequestID
|
||||
}
|
||||
if exec.QuoteRef != "" {
|
||||
payment.Metadata["gateway_quote_ref"] = exec.QuoteRef
|
||||
}
|
||||
if exec.ExecutedMoney != nil {
|
||||
payment.Metadata["gateway_executed_amount"] = exec.ExecutedMoney.Amount
|
||||
payment.Metadata["gateway_executed_currency"] = exec.ExecutedMoney.Currency
|
||||
}
|
||||
payment.Metadata["gateway_confirmation_status"] = string(exec.Status)
|
||||
|
||||
switch exec.Status {
|
||||
case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified:
|
||||
payment.State = paymodel.PaymentStateSettled
|
||||
payment.FailureCode = paymodel.PaymentFailureCodeUnspecified
|
||||
payment.FailureReason = ""
|
||||
case model.ConfirmationStatusRejected:
|
||||
payment.State = paymodel.PaymentStateFailed
|
||||
payment.FailureCode = paymodel.PaymentFailureCodePolicy
|
||||
payment.FailureReason = "gateway_rejected"
|
||||
case model.ConfirmationStatusTimeout:
|
||||
payment.State = paymodel.PaymentStateFailed
|
||||
payment.FailureCode = paymodel.PaymentFailureCodePolicy
|
||||
payment.FailureReason = "confirmation_timeout"
|
||||
default:
|
||||
s.logger.Warn("Unhandled gateway confirmation status", zap.String("status", string(exec.Status)), zap.String("payment_ref", paymentRef))
|
||||
}
|
||||
if err := s.storage.Payments().Update(ctx, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("Payment gateway execution applied", zap.String("payment_ref", paymentRef), zap.String("status", string(exec.Status)), zap.String("service", string(mservice.PaymentGateway)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, consumer := range s.gatewayConsumers {
|
||||
if consumer != nil {
|
||||
consumer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
paymodel "github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
func TestGatewayExecutionConfirmedUpdatesPayment(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
store := newHelperPaymentStore()
|
||||
payment := &paymodel.Payment{PaymentRef: "pi-1", State: paymodel.PaymentStateSubmitted}
|
||||
if err := store.Create(context.Background(), payment); err != nil {
|
||||
t.Fatalf("failed to seed payment: %v", err)
|
||||
}
|
||||
svc := &Service{
|
||||
logger: logger,
|
||||
storage: stubRepo{payments: store},
|
||||
}
|
||||
exec := &model.PaymentGatewayExecution{
|
||||
PaymentIntentID: "pi-1",
|
||||
Status: model.ConfirmationStatusConfirmed,
|
||||
RequestID: "req-1",
|
||||
QuoteRef: "quote-1",
|
||||
}
|
||||
if err := svc.onGatewayExecution(context.Background(), exec); err != nil {
|
||||
t.Fatalf("onGatewayExecution error: %v", err)
|
||||
}
|
||||
updated, _ := store.GetByPaymentRef(context.Background(), "pi-1")
|
||||
if updated.State != paymodel.PaymentStateSettled {
|
||||
t.Fatalf("expected payment settled, got %s", updated.State)
|
||||
}
|
||||
if updated.Metadata["gateway_request_id"] != "req-1" {
|
||||
t.Fatalf("expected gateway_request_id metadata")
|
||||
}
|
||||
if updated.Metadata["gateway_confirmation_status"] != string(model.ConfirmationStatusConfirmed) {
|
||||
t.Fatalf("expected gateway_confirmation_status metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayExecutionRejectedFailsPayment(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
store := newHelperPaymentStore()
|
||||
payment := &paymodel.Payment{PaymentRef: "pi-2", State: paymodel.PaymentStateSubmitted}
|
||||
if err := store.Create(context.Background(), payment); err != nil {
|
||||
t.Fatalf("failed to seed payment: %v", err)
|
||||
}
|
||||
svc := &Service{
|
||||
logger: logger,
|
||||
storage: stubRepo{payments: store},
|
||||
}
|
||||
exec := &model.PaymentGatewayExecution{
|
||||
PaymentIntentID: "pi-2",
|
||||
Status: model.ConfirmationStatusRejected,
|
||||
}
|
||||
if err := svc.onGatewayExecution(context.Background(), exec); err != nil {
|
||||
t.Fatalf("onGatewayExecution error: %v", err)
|
||||
}
|
||||
updated, _ := store.GetByPaymentRef(context.Background(), "pi-2")
|
||||
if updated.State != paymodel.PaymentStateFailed {
|
||||
t.Fatalf("expected payment failed, got %s", updated.State)
|
||||
}
|
||||
if updated.FailureReason != "gateway_rejected" {
|
||||
t.Fatalf("expected failure reason gateway_rejected, got %q", updated.FailureReason)
|
||||
}
|
||||
}
|
||||
@@ -465,13 +465,14 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
}
|
||||
|
||||
intentProto := &orchestratorv1.PaymentIntent{
|
||||
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
|
||||
Source: req.GetSource(),
|
||||
Destination: req.GetDestination(),
|
||||
Amount: amount,
|
||||
RequiresFx: true,
|
||||
Fx: fxIntent,
|
||||
FeePolicy: req.GetFeePolicy(),
|
||||
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
|
||||
Source: req.GetSource(),
|
||||
Destination: req.GetDestination(),
|
||||
Amount: amount,
|
||||
RequiresFx: true,
|
||||
Fx: fxIntent,
|
||||
FeePolicy: req.GetFeePolicy(),
|
||||
SettlementCurrency: strings.TrimSpace(amount.GetCurrency()),
|
||||
}
|
||||
|
||||
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
|
||||
@@ -55,14 +55,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or
|
||||
}
|
||||
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)
|
||||
ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
|
||||
}
|
||||
updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent())
|
||||
if payment.Execution == nil {
|
||||
@@ -139,7 +132,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or
|
||||
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 cardPayoutDependenciesConfirmed(payment.PaymentPlan, payment.ExecutionPlan) {
|
||||
if payment.Execution.CardPayoutRef != "" {
|
||||
payment.State = model.PaymentStateSubmitted
|
||||
} else {
|
||||
|
||||
@@ -46,6 +46,24 @@ func cloneMetadata(input map[string]string) map[string]string {
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneStringList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, clean)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -2,12 +2,14 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
@@ -73,10 +75,37 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
|
||||
if intent == nil {
|
||||
return false
|
||||
}
|
||||
if intent.GetRequiresFx() {
|
||||
if fxIntentForQuote(intent) != nil {
|
||||
return true
|
||||
}
|
||||
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
|
||||
return intent.GetRequiresFx()
|
||||
}
|
||||
|
||||
func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIntent {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil {
|
||||
return fx
|
||||
}
|
||||
amount := intent.GetAmount()
|
||||
if amount == nil {
|
||||
return nil
|
||||
}
|
||||
settlementCurrency := strings.TrimSpace(intent.GetSettlementCurrency())
|
||||
if settlementCurrency == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(amount.GetCurrency(), settlementCurrency) {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{
|
||||
Base: strings.TrimSpace(amount.GetCurrency()),
|
||||
Quote: settlementCurrency,
|
||||
},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
}
|
||||
}
|
||||
|
||||
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
)
|
||||
@@ -153,6 +154,14 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option
|
||||
}
|
||||
}
|
||||
|
||||
func WithPaymentGatewayBroker(broker mb.Broker) Option {
|
||||
return func(s *Service) {
|
||||
if broker != nil {
|
||||
s.gatewayBroker = broker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLedgerClient wires the ledger client.
|
||||
func WithLedgerClient(client ledgerclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
|
||||
@@ -39,11 +39,15 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym
|
||||
if routeStore == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
|
||||
}
|
||||
planTemplates := p.svc.storage.PlanTemplates()
|
||||
if planTemplates == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "plan_templates_store_unavailable", errStorageUnavailable)
|
||||
}
|
||||
builder := p.svc.deps.planBuilder
|
||||
if builder == nil {
|
||||
builder = &defaultPlanBuilder{}
|
||||
}
|
||||
plan, err := builder.Build(ctx, payment, quote, routeStore, p.svc.deps.gatewayRegistry)
|
||||
plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry)
|
||||
if err != nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
|
||||
}
|
||||
|
||||
@@ -22,28 +22,30 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage.
|
||||
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)
|
||||
order, _, err := planExecutionOrder(plan)
|
||||
if err != nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
|
||||
}
|
||||
|
||||
execPlan := ensureExecutionPlanForPlan(payment, plan)
|
||||
execSteps := executionStepsByCode(execPlan)
|
||||
planSteps := planStepsByID(plan)
|
||||
asyncSubmitted := false
|
||||
|
||||
for idx, step := range plan.Steps {
|
||||
for _, idx := range order {
|
||||
step := plan.Steps[idx]
|
||||
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]
|
||||
stepID := planStepID(step, idx)
|
||||
execStep := execSteps[stepID]
|
||||
if execStep == nil {
|
||||
execStep = &model.ExecutionStep{Code: stepID}
|
||||
execSteps[stepID] = execStep
|
||||
}
|
||||
status := executionStepStatus(execStep)
|
||||
switch status {
|
||||
case executionStepStatusConfirmed, executionStepStatusSkipped:
|
||||
@@ -58,17 +60,21 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage.
|
||||
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
|
||||
ready, blocked, err := stepDependenciesReady(step, execSteps, planSteps, false)
|
||||
if err != nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
|
||||
}
|
||||
if blocked {
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = failureCodeForStep(step)
|
||||
return p.persistPayment(ctx, store, payment)
|
||||
}
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
async, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, charges, idx)
|
||||
if err != nil {
|
||||
@@ -76,10 +82,6 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage.
|
||||
}
|
||||
if async {
|
||||
asyncSubmitted = true
|
||||
if isConsumerExecutionStep(execStep) || step.Action == model.RailOperationObserveConfirm {
|
||||
payment.State = model.PaymentStateSubmitted
|
||||
return p.persistPayment(ctx, store, payment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -121,12 +121,12 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) {
|
||||
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"}},
|
||||
{StepID: "crypto_send", Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}},
|
||||
{StepID: "crypto_fee", Rail: model.RailCrypto, Action: model.RailOperationFee, DependsOn: []string{"crypto_send"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}},
|
||||
{StepID: "crypto_observe", Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm, DependsOn: []string{"crypto_send"}},
|
||||
{StepID: "ledger_credit", Rail: model.RailLedger, Action: model.RailOperationCredit, DependsOn: []string{"crypto_observe"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}},
|
||||
{StepID: "card_payout", Rail: model.RailCardPayout, Action: model.RailOperationSend, DependsOn: []string{"ledger_credit"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}},
|
||||
{StepID: "ledger_debit", Rail: model.RailLedger, Action: model.RailOperationDebit, DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -172,8 +172,8 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) {
|
||||
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 debitCalls != 0 || creditCalls != 1 {
|
||||
t.Fatalf("expected ledger credit after source confirmation, debit=%d credit=%d", debitCalls, creditCalls)
|
||||
}
|
||||
if payoutCalls != 1 {
|
||||
t.Fatalf("expected card payout submitted, got %d", payoutCalls)
|
||||
@@ -181,4 +181,18 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) {
|
||||
if payment.Execution == nil || payment.Execution.CardPayoutRef == "" {
|
||||
t.Fatalf("expected card payout ref set")
|
||||
}
|
||||
|
||||
steps := executionStepsByCode(payment.ExecutionPlan)
|
||||
cardStep := steps["card_payout"]
|
||||
if cardStep == nil {
|
||||
t.Fatalf("expected card payout step in execution plan")
|
||||
}
|
||||
setExecutionStepStatus(cardStep, executionStepStatusConfirmed)
|
||||
|
||||
if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil {
|
||||
t.Fatalf("executePaymentPlan finalize error: %v", err)
|
||||
}
|
||||
if debitCalls != 1 || creditCalls != 1 {
|
||||
t.Fatalf("expected ledger debit after payout confirmation, debit=%d credit=%d", debitCalls, creditCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,41 +25,7 @@ func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote)
|
||||
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 {
|
||||
func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan {
|
||||
if payment == nil || plan == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -77,7 +43,7 @@ func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan,
|
||||
}
|
||||
steps := make([]*model.ExecutionStep, len(plan.Steps))
|
||||
for idx, planStep := range plan.Steps {
|
||||
code := planStepCode(idx)
|
||||
code := planStepID(planStep, idx)
|
||||
step := existing[code]
|
||||
if step == nil {
|
||||
step = &model.ExecutionStep{Code: code}
|
||||
@@ -86,11 +52,6 @@ func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan,
|
||||
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)
|
||||
}
|
||||
@@ -119,7 +80,12 @@ func executionPlanComplete(plan *model.ExecutionPlan) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func planStepCode(idx int) string {
|
||||
func planStepID(step *model.PaymentStep, idx int) string {
|
||||
if step != nil {
|
||||
if val := strings.TrimSpace(step.StepID); val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("plan_step_%d", idx)
|
||||
}
|
||||
|
||||
@@ -144,7 +110,11 @@ func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.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)))
|
||||
stepID := strings.TrimSpace(step.StepID)
|
||||
if stepID == "" {
|
||||
stepID = fmt.Sprintf("%d", idx)
|
||||
}
|
||||
return fmt.Sprintf("%s:plan:%s:%s:%s", base, stepID, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action)))
|
||||
}
|
||||
|
||||
func failureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func planExecutionOrder(plan *model.PaymentPlan) ([]int, map[string]int, error) {
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return nil, nil, merrors.InvalidArgument("payment plan: steps are required")
|
||||
}
|
||||
idToIndex := map[string]int{}
|
||||
for idx, step := range plan.Steps {
|
||||
if step == nil {
|
||||
return nil, nil, merrors.InvalidArgument("payment plan: step is required")
|
||||
}
|
||||
id := planStepID(step, idx)
|
||||
if _, exists := idToIndex[id]; exists {
|
||||
return nil, nil, merrors.InvalidArgument("payment plan: duplicate step id")
|
||||
}
|
||||
idToIndex[id] = idx
|
||||
}
|
||||
|
||||
indegree := make([]int, len(plan.Steps))
|
||||
adj := make([][]int, len(plan.Steps))
|
||||
for idx, step := range plan.Steps {
|
||||
for _, dep := range step.DependsOn {
|
||||
key := strings.TrimSpace(dep)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
depIdx, ok := idToIndex[key]
|
||||
if !ok {
|
||||
return nil, nil, merrors.InvalidArgument("payment plan: dependency missing")
|
||||
}
|
||||
adj[depIdx] = append(adj[depIdx], idx)
|
||||
indegree[idx]++
|
||||
}
|
||||
}
|
||||
|
||||
queue := make([]int, 0, len(plan.Steps))
|
||||
for idx := range indegree {
|
||||
if indegree[idx] == 0 {
|
||||
queue = append(queue, idx)
|
||||
}
|
||||
}
|
||||
sort.Ints(queue)
|
||||
|
||||
order := make([]int, 0, len(plan.Steps))
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
order = append(order, current)
|
||||
for _, next := range adj[current] {
|
||||
indegree[next]--
|
||||
if indegree[next] == 0 {
|
||||
queue = append(queue, next)
|
||||
}
|
||||
}
|
||||
sort.Ints(queue)
|
||||
}
|
||||
|
||||
if len(order) != len(plan.Steps) {
|
||||
return nil, nil, merrors.InvalidArgument("payment plan: dependency cycle detected")
|
||||
}
|
||||
return order, idToIndex, nil
|
||||
}
|
||||
|
||||
func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
|
||||
result := map[string]*model.ExecutionStep{}
|
||||
if plan == nil {
|
||||
return result
|
||||
}
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if code := strings.TrimSpace(step.Code); code != "" {
|
||||
result[code] = step
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep {
|
||||
result := map[string]*model.PaymentStep{}
|
||||
if plan == nil {
|
||||
return result
|
||||
}
|
||||
for idx, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
id := planStepID(step, idx)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
result[id] = step
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stepDependenciesReady(step *model.PaymentStep, execSteps map[string]*model.ExecutionStep, planSteps map[string]*model.PaymentStep, requireConfirmed bool) (bool, bool, error) {
|
||||
if step == nil {
|
||||
return false, false, merrors.InvalidArgument("payment plan: step is required")
|
||||
}
|
||||
for _, dep := range step.DependsOn {
|
||||
key := strings.TrimSpace(dep)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
execStep := execSteps[key]
|
||||
if execStep == nil {
|
||||
return false, false, merrors.InvalidArgument("payment plan: dependency missing")
|
||||
}
|
||||
depStep := planSteps[key]
|
||||
needsConfirm := requireConfirmed
|
||||
if depStep != nil && depStep.Action == model.RailOperationObserveConfirm {
|
||||
needsConfirm = true
|
||||
}
|
||||
status := executionStepStatus(execStep)
|
||||
switch status {
|
||||
case executionStepStatusFailed, executionStepStatusCancelled:
|
||||
return false, true, nil
|
||||
case executionStepStatusConfirmed, executionStepStatusSkipped:
|
||||
continue
|
||||
case executionStepStatusSubmitted:
|
||||
if needsConfirm {
|
||||
return false, false, nil
|
||||
}
|
||||
continue
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
if step.CommitPolicy != model.CommitPolicyAfterSuccess {
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
commitAfter := step.CommitAfter
|
||||
if len(commitAfter) == 0 {
|
||||
commitAfter = step.DependsOn
|
||||
}
|
||||
for _, dep := range commitAfter {
|
||||
key := strings.TrimSpace(dep)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
execStep := execSteps[key]
|
||||
if execStep == nil {
|
||||
return false, false, merrors.InvalidArgument("payment plan: commit dependency missing")
|
||||
}
|
||||
status := executionStepStatus(execStep)
|
||||
switch status {
|
||||
case executionStepStatusFailed, executionStepStatusCancelled:
|
||||
return false, true, nil
|
||||
case executionStepStatusConfirmed, executionStepStatusSkipped:
|
||||
continue
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
}
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
func cardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
|
||||
if execPlan == nil {
|
||||
return false
|
||||
}
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return sourceStepsConfirmed(execPlan)
|
||||
}
|
||||
execSteps := executionStepsByCode(execPlan)
|
||||
planSteps := planStepsByID(plan)
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if step.Rail != model.RailCardPayout || step.Action != model.RailOperationSend {
|
||||
continue
|
||||
}
|
||||
ready, blocked, err := stepDependenciesReady(step, execSteps, planSteps, true)
|
||||
if err != nil || blocked {
|
||||
return false
|
||||
}
|
||||
return ready
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -12,6 +12,11 @@ type RouteStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
||||
}
|
||||
|
||||
// PlanTemplateStore exposes orchestration plan templates for plan construction.
|
||||
type PlanTemplateStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
|
||||
}
|
||||
|
||||
// GatewayRegistry exposes gateway instances for capability-based selection.
|
||||
type GatewayRegistry interface {
|
||||
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
|
||||
@@ -19,5 +24,5 @@ type GatewayRegistry interface {
|
||||
|
||||
// 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)
|
||||
Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@ import (
|
||||
|
||||
type defaultPlanBuilder struct{}
|
||||
|
||||
func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: payment is required")
|
||||
}
|
||||
if routes == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: routes store is required")
|
||||
}
|
||||
if templates == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan templates store is required")
|
||||
}
|
||||
|
||||
intent := payment.Intent
|
||||
if intent.Kind == model.PaymentKindFXConversion {
|
||||
@@ -42,10 +45,19 @@ func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment,
|
||||
return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment")
|
||||
}
|
||||
|
||||
path, err := buildRoutePath(ctx, routes, sourceRail, destRail, sourceNetwork, destNetwork)
|
||||
network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.buildPlanFromRoutePath(ctx, payment, quote, path, sourceRail, destRail, sourceNetwork, destNetwork, gateways)
|
||||
if _, err := selectRoute(ctx, routes, sourceRail, destRail, network); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template, err := selectPlanTemplate(ctx, templates, sourceRail, destRail, network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.buildPlanFromTemplate(ctx, payment, quote, template, sourceRail, destRail, sourceNetwork, destNetwork, gateways)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
@@ -33,7 +34,8 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{MaskedPan: "4111"},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"},
|
||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"},
|
||||
SettlementCurrency: "USDT",
|
||||
},
|
||||
LastQuote: &model.PaymentQuoteSnapshot{
|
||||
ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USDT", Amount: "95"},
|
||||
@@ -48,9 +50,26 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
||||
|
||||
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},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
templates := &stubPlanTemplateStore{
|
||||
templates: []*model.PaymentPlanTemplate{
|
||||
{
|
||||
FromRail: model.RailCrypto,
|
||||
ToRail: model.RailCardPayout,
|
||||
Network: "TRON",
|
||||
IsEnabled: true,
|
||||
Steps: []model.OrchestrationStep{
|
||||
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
||||
{StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}},
|
||||
{StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}},
|
||||
{StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"crypto_observe"}},
|
||||
{StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}},
|
||||
{StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -58,18 +77,21 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto-tron",
|
||||
InstanceID: "crypto-tron-1",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
CanSendFee: true,
|
||||
CanPayOut: true,
|
||||
CanSendFee: true,
|
||||
RequiresObserveConfirm: true,
|
||||
},
|
||||
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "settlement",
|
||||
InstanceID: "settlement-1",
|
||||
Rail: model.RailProviderSettlement,
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
@@ -80,6 +102,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
||||
},
|
||||
{
|
||||
ID: "card",
|
||||
InstanceID: "card-1",
|
||||
Rail: model.RailCardPayout,
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
@@ -91,7 +114,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := builder.Build(ctx, payment, quote, routes, registry)
|
||||
plan, err := builder.Build(ctx, payment, quote, routes, templates, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("expected plan, got error: %v", err)
|
||||
}
|
||||
@@ -102,12 +125,12 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
||||
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")
|
||||
assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-1", "USDT", "100")
|
||||
assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-1", "USDT", "5")
|
||||
assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-1", "", "")
|
||||
assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationCredit, "", "", "USDT", "95")
|
||||
assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-1", "USDT", "95")
|
||||
assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationDebit, "", "", "USDT", "95")
|
||||
}
|
||||
|
||||
func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
|
||||
@@ -135,9 +158,10 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
routes := &stubRouteStore{}
|
||||
templates := &stubPlanTemplateStore{}
|
||||
registry := &stubGatewayRegistry{}
|
||||
|
||||
plan, err := builder.Build(ctx, payment, &orchestratorv1.PaymentQuote{}, routes, registry)
|
||||
plan, err := builder.Build(ctx, payment, &orchestratorv1.PaymentQuote{}, routes, templates, registry)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got plan: %#v", plan)
|
||||
}
|
||||
@@ -155,6 +179,17 @@ func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilte
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
if filter != nil {
|
||||
if filter.FromRail != "" && route.FromRail != filter.FromRail {
|
||||
continue
|
||||
}
|
||||
if filter.ToRail != "" && route.ToRail != filter.ToRail {
|
||||
continue
|
||||
}
|
||||
if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filter != nil && filter.IsEnabled != nil {
|
||||
if route.IsEnabled != *filter.IsEnabled {
|
||||
continue
|
||||
@@ -165,6 +200,37 @@ func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilte
|
||||
return &model.PaymentRouteList{Items: items}, nil
|
||||
}
|
||||
|
||||
type stubPlanTemplateStore struct {
|
||||
templates []*model.PaymentPlanTemplate
|
||||
}
|
||||
|
||||
func (s *stubPlanTemplateStore) List(_ context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) {
|
||||
items := make([]*model.PaymentPlanTemplate, 0, len(s.templates))
|
||||
for _, tpl := range s.templates {
|
||||
if tpl == nil {
|
||||
continue
|
||||
}
|
||||
if filter != nil {
|
||||
if filter.FromRail != "" && tpl.FromRail != filter.FromRail {
|
||||
continue
|
||||
}
|
||||
if filter.ToRail != "" && tpl.ToRail != filter.ToRail {
|
||||
continue
|
||||
}
|
||||
if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filter != nil && filter.IsEnabled != nil {
|
||||
if tpl.IsEnabled != *filter.IsEnabled {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, tpl)
|
||||
}
|
||||
return &model.PaymentPlanTemplateList{Items: items}, nil
|
||||
}
|
||||
|
||||
type stubGatewayRegistry struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
@@ -173,11 +239,14 @@ func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceD
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func assertPlanStep(t *testing.T, step *model.PaymentStep, rail model.Rail, action model.RailOperation, gatewayID, currency, amount string) {
|
||||
func assertPlanStep(t *testing.T, step *model.PaymentStep, stepID string, rail model.Rail, action model.RailOperation, gatewayID, instanceID, currency, amount string) {
|
||||
t.Helper()
|
||||
if step == nil {
|
||||
t.Fatal("expected step")
|
||||
}
|
||||
if step.StepID != stepID {
|
||||
t.Fatalf("expected step id %q, got %q", stepID, step.StepID)
|
||||
}
|
||||
if step.Rail != rail {
|
||||
t.Fatalf("expected rail %s, got %s", rail, step.Rail)
|
||||
}
|
||||
@@ -187,6 +256,9 @@ func assertPlanStep(t *testing.T, step *model.PaymentStep, rail model.Rail, acti
|
||||
if step.GatewayID != gatewayID {
|
||||
t.Fatalf("expected gateway %q, got %q", gatewayID, step.GatewayID)
|
||||
}
|
||||
if step.InstanceID != instanceID {
|
||||
t.Fatalf("expected instance %q, got %q", instanceID, step.InstanceID)
|
||||
}
|
||||
if currency == "" && amount == "" {
|
||||
if step.Amount != nil && step.Amount.Amount != "" {
|
||||
t.Fatalf("expected empty amount, got %v", step.Amount)
|
||||
|
||||
@@ -11,17 +11,19 @@ import (
|
||||
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) {
|
||||
func ensureGatewayForAction(ctx context.Context, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, 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
|
||||
if instanceID == "" || strings.EqualFold(gw.InstanceID, instanceID) {
|
||||
if err := validateGatewayAction(gw, network, amount, action, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gw, nil
|
||||
}
|
||||
return gw, nil
|
||||
}
|
||||
gw, err := selectGateway(ctx, registry, rail, network, amount, action, dir)
|
||||
gw, err := selectGateway(ctx, registry, rail, network, amount, action, instanceID, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -66,7 +68,7 @@ func sendDirectionForRail(rail model.Rail) sendDirection {
|
||||
}
|
||||
}
|
||||
|
||||
func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) (*model.GatewayInstanceDescriptor, error) {
|
||||
func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) {
|
||||
if registry == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: gateway registry is required")
|
||||
}
|
||||
@@ -91,6 +93,9 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
|
||||
|
||||
eligible := make([]*model.GatewayInstanceDescriptor, 0)
|
||||
for _, gw := range all {
|
||||
if instanceID != "" && !strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
|
||||
continue
|
||||
}
|
||||
if !isGatewayEligible(gw, rail, network, currency, action, dir, amt) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
|
||||
return nil, merrors.InvalidArgument("plan builder: payment is required")
|
||||
}
|
||||
step := &model.PaymentStep{
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationFXConvert,
|
||||
Amount: cloneMoney(payment.Intent.Amount),
|
||||
StepID: "fx_convert",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationFXConvert,
|
||||
CommitPolicy: model.CommitPolicyImmediate,
|
||||
Amount: cloneMoney(payment.Intent.Amount),
|
||||
}
|
||||
return &model.PaymentPlan{
|
||||
ID: payment.PaymentRef,
|
||||
@@ -33,14 +35,20 @@ func buildLedgerTransferPlan(payment *model.Payment) (*model.PaymentPlan, error)
|
||||
amount := cloneMoney(payment.Intent.Amount)
|
||||
steps := []*model.PaymentStep{
|
||||
{
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationDebit,
|
||||
Amount: cloneMoney(amount),
|
||||
StepID: "ledger_debit",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationDebit,
|
||||
CommitPolicy: model.CommitPolicyImmediate,
|
||||
Amount: cloneMoney(amount),
|
||||
},
|
||||
{
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationCredit,
|
||||
Amount: cloneMoney(amount),
|
||||
StepID: "ledger_credit",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationCredit,
|
||||
DependsOn: []string{"ledger_debit"},
|
||||
CommitPolicy: model.CommitPolicyAfterSuccess,
|
||||
CommitAfter: []string{"ledger_debit"},
|
||||
Amount: cloneMoney(amount),
|
||||
},
|
||||
}
|
||||
return &model.PaymentPlan{
|
||||
|
||||
@@ -9,115 +9,85 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func buildRoutePath(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) ([]*model.PaymentRoute, error) {
|
||||
func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
|
||||
src := strings.ToUpper(strings.TrimSpace(sourceNetwork))
|
||||
dst := strings.ToUpper(strings.TrimSpace(destNetwork))
|
||||
if src != "" && dst != "" && !strings.EqualFold(src, dst) {
|
||||
return "", merrors.InvalidArgument("plan builder: source and destination networks mismatch")
|
||||
}
|
||||
|
||||
override := strings.ToUpper(strings.TrimSpace(attributeLookup(attrs,
|
||||
"network",
|
||||
"route_network",
|
||||
"routeNetwork",
|
||||
"source_network",
|
||||
"sourceNetwork",
|
||||
"destination_network",
|
||||
"destinationNetwork",
|
||||
)))
|
||||
if override != "" {
|
||||
if src != "" && !strings.EqualFold(src, override) {
|
||||
return "", merrors.InvalidArgument("plan builder: source network does not match override")
|
||||
}
|
||||
if dst != "" && !strings.EqualFold(dst, override) {
|
||||
return "", merrors.InvalidArgument("plan builder: destination network does not match override")
|
||||
}
|
||||
return override, nil
|
||||
}
|
||||
if src != "" {
|
||||
return src, nil
|
||||
}
|
||||
if dst != "" {
|
||||
return dst, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func selectRoute(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, network 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})
|
||||
result, err := routes.List(ctx, &model.PaymentRouteFilter{
|
||||
FromRail: sourceRail,
|
||||
ToRail: destRail,
|
||||
Network: "",
|
||||
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 {
|
||||
candidates := make([]*model.PaymentRoute, 0, len(result.Items))
|
||||
for _, route := range result.Items {
|
||||
if route == nil || !route.IsEnabled {
|
||||
continue
|
||||
}
|
||||
from := route.FromRail
|
||||
to := route.ToRail
|
||||
if from == "" || to == "" || from == model.RailUnspecified || to == model.RailUnspecified {
|
||||
if route.FromRail != sourceRail || route.ToRail != destRail {
|
||||
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 !routeMatchesNetwork(route, network) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, route)
|
||||
}
|
||||
|
||||
if !found {
|
||||
if len(candidates) == 0 {
|
||||
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")
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
pi := routePriority(candidates[i], network)
|
||||
pj := routePriority(candidates[j], network)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
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 ""
|
||||
if candidates[i].Network != candidates[j].Network {
|
||||
return candidates[i].Network < candidates[j].Network
|
||||
}
|
||||
return candidates[i].ID.Hex() < candidates[j].ID.Hex()
|
||||
})
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
func routeMatchesNetwork(route *model.PaymentRoute, network string) bool {
|
||||
@@ -125,13 +95,14 @@ func routeMatchesNetwork(route *model.PaymentRoute, network string) bool {
|
||||
return false
|
||||
}
|
||||
routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network))
|
||||
if strings.TrimSpace(network) == "" {
|
||||
return routeNetwork == ""
|
||||
}
|
||||
net := strings.ToUpper(strings.TrimSpace(network))
|
||||
if routeNetwork == "" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(routeNetwork, network)
|
||||
if net == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(routeNetwork, net)
|
||||
}
|
||||
|
||||
func routePriority(route *model.PaymentRoute, network string) int {
|
||||
@@ -139,7 +110,8 @@ func routePriority(route *model.PaymentRoute, network string) int {
|
||||
return 2
|
||||
}
|
||||
routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network))
|
||||
if network != "" && strings.EqualFold(routeNetwork, network) {
|
||||
net := strings.ToUpper(strings.TrimSpace(network))
|
||||
if net != "" && strings.EqualFold(routeNetwork, net) {
|
||||
return 0
|
||||
}
|
||||
if routeNetwork == "" {
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
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")
|
||||
func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
if template == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan template is required")
|
||||
}
|
||||
|
||||
sourceAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount")
|
||||
@@ -26,7 +26,7 @@ func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment
|
||||
feeAmount := resolveFeeAmount(payment, quote)
|
||||
feeRequired := isPositiveMoney(feeAmount)
|
||||
|
||||
var payoutAmount *paymenttypes.Money
|
||||
payoutAmount := settlementAmount
|
||||
if destRail == model.RailCardPayout {
|
||||
payoutAmount, err = cardPayoutAmount(payment)
|
||||
if err != nil {
|
||||
@@ -40,192 +40,158 @@ func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment
|
||||
ledgerDebitAmount = payoutAmount
|
||||
}
|
||||
|
||||
observeRequired := observeRailsFromPath(path)
|
||||
intermediate := intermediateRailsFromPath(path, sourceRail, destRail)
|
||||
|
||||
steps := make([]*model.PaymentStep, 0)
|
||||
steps := make([]*model.PaymentStep, 0, len(template.Steps))
|
||||
gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{}
|
||||
observeAdded := map[model.Rail]bool{}
|
||||
useSourceSend := isSendSourceRail(sourceRail)
|
||||
useDestSend := isSendDestinationRail(destRail)
|
||||
stepIDs := map[string]bool{}
|
||||
|
||||
for idx, edge := range path {
|
||||
if edge == nil {
|
||||
for _, tpl := range template.Steps {
|
||||
stepID := strings.TrimSpace(tpl.StepID)
|
||||
if stepID == "" {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan template step id is required")
|
||||
}
|
||||
if stepIDs[stepID] {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan template step id must be unique")
|
||||
}
|
||||
stepIDs[stepID] = true
|
||||
|
||||
action, err := actionForOperation(tpl.Operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if amount == nil && action != model.RailOperationObserveConfirm {
|
||||
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),
|
||||
})
|
||||
policy := tpl.CommitPolicy
|
||||
if strings.TrimSpace(string(policy)) == "" {
|
||||
policy = model.CommitPolicyImmediate
|
||||
}
|
||||
step := &model.PaymentStep{
|
||||
StepID: stepID,
|
||||
Rail: tpl.Rail,
|
||||
Action: action,
|
||||
DependsOn: cloneStringList(tpl.DependsOn),
|
||||
CommitPolicy: policy,
|
||||
CommitAfter: cloneStringList(tpl.CommitAfter),
|
||||
Amount: cloneMoney(amount),
|
||||
}
|
||||
|
||||
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 action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm {
|
||||
network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork)
|
||||
instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail)
|
||||
checkAmount := amount
|
||||
if action == model.RailOperationObserveConfirm {
|
||||
checkAmount = observeAmountForRail(tpl.Rail, sourceAmount, settlementAmount, payoutAmount)
|
||||
}
|
||||
gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail))
|
||||
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
|
||||
}
|
||||
step.GatewayID = strings.TrimSpace(gw.ID)
|
||||
step.InstanceID = strings.TrimSpace(gw.InstanceID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
steps = append(steps, step)
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
return nil, merrors.InvalidArgument("plan builder: empty payment plan")
|
||||
}
|
||||
|
||||
execQuote := executionQuote(payment, quote)
|
||||
return &model.PaymentPlan{
|
||||
ID: payment.PaymentRef,
|
||||
FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()),
|
||||
Fees: feeLinesFromProto(execQuote.GetFeeLines()),
|
||||
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
|
||||
func actionForOperation(operation string) (model.RailOperation, error) {
|
||||
op := strings.ToLower(strings.TrimSpace(operation))
|
||||
switch op {
|
||||
case "debit", "ledger.debit", "wallet.debit":
|
||||
return model.RailOperationDebit, nil
|
||||
case "credit", "ledger.credit", "wallet.credit":
|
||||
return model.RailOperationCredit, nil
|
||||
case "fx.convert", "fx_conversion", "fx.converted":
|
||||
return model.RailOperationFXConvert, nil
|
||||
case "observe", "observe.confirm", "observe.confirmation", "observe.crypto", "observe.card":
|
||||
return model.RailOperationObserveConfirm, nil
|
||||
case "fee", "fee.send":
|
||||
return model.RailOperationFee, nil
|
||||
case "send", "payout.card", "payout.crypto", "payout.fiat", "payin.crypto", "payin.fiat", "fund.crypto", "fund.card":
|
||||
return model.RailOperationSend, nil
|
||||
}
|
||||
return observe
|
||||
|
||||
switch strings.ToUpper(strings.TrimSpace(operation)) {
|
||||
case string(model.RailOperationDebit):
|
||||
return model.RailOperationDebit, nil
|
||||
case string(model.RailOperationCredit):
|
||||
return model.RailOperationCredit, nil
|
||||
case string(model.RailOperationSend):
|
||||
return model.RailOperationSend, nil
|
||||
case string(model.RailOperationFee):
|
||||
return model.RailOperationFee, nil
|
||||
case string(model.RailOperationObserveConfirm):
|
||||
return model.RailOperationObserveConfirm, nil
|
||||
case string(model.RailOperationFXConvert):
|
||||
return model.RailOperationFXConvert, nil
|
||||
}
|
||||
|
||||
return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation")
|
||||
}
|
||||
|
||||
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
|
||||
func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) {
|
||||
switch action {
|
||||
case model.RailOperationDebit:
|
||||
if rail == model.RailLedger {
|
||||
return cloneMoney(ledgerDebitAmount), nil
|
||||
}
|
||||
rail := edge.ToRail
|
||||
if rail == model.RailLedger || rail == sourceRail || rail == destRail || rail == model.RailUnspecified {
|
||||
continue
|
||||
return cloneMoney(settlementAmount), nil
|
||||
case model.RailOperationCredit:
|
||||
if rail == model.RailLedger {
|
||||
return cloneMoney(ledgerCreditAmount), nil
|
||||
}
|
||||
intermediate[rail] = true
|
||||
}
|
||||
return intermediate
|
||||
}
|
||||
|
||||
func isSendSourceRail(rail model.Rail) bool {
|
||||
switch rail {
|
||||
case model.RailCrypto, model.RailFiatOnRamp:
|
||||
return true
|
||||
return cloneMoney(settlementAmount), nil
|
||||
case model.RailOperationSend:
|
||||
switch rail {
|
||||
case sourceRail:
|
||||
return cloneMoney(sourceAmount), nil
|
||||
case destRail:
|
||||
return cloneMoney(payoutAmount), nil
|
||||
default:
|
||||
return cloneMoney(settlementAmount), nil
|
||||
}
|
||||
case model.RailOperationFee:
|
||||
if !feeRequired {
|
||||
return nil, nil
|
||||
}
|
||||
return cloneMoney(feeAmount), nil
|
||||
case model.RailOperationObserveConfirm:
|
||||
return nil, nil
|
||||
case model.RailOperationFXConvert:
|
||||
return cloneMoney(settlementAmount), nil
|
||||
default:
|
||||
return false
|
||||
return nil, merrors.InvalidArgument("plan builder: unsupported action")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string {
|
||||
if rail == sourceRail {
|
||||
return strings.TrimSpace(intent.Source.InstanceID)
|
||||
}
|
||||
if gw != nil && gw.Capabilities.RequiresObserveConfirm {
|
||||
return true
|
||||
if rail == destRail {
|
||||
return strings.TrimSpace(intent.Destination.InstanceID)
|
||||
}
|
||||
return false
|
||||
return ""
|
||||
}
|
||||
|
||||
func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func selectPlanTemplate(ctx context.Context, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
|
||||
if templates == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan templates store is required")
|
||||
}
|
||||
enabled := true
|
||||
result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{
|
||||
FromRail: sourceRail,
|
||||
ToRail: destRail,
|
||||
IsEnabled: &enabled,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil || len(result.Items) == 0 {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan template missing")
|
||||
}
|
||||
|
||||
candidates := make([]*model.PaymentPlanTemplate, 0, len(result.Items))
|
||||
for _, tpl := range result.Items {
|
||||
if tpl == nil || !tpl.IsEnabled {
|
||||
continue
|
||||
}
|
||||
if tpl.FromRail != sourceRail || tpl.ToRail != destRail {
|
||||
continue
|
||||
}
|
||||
if !templateMatchesNetwork(tpl, network) {
|
||||
continue
|
||||
}
|
||||
if err := validatePlanTemplate(tpl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates = append(candidates, tpl)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan template missing")
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
pi := templatePriority(candidates[i], network)
|
||||
pj := templatePriority(candidates[j], network)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
if candidates[i].Network != candidates[j].Network {
|
||||
return candidates[i].Network < candidates[j].Network
|
||||
}
|
||||
return candidates[i].ID.Hex() < candidates[j].ID.Hex()
|
||||
})
|
||||
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
func templateMatchesNetwork(template *model.PaymentPlanTemplate, network string) bool {
|
||||
if template == nil {
|
||||
return false
|
||||
}
|
||||
templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network))
|
||||
net := strings.ToUpper(strings.TrimSpace(network))
|
||||
if templateNetwork == "" {
|
||||
return true
|
||||
}
|
||||
if net == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(templateNetwork, net)
|
||||
}
|
||||
|
||||
func templatePriority(template *model.PaymentPlanTemplate, network string) int {
|
||||
if template == nil {
|
||||
return 2
|
||||
}
|
||||
templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network))
|
||||
net := strings.ToUpper(strings.TrimSpace(network))
|
||||
if net != "" && strings.EqualFold(templateNetwork, net) {
|
||||
return 0
|
||||
}
|
||||
if templateNetwork == "" {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func validatePlanTemplate(template *model.PaymentPlanTemplate) error {
|
||||
if template == nil {
|
||||
return merrors.InvalidArgument("plan builder: plan template is required")
|
||||
}
|
||||
if len(template.Steps) == 0 {
|
||||
return merrors.InvalidArgument("plan builder: plan template steps are required")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, step := range template.Steps {
|
||||
id := strings.TrimSpace(step.StepID)
|
||||
if id == "" {
|
||||
return merrors.InvalidArgument("plan builder: plan template step id is required")
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return merrors.InvalidArgument("plan builder: plan template step id must be unique")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if strings.TrimSpace(step.Operation) == "" {
|
||||
return merrors.InvalidArgument("plan builder: plan template operation is required")
|
||||
}
|
||||
}
|
||||
for _, step := range template.Steps {
|
||||
for _, dep := range step.DependsOn {
|
||||
if _, ok := seen[strings.TrimSpace(dep)]; !ok {
|
||||
return merrors.InvalidArgument("plan builder: plan template dependency missing")
|
||||
}
|
||||
}
|
||||
for _, dep := range step.CommitAfter {
|
||||
if _, ok := seen[strings.TrimSpace(dep)]; !ok {
|
||||
return merrors.InvalidArgument("plan builder: plan template commit dependency missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
@@ -21,8 +22,8 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
|
||||
intent := req.GetIntent()
|
||||
amount := intent.GetAmount()
|
||||
fxSide := fxv1.Side_SIDE_UNSPECIFIED
|
||||
if intent.GetFx() != nil {
|
||||
fxSide = intent.GetFx().GetSide()
|
||||
if fxIntent := fxIntentForQuote(intent); fxIntent != nil {
|
||||
fxSide = fxIntent.GetSide()
|
||||
}
|
||||
|
||||
var fxQuote *oraclev1.Quote
|
||||
@@ -42,10 +43,23 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
|
||||
feeBaseAmount = cloneProtoMoney(amount)
|
||||
}
|
||||
|
||||
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
|
||||
intentModel := intentFromProto(intent)
|
||||
sourceRail, _, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
destRail, _, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
feeRequired := feesRequiredForRails(sourceRail, destRail)
|
||||
feeQuote := &feesv1.PrecomputeFeesResponse{}
|
||||
if feeRequired {
|
||||
feeQuote, err = s.quoteFees(ctx, orgRef, req, feeBaseAmount)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
}
|
||||
feeCurrency := ""
|
||||
if feeBaseAmount != nil {
|
||||
feeCurrency = feeBaseAmount.GetCurrency()
|
||||
@@ -160,8 +174,11 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
meta := req.GetMeta()
|
||||
fxIntent := intent.GetFx()
|
||||
fxIntent := fxIntentForQuote(intent)
|
||||
if fxIntent == nil {
|
||||
if intent.GetRequiresFx() {
|
||||
return nil, merrors.InvalidArgument("fx intent missing")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -210,6 +227,13 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
|
||||
return quoteToProto(quote), nil
|
||||
}
|
||||
|
||||
func feesRequiredForRails(sourceRail, destRail model.Rail) bool {
|
||||
if sourceRail == model.RailLedger && destRail == model.RailLedger {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string {
|
||||
if intent == nil || len(s.deps.feeLedgerAccounts) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type feeEngineFake struct {
|
||||
precomputeCalls int
|
||||
}
|
||||
|
||||
func (f *feeEngineFake) QuoteFees(ctx context.Context, in *feesv1.QuoteFeesRequest, opts ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) {
|
||||
return &feesv1.QuoteFeesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *feeEngineFake) PrecomputeFees(ctx context.Context, in *feesv1.PrecomputeFeesRequest, opts ...grpc.CallOption) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
f.precomputeCalls++
|
||||
return &feesv1.PrecomputeFeesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *feeEngineFake) ValidateFeeToken(ctx context.Context, in *feesv1.ValidateFeeTokenRequest, opts ...grpc.CallOption) (*feesv1.ValidateFeeTokenResponse, error) {
|
||||
return &feesv1.ValidateFeeTokenResponse{}, nil
|
||||
}
|
||||
|
||||
func TestBuildPaymentQuote_RequestsFXWhenSettlementDiffers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
calls := 0
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
oracle: oracleDependency{
|
||||
client: &oracleclient.Fake{
|
||||
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||
calls++
|
||||
return &oracleclient.Quote{
|
||||
QuoteRef: "q1",
|
||||
Pair: params.Pair,
|
||||
Side: params.Side,
|
||||
Price: "1.1",
|
||||
BaseAmount: params.BaseAmount,
|
||||
QuoteAmount: params.QuoteAmount,
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
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: "100"},
|
||||
SettlementCurrency: "EUR",
|
||||
},
|
||||
}
|
||||
|
||||
if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil {
|
||||
t.Fatalf("buildPaymentQuote returned error: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected 1 fx quote call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPaymentQuote_FeesRequestedForExternalRails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
feeFake := &feeEngineFake{}
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
fees: feesDependency{client: feeFake},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Source: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}},
|
||||
},
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Card{Card: &orchestratorv1.CardEndpoint{}},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil {
|
||||
t.Fatalf("buildPaymentQuote returned error: %v", err)
|
||||
}
|
||||
if feeFake.precomputeCalls != 1 {
|
||||
t.Fatalf("expected 1 fee precompute call, got %d", feeFake.precomputeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPaymentQuote_FeesSkippedForLedgerTransfer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
feeFake := &feeEngineFake{}
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
fees: feesDependency{client: feeFake},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
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: "100"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil {
|
||||
t.Fatalf("buildPaymentQuote returned error: %v", err)
|
||||
}
|
||||
if feeFake.precomputeCalls != 0 {
|
||||
t.Fatalf("expected fee precompute to be skipped, got %d", feeFake.precomputeCalls)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
SettlementCurrency: "USD",
|
||||
Fx: &orchestratorv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"google.golang.org/grpc"
|
||||
@@ -37,6 +39,9 @@ type Service struct {
|
||||
h handlerSet
|
||||
comp componentSet
|
||||
|
||||
gatewayBroker mb.Broker
|
||||
gatewayConsumers []msg.Consumer
|
||||
|
||||
orchestratorv1.UnimplementedPaymentOrchestratorServer
|
||||
}
|
||||
|
||||
@@ -88,6 +93,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
|
||||
svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries"))
|
||||
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"), svc.submitCardPayout, svc.resumePaymentPlan)
|
||||
svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc)
|
||||
svc.startGatewayConsumers()
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error {
|
||||
if intent.GetAmount() == nil {
|
||||
return merrors.InvalidArgument("intent.amount is required")
|
||||
}
|
||||
if strings.TrimSpace(intent.GetSettlementCurrency()) == "" {
|
||||
return merrors.InvalidArgument("intent.settlement_currency is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
@@ -50,8 +51,9 @@ func TestRequireIdempotencyKey(t *testing.T) {
|
||||
func TestNewPayment(t *testing.T) {
|
||||
org := primitive.NewObjectID()
|
||||
intent := &orchestratorv1.PaymentIntent{
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
|
||||
SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
|
||||
SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED,
|
||||
SettlementCurrency: "USD",
|
||||
}
|
||||
quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"}
|
||||
p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote)
|
||||
@@ -79,7 +81,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
|
||||
Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"},
|
||||
QuoteRef: "missing",
|
||||
})
|
||||
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" {
|
||||
@@ -89,7 +91,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
|
||||
|
||||
func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
org := primitive.NewObjectID()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
@@ -114,7 +116,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
|
||||
func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
|
||||
org := primitive.NewObjectID()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
@@ -141,6 +143,51 @@ func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) {
|
||||
org := primitive.NewObjectID()
|
||||
intent := &orchestratorv1.PaymentIntent{
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
SettlementCurrency: "USD",
|
||||
}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
|
||||
feeFake := &feeEngineFake{}
|
||||
oracleCalls := 0
|
||||
svc := &Service{
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
deps: serviceDependencies{
|
||||
fees: feesDependency{client: feeFake},
|
||||
oracle: oracleDependency{client: &oracleclient.Fake{
|
||||
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||
oracleCalls++
|
||||
return &oracleclient.Quote{QuoteRef: "q1", ExpiresAt: time.Now()}, nil
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if feeFake.precomputeCalls != 0 {
|
||||
t.Fatalf("expected no fee recompute, got %d", feeFake.precomputeCalls)
|
||||
}
|
||||
if oracleCalls != 0 {
|
||||
t.Fatalf("expected no fx recompute, got %d", oracleCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
@@ -153,9 +200,28 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil
|
||||
},
|
||||
}
|
||||
routes := &stubRoutesStore{
|
||||
routes: []*model.PaymentRoute{
|
||||
{FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true},
|
||||
},
|
||||
}
|
||||
plans := &stubPlanTemplatesStore{
|
||||
templates: []*model.PaymentPlanTemplate{
|
||||
{
|
||||
FromRail: model.RailLedger,
|
||||
ToRail: model.RailLedger,
|
||||
IsEnabled: true,
|
||||
Steps: []model.OrchestrationStep{
|
||||
{StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit"},
|
||||
{StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"ledger_debit"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"ledger_debit"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(logger, stubRepo{
|
||||
payments: store,
|
||||
routes: &stubRoutesStore{},
|
||||
routes: routes,
|
||||
plans: plans,
|
||||
}, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake))
|
||||
svc.ensureHandlers()
|
||||
|
||||
@@ -166,7 +232,8 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
SettlementCurrency: "USD",
|
||||
}
|
||||
req := &orchestratorv1.InitiatePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -197,7 +264,8 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) {
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
SettlementCurrency: "USD",
|
||||
}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
@@ -212,10 +280,29 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) {
|
||||
return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil
|
||||
},
|
||||
}
|
||||
routes := &stubRoutesStore{
|
||||
routes: []*model.PaymentRoute{
|
||||
{FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true},
|
||||
},
|
||||
}
|
||||
plans := &stubPlanTemplatesStore{
|
||||
templates: []*model.PaymentPlanTemplate{
|
||||
{
|
||||
FromRail: model.RailLedger,
|
||||
ToRail: model.RailLedger,
|
||||
IsEnabled: true,
|
||||
Steps: []model.OrchestrationStep{
|
||||
{StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit"},
|
||||
{StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"ledger_debit"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"ledger_debit"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(logger, stubRepo{
|
||||
payments: store,
|
||||
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
|
||||
routes: &stubRoutesStore{},
|
||||
routes: routes,
|
||||
plans: plans,
|
||||
}, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake))
|
||||
svc.ensureHandlers()
|
||||
|
||||
@@ -245,6 +332,7 @@ type stubRepo struct {
|
||||
payments storage.PaymentsStore
|
||||
quotes storage.QuotesStore
|
||||
routes storage.RoutesStore
|
||||
plans storage.PlanTemplatesStore
|
||||
pingErr error
|
||||
}
|
||||
|
||||
@@ -252,6 +340,12 @@ 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 }
|
||||
func (s stubRepo) PlanTemplates() storage.PlanTemplatesStore {
|
||||
if s.plans != nil {
|
||||
return s.plans
|
||||
}
|
||||
return &stubPlanTemplatesStore{}
|
||||
}
|
||||
|
||||
type helperPaymentStore struct {
|
||||
byRef map[string]*model.Payment
|
||||
|
||||
@@ -94,11 +94,25 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
|
||||
store := newStubPaymentsStore()
|
||||
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},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON", IsEnabled: true},
|
||||
},
|
||||
}
|
||||
repo := &stubRepository{store: store, routes: routes}
|
||||
plans := &stubPlanTemplatesStore{
|
||||
templates: []*model.PaymentPlanTemplate{
|
||||
{
|
||||
FromRail: model.RailCrypto,
|
||||
ToRail: model.RailLedger,
|
||||
Network: "TRON",
|
||||
IsEnabled: true,
|
||||
Steps: []model.OrchestrationStep{
|
||||
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
||||
{StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}},
|
||||
{StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"crypto_observe"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
repo := &stubRepository{store: store, routes: routes, plans: plans}
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
@@ -116,20 +130,12 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto-tron",
|
||||
InstanceID: "crypto-tron-1",
|
||||
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{
|
||||
CanPayOut: true,
|
||||
RequiresObserveConfirm: true,
|
||||
},
|
||||
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
||||
@@ -367,6 +373,7 @@ type stubRepository struct {
|
||||
store *stubPaymentsStore
|
||||
quotes storage.QuotesStore
|
||||
routes storage.RoutesStore
|
||||
plans storage.PlanTemplatesStore
|
||||
}
|
||||
|
||||
func (r *stubRepository) Ping(context.Context) error { return nil }
|
||||
@@ -384,6 +391,13 @@ func (r *stubRepository) Routes() storage.RoutesStore {
|
||||
return &stubRoutesStore{}
|
||||
}
|
||||
|
||||
func (r *stubRepository) PlanTemplates() storage.PlanTemplatesStore {
|
||||
if r.plans != nil {
|
||||
return r.plans
|
||||
}
|
||||
return &stubPlanTemplatesStore{}
|
||||
}
|
||||
|
||||
type stubQuotesStore struct {
|
||||
quotes map[string]*model.PaymentQuoteRecord
|
||||
}
|
||||
@@ -431,6 +445,17 @@ func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFi
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
if filter != nil {
|
||||
if filter.FromRail != "" && route.FromRail != filter.FromRail {
|
||||
continue
|
||||
}
|
||||
if filter.ToRail != "" && route.ToRail != filter.ToRail {
|
||||
continue
|
||||
}
|
||||
if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filter != nil && filter.IsEnabled != nil {
|
||||
if route.IsEnabled != *filter.IsEnabled {
|
||||
continue
|
||||
@@ -441,6 +466,49 @@ func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFi
|
||||
return &model.PaymentRouteList{Items: items}, nil
|
||||
}
|
||||
|
||||
type stubPlanTemplatesStore struct {
|
||||
templates []*model.PaymentPlanTemplate
|
||||
}
|
||||
|
||||
func (s *stubPlanTemplatesStore) Create(ctx context.Context, template *model.PaymentPlanTemplate) error {
|
||||
return merrors.InvalidArgument("plan templates store not implemented")
|
||||
}
|
||||
|
||||
func (s *stubPlanTemplatesStore) Update(ctx context.Context, template *model.PaymentPlanTemplate) error {
|
||||
return merrors.InvalidArgument("plan templates store not implemented")
|
||||
}
|
||||
|
||||
func (s *stubPlanTemplatesStore) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error) {
|
||||
return nil, storage.ErrPlanTemplateNotFound
|
||||
}
|
||||
|
||||
func (s *stubPlanTemplatesStore) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) {
|
||||
items := make([]*model.PaymentPlanTemplate, 0, len(s.templates))
|
||||
for _, tpl := range s.templates {
|
||||
if tpl == nil {
|
||||
continue
|
||||
}
|
||||
if filter != nil {
|
||||
if filter.FromRail != "" && tpl.FromRail != filter.FromRail {
|
||||
continue
|
||||
}
|
||||
if filter.ToRail != "" && tpl.ToRail != filter.ToRail {
|
||||
continue
|
||||
}
|
||||
if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filter != nil && filter.IsEnabled != nil {
|
||||
if tpl.IsEnabled != *filter.IsEnabled {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, tpl)
|
||||
}
|
||||
return &model.PaymentPlanTemplateList{Items: items}, nil
|
||||
}
|
||||
|
||||
type stubPaymentsStore struct {
|
||||
payments map[string]*model.Payment
|
||||
byChain map[string]*model.Payment
|
||||
|
||||
@@ -29,6 +29,15 @@ const (
|
||||
SettlementModeFixReceived SettlementMode = "fix_received"
|
||||
)
|
||||
|
||||
// CommitPolicy controls when a step is committed during orchestration.
|
||||
type CommitPolicy string
|
||||
|
||||
const (
|
||||
CommitPolicyUnspecified CommitPolicy = "UNSPECIFIED"
|
||||
CommitPolicyImmediate CommitPolicy = "IMMEDIATE"
|
||||
CommitPolicyAfterSuccess CommitPolicy = "AFTER_SUCCESS"
|
||||
)
|
||||
|
||||
// PaymentState enumerates lifecycle phases.
|
||||
type PaymentState string
|
||||
|
||||
@@ -180,6 +189,7 @@ type CardPayout struct {
|
||||
// PaymentEndpoint is a polymorphic payment destination/source.
|
||||
type PaymentEndpoint struct {
|
||||
Type PaymentEndpointType `bson:"type" json:"type"`
|
||||
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
|
||||
Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"`
|
||||
ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"`
|
||||
ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"`
|
||||
@@ -199,16 +209,17 @@ type FXIntent struct {
|
||||
|
||||
// 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 *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"`
|
||||
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"`
|
||||
SettlementCurrency string `bson:"settlementCurrency,omitempty" json:"settlementCurrency,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.
|
||||
@@ -249,19 +260,26 @@ type ExecutionRefs struct {
|
||||
|
||||
// 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"`
|
||||
StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"`
|
||||
Rail Rail `bson:"rail" json:"rail"`
|
||||
GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"`
|
||||
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
|
||||
Action RailOperation `bson:"action" json:"action"`
|
||||
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
|
||||
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
|
||||
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
|
||||
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"`
|
||||
ID string `bson:"id,omitempty" json:"id,omitempty"`
|
||||
FXQuote *paymenttypes.FXQuote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
|
||||
Fees []*paymenttypes.FeeLine `bson:"fees,omitempty" json:"fees,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.
|
||||
@@ -338,6 +356,7 @@ func (p *Payment) Normalize() {
|
||||
p.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
p.Intent.SettlementCurrency = strings.TrimSpace(p.Intent.SettlementCurrency)
|
||||
if p.Intent.Customer != nil {
|
||||
p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID)
|
||||
p.Intent.Customer.FirstName = strings.TrimSpace(p.Intent.Customer.FirstName)
|
||||
@@ -380,9 +399,14 @@ func (p *Payment) Normalize() {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
step.StepID = strings.TrimSpace(step.StepID)
|
||||
step.Rail = Rail(strings.TrimSpace(string(step.Rail)))
|
||||
step.GatewayID = strings.TrimSpace(step.GatewayID)
|
||||
step.InstanceID = strings.TrimSpace(step.InstanceID)
|
||||
step.Action = RailOperation(strings.TrimSpace(string(step.Action)))
|
||||
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
|
||||
step.DependsOn = normalizeStringList(step.DependsOn)
|
||||
step.CommitAfter = normalizeStringList(step.CommitAfter)
|
||||
step.Ref = strings.TrimSpace(step.Ref)
|
||||
}
|
||||
}
|
||||
@@ -392,6 +416,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
|
||||
if ep == nil {
|
||||
return
|
||||
}
|
||||
ep.InstanceID = strings.TrimSpace(ep.InstanceID)
|
||||
if ep.Metadata != nil {
|
||||
for k, v := range ep.Metadata {
|
||||
ep.Metadata[k] = strings.TrimSpace(v)
|
||||
@@ -433,3 +458,34 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCommitPolicy(policy CommitPolicy) CommitPolicy {
|
||||
val := strings.ToUpper(strings.TrimSpace(string(policy)))
|
||||
switch CommitPolicy(val) {
|
||||
case CommitPolicyImmediate, CommitPolicyAfterSuccess:
|
||||
return CommitPolicy(val)
|
||||
default:
|
||||
if val == "" {
|
||||
return CommitPolicyUnspecified
|
||||
}
|
||||
return CommitPolicy(val)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStringList(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
clean := strings.TrimSpace(item)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, clean)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
69
api/payments/orchestrator/storage/model/plan_template.go
Normal file
69
api/payments/orchestrator/storage/model/plan_template.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
)
|
||||
|
||||
// OrchestrationStep defines a template step for execution planning.
|
||||
type OrchestrationStep struct {
|
||||
StepID string `bson:"stepId" json:"stepId"`
|
||||
Rail Rail `bson:"rail" json:"rail"`
|
||||
Operation string `bson:"operation" json:"operation"`
|
||||
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
|
||||
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
|
||||
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentPlanTemplate stores reusable orchestration templates.
|
||||
type PaymentPlanTemplate 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"`
|
||||
Steps []OrchestrationStep `bson:"steps,omitempty" json:"steps,omitempty"`
|
||||
IsEnabled bool `bson:"isEnabled" json:"isEnabled"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*PaymentPlanTemplate) Collection() string {
|
||||
return mservice.PaymentPlanTemplates
|
||||
}
|
||||
|
||||
// Normalize standardizes template fields for matching and indexing.
|
||||
func (t *PaymentPlanTemplate) Normalize() {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.FromRail))))
|
||||
t.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.ToRail))))
|
||||
t.Network = strings.ToUpper(strings.TrimSpace(t.Network))
|
||||
if len(t.Steps) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range t.Steps {
|
||||
step := &t.Steps[i]
|
||||
step.StepID = strings.TrimSpace(step.StepID)
|
||||
step.Rail = Rail(strings.ToUpper(strings.TrimSpace(string(step.Rail))))
|
||||
step.Operation = strings.ToLower(strings.TrimSpace(step.Operation))
|
||||
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
|
||||
step.DependsOn = normalizeStringList(step.DependsOn)
|
||||
step.CommitAfter = normalizeStringList(step.CommitAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// PaymentPlanTemplateFilter selects templates for lookup.
|
||||
type PaymentPlanTemplateFilter struct {
|
||||
FromRail Rail
|
||||
ToRail Rail
|
||||
Network string
|
||||
IsEnabled *bool
|
||||
}
|
||||
|
||||
// PaymentPlanTemplateList holds template results.
|
||||
type PaymentPlanTemplateList struct {
|
||||
Items []*PaymentPlanTemplate
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Store struct {
|
||||
payments storage.PaymentsStore
|
||||
quotes storage.QuotesStore
|
||||
routes storage.RoutesStore
|
||||
plans storage.PlanTemplatesStore
|
||||
}
|
||||
|
||||
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
||||
@@ -30,11 +31,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())
|
||||
routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection())
|
||||
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo)
|
||||
plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection())
|
||||
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo, plansRepo)
|
||||
}
|
||||
|
||||
// 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, routesRepo repository.Repository) (*Store, error) {
|
||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, plansRepo repository.Repository) (*Store, error) {
|
||||
if ping == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||
}
|
||||
@@ -47,6 +49,9 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error,
|
||||
if routesRepo == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil")
|
||||
}
|
||||
if plansRepo == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: plan templates repository is nil")
|
||||
}
|
||||
|
||||
childLogger := logger.Named("storage").Named("mongo")
|
||||
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
||||
@@ -61,12 +66,17 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plansStore, err := store.NewPlanTemplates(childLogger, plansRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Store{
|
||||
logger: childLogger,
|
||||
ping: ping,
|
||||
payments: paymentsStore,
|
||||
quotes: quotesStore,
|
||||
routes: routesStore,
|
||||
plans: plansStore,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -95,4 +105,9 @@ func (s *Store) Routes() storage.RoutesStore {
|
||||
return s.routes
|
||||
}
|
||||
|
||||
// PlanTemplates returns the plan templates store.
|
||||
func (s *Store) PlanTemplates() storage.PlanTemplatesStore {
|
||||
return s.plans
|
||||
}
|
||||
|
||||
var _ storage.Repository = (*Store)(nil)
|
||||
|
||||
168
api/payments/orchestrator/storage/mongo/store/plan_templates.go
Normal file
168
api/payments/orchestrator/storage/mongo/store/plan_templates.go
Normal file
@@ -0,0 +1,168 @@
|
||||
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 PlanTemplates struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
// NewPlanTemplates constructs a Mongo-backed plan template store.
|
||||
func NewPlanTemplates(logger mlogger.Logger, repo repository.Repository) (*PlanTemplates, error) {
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("planTemplatesStore: 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 plan templates index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &PlanTemplates{
|
||||
logger: logger.Named("plan_templates"),
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PlanTemplates) Create(ctx context.Context, template *model.PaymentPlanTemplate) error {
|
||||
if template == nil {
|
||||
return merrors.InvalidArgument("planTemplatesStore: nil template")
|
||||
}
|
||||
template.Normalize()
|
||||
if template.FromRail == "" || template.FromRail == model.RailUnspecified {
|
||||
return merrors.InvalidArgument("planTemplatesStore: from_rail is required")
|
||||
}
|
||||
if template.ToRail == "" || template.ToRail == model.RailUnspecified {
|
||||
return merrors.InvalidArgument("planTemplatesStore: to_rail is required")
|
||||
}
|
||||
if len(template.Steps) == 0 {
|
||||
return merrors.InvalidArgument("planTemplatesStore: steps are required")
|
||||
}
|
||||
if template.ID.IsZero() {
|
||||
template.SetID(primitive.NewObjectID())
|
||||
} else {
|
||||
template.Update()
|
||||
}
|
||||
|
||||
filter := repository.Filter("fromRail", template.FromRail).And(
|
||||
repository.Filter("toRail", template.ToRail),
|
||||
repository.Filter("network", template.Network),
|
||||
)
|
||||
|
||||
if err := p.repo.Insert(ctx, template, filter); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicatePlanTemplate
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PlanTemplates) Update(ctx context.Context, template *model.PaymentPlanTemplate) error {
|
||||
if template == nil {
|
||||
return merrors.InvalidArgument("planTemplatesStore: nil template")
|
||||
}
|
||||
if template.ID.IsZero() {
|
||||
return merrors.InvalidArgument("planTemplatesStore: missing template id")
|
||||
}
|
||||
template.Normalize()
|
||||
template.Update()
|
||||
if err := p.repo.Update(ctx, template); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return storage.ErrPlanTemplateNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PlanTemplates) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error) {
|
||||
if id == primitive.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("planTemplatesStore: template id is required")
|
||||
}
|
||||
entity := &model.PaymentPlanTemplate{}
|
||||
if err := p.repo.Get(ctx, id, entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrPlanTemplateNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) {
|
||||
if filter == nil {
|
||||
filter = &model.PaymentPlanTemplateFilter{}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
templates := make([]*model.PaymentPlanTemplate, 0)
|
||||
decoder := func(cur *mongo.Cursor) error {
|
||||
item := &model.PaymentPlanTemplate{}
|
||||
if err := cur.Decode(item); err != nil {
|
||||
return err
|
||||
}
|
||||
templates = append(templates, item)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.PaymentPlanTemplateList{
|
||||
Items: templates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ storage.PlanTemplatesStore = (*PlanTemplates)(nil)
|
||||
@@ -26,6 +26,10 @@ var (
|
||||
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")
|
||||
// ErrPlanTemplateNotFound signals that a plan template record does not exist.
|
||||
ErrPlanTemplateNotFound = storageError("payments.orchestrator.storage: plan template not found")
|
||||
// ErrDuplicatePlanTemplate signals that a plan template already exists for the same transition.
|
||||
ErrDuplicatePlanTemplate = storageError("payments.orchestrator.storage: duplicate plan template")
|
||||
)
|
||||
|
||||
// Repository exposes persistence primitives for the orchestrator domain.
|
||||
@@ -34,6 +38,7 @@ type Repository interface {
|
||||
Payments() PaymentsStore
|
||||
Quotes() QuotesStore
|
||||
Routes() RoutesStore
|
||||
PlanTemplates() PlanTemplatesStore
|
||||
}
|
||||
|
||||
// PaymentsStore manages payment lifecycle state.
|
||||
@@ -59,3 +64,11 @@ type RoutesStore interface {
|
||||
GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error)
|
||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
||||
}
|
||||
|
||||
// PlanTemplatesStore manages orchestration plan templates.
|
||||
type PlanTemplatesStore interface {
|
||||
Create(ctx context.Context, template *model.PaymentPlanTemplate) error
|
||||
Update(ctx context.Context, template *model.PaymentPlanTemplate) error
|
||||
GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error)
|
||||
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user