TG settlement service

This commit is contained in:
Stephan D
2026-01-02 14:54:18 +01:00
parent ea1c69f14a
commit 743f683d92
82 changed files with 4693 additions and 503 deletions

View File

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

View File

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

View File

@@ -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())

View File

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

View File

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

View File

@@ -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{

View File

@@ -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 {

View File

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

View File

@@ -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 {

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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{

View File

@@ -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 == "" {

View File

@@ -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 {

View File

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

View File

@@ -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 ""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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