Orchestration / payments v2 #554

Merged
tech merged 23 commits from pqpov2-547 into main 2026-02-26 22:45:55 +00:00
24 changed files with 321 additions and 3346 deletions
Showing only changes of commit 008427483c - Show all commits

View File

@@ -76,8 +76,4 @@ func (fakeStorageRepo) Routes() storage.RoutesStore {
return nil return nil
} }
func (fakeStorageRepo) PlanTemplates() storage.PlanTemplatesStore {
return nil
}
var _ storage.Repository = fakeStorageRepo{} var _ storage.Repository = fakeStorageRepo{}

View File

@@ -1,103 +0,0 @@
package plan_builder
import (
"context"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/mzap"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
)
type defaultPlanBuilder struct {
logger mlogger.Logger
}
// New constructs the default plan builder.
func New(logger mlogger.Logger) *defaultPlanBuilder {
return &defaultPlanBuilder{
logger: logger.Named("plan_builder"),
}
}
func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *sharedv1.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")
}
logger := b.logger.With(
zap.String("payment_ref", payment.PaymentRef),
zap.String("payment_kind", string(payment.Intent.Kind)),
)
logger.Debug("Building payment plan")
intent := payment.Intent
if intent.Kind == model.PaymentKindFXConversion {
logger.Debug("Building fx conversion plan")
plan, err := buildFXConversionPlan(payment)
if err != nil {
logger.Warn("Failed to build fx conversion plan", zap.Error(err))
return nil, err
}
logger.Info("Fx conversion plan built", zap.Int("steps", len(plan.Steps)))
return plan, nil
}
sourceRail, sourceNetwork, err := railFromEndpoint(intent.Source, intent.Attributes, true)
if err != nil {
logger.Warn("Failed to resolve source rail", zap.Error(err))
return nil, err
}
destRail, destNetwork, err := railFromEndpoint(intent.Destination, intent.Attributes, false)
if err != nil {
logger.Warn("Failed to resolve destination rail", zap.Error(err))
return nil, err
}
logger = logger.With(
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
zap.String("source_network", sourceNetwork),
zap.String("dest_network", destNetwork),
)
if sourceRail == model.RailUnspecified || destRail == model.RailUnspecified {
logger.Warn("Source and destination rails are required")
return nil, merrors.InvalidArgument("plan builder: source and destination rails are required")
}
if sourceRail == destRail && sourceRail != model.RailLedger {
logger.Warn("Unsupported same-rail payment")
return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment")
}
network, err := ResolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
if err != nil {
logger.Warn("Failed to resolve route network", zap.Error(err))
return nil, err
}
logger = logger.With(zap.String("network", network))
route, err := selectRoute(ctx, routes, sourceRail, destRail, network)
if err != nil {
logger.Warn("Failed to select route", zap.Error(err))
return nil, err
}
logger.Debug("Route selected", mzap.StorableRef(route))
template, err := SelectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
if err != nil {
logger.Warn("Failed to select plan template", zap.Error(err))
return nil, err
}
logger.Debug("Plan template selected", mzap.StorableRef(template))
return b.buildPlanFromTemplate(ctx, payment, quote, template, sourceRail, destRail, sourceNetwork, destNetwork, gateways)
}

View File

@@ -1,462 +0,0 @@
package plan_builder
import (
"context"
"strings"
"testing"
"github.com/tech/sendico/payments/storage/model"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model/account_role"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
)
func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
ctx := context.Background()
builder := New(mloggerfactory.NewLogger(false))
payment := &model.Payment{
PaymentRef: "pay-1",
IdempotencyKey: "idem-1",
Intent: model.PaymentIntent{
Ref: "ref-1",
Kind: model.PaymentKindPayout,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-1",
Asset: &paymenttypes.Asset{
Chain: "TRON_MAINNET",
TokenSymbol: "USDT",
},
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{MaskedPan: "4111"},
},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"},
SettlementCurrency: "USDT",
},
LastQuote: &model.PaymentQuoteSnapshot{
ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USDT", Amount: "95"},
ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "5"},
},
}
quote := &sharedv1.PaymentQuote{
ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "95"},
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"},
}
routes := &stubRouteStore{
routes: []*model.PaymentRoute{
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON_MAINNET", IsEnabled: true},
},
}
templates := &stubPlanTemplateStore{
templates: []*model.PaymentPlanTemplate{
{
FromRail: model.RailCrypto,
ToRail: model.RailCardPayout,
Network: "TRON_MAINNET",
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.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)},
{StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}},
{StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)},
},
},
},
}
registry := &stubGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-tron",
InstanceID: "crypto-tron-1",
Rail: model.RailCrypto,
Network: "TRON_MAINNET",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
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{
RequiresObserveConfirm: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
{
ID: "card",
InstanceID: "card-1",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
},
}
plan, err := builder.Build(ctx, payment, quote, routes, templates, registry)
if err != nil {
t.Fatalf("expected plan, got error: %v", err)
}
if plan == nil {
t.Fatal("expected plan")
}
if len(plan.Steps) != 6 {
t.Fatalf("expected 6 steps, got %d", len(plan.Steps))
}
assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-1", "USDT", "95")
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.RailOperationMove, "", "", "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.RailOperationMove, "", "", "USDT", "95")
}
func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
ctx := context.Background()
builder := New(mloggerfactory.NewLogger(false))
payment := &model.Payment{
PaymentRef: "pay-1",
IdempotencyKey: "idem-1",
Intent: model.PaymentIntent{
Ref: "ref-1",
Kind: model.PaymentKindPayout,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-1",
Asset: &paymenttypes.Asset{Chain: "TRON_MAINNET"},
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{MaskedPan: "4111"},
},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "10"},
},
}
routes := &stubRouteStore{}
templates := &stubPlanTemplateStore{}
registry := &stubGatewayRegistry{}
plan, err := builder.Build(ctx, payment, &sharedv1.PaymentQuote{}, routes, templates, registry)
if err == nil {
t.Fatalf("expected error, got plan: %#v", plan)
}
}
func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t *testing.T) {
ctx := context.Background()
builder := New(mloggerfactory.NewLogger(false))
payment := &model.Payment{
PaymentRef: "pay-settle-1",
IdempotencyKey: "idem-settle-1",
Intent: model.PaymentIntent{
Ref: "ref-1",
Kind: model.PaymentKindPayout,
SettlementMode: model.SettlementModeFixReceived,
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"},
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-1",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{MaskedPan: "4111"},
},
},
}
quote := &sharedv1.PaymentQuote{
DebitAmount: &moneyv1.Money{Currency: "USDT", Amount: "105"},
ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "100"},
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"},
}
template := &model.PaymentPlanTemplate{
Steps: []model.OrchestrationStep{
{StepID: "settle", Rail: model.RailProviderSettlement, Operation: "send"},
},
}
registry := &stubGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "settlement",
InstanceID: "settlement-1",
Rail: model.RailProviderSettlement,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
},
}
plan, err := builder.buildPlanFromTemplate(ctx, payment, quote, template, model.RailCrypto, model.RailProviderSettlement, "TRON_MAINNET", "", registry)
if err != nil {
t.Fatalf("expected plan, got error: %v", err)
}
if len(plan.Steps) != 1 {
t.Fatalf("expected 1 step, got %d", len(plan.Steps))
}
assertPlanStep(t, plan.Steps[0], "settle", model.RailProviderSettlement, model.RailOperationSend, "settlement", "settlement-1", "USDT", "95")
}
func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) {
ctx := context.Background()
builder := New(mloggerfactory.NewLogger(false))
payment := &model.Payment{
PaymentRef: "pay-2",
IdempotencyKey: "idem-2",
Intent: model.PaymentIntent{
Ref: "ref-1",
Kind: model.PaymentKindPayout,
RequiresFX: true,
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-2",
Asset: &paymenttypes.Asset{
Chain: "TRON_MAINNET",
TokenSymbol: "USDT",
},
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{MaskedPan: "4111"},
},
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1.4"},
SettlementMode: model.SettlementModeFixReceived,
SettlementCurrency: "RUB",
},
LastQuote: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Currency: "USDT", Amount: "1.498"},
ExpectedSettlementAmount: &paymenttypes.Money{Currency: "RUB", Amount: "108.99"},
ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "0.098"},
},
}
quote := &sharedv1.PaymentQuote{
DebitAmount: &moneyv1.Money{Currency: "USDT", Amount: "1.498"},
ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "108.99"},
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.098"},
}
routes := &stubRouteStore{
routes: []*model.PaymentRoute{
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON_MAINNET", IsEnabled: true},
},
}
templates := &stubPlanTemplateStore{
templates: []*model.PaymentPlanTemplate{
{
FromRail: model.RailCrypto,
ToRail: model.RailCardPayout,
Network: "TRON_MAINNET",
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.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)},
{StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}},
{StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)},
},
},
},
}
registry := &stubGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-tron",
InstanceID: "crypto-tron-2",
Rail: model.RailCrypto,
Network: "TRON_MAINNET",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
CanSendFee: true,
RequiresObserveConfirm: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
{
ID: "card",
InstanceID: "card-2",
Rail: model.RailCardPayout,
Currencies: []string{"RUB"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
IsEnabled: true,
},
},
}
plan, err := builder.Build(ctx, payment, quote, routes, templates, registry)
if err != nil {
t.Fatalf("expected plan, got error: %v", err)
}
if plan == nil {
t.Fatal("expected plan")
}
if len(plan.Steps) != 6 {
t.Fatalf("expected 6 steps, got %d", len(plan.Steps))
}
assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-2", "USDT", "1.4")
assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-2", "USDT", "0.098")
assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-2", "", "")
assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationMove, "", "", "RUB", "108.99")
assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-2", "RUB", "108.99")
assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationMove, "", "", "RUB", "108.99")
}
// --- test doubles ---
type stubRouteStore struct {
routes []*model.PaymentRoute
}
func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) {
items := make([]*model.PaymentRoute, 0, len(s.routes))
for _, route := range s.routes {
if route == nil {
continue
}
if filter != nil {
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
}
}
items = append(items, route)
}
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
}
func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) {
return s.items, nil
}
func rolePtr(role account_role.AccountRole) *account_role.AccountRole {
return &role
}
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)
}
if step.Action != action {
t.Fatalf("expected action %s, got %s", action, step.Action)
}
if step.GatewayID != gatewayID {
t.Fatalf("expected gateway %q, got %q", gatewayID, step.GatewayID)
}
if 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)
}
return
}
if step.Amount == nil {
t.Fatalf("expected amount %s %s, got nil", currency, amount)
}
if step.Amount.GetCurrency() != currency || step.Amount.GetAmount() != amount {
t.Fatalf("expected amount %s %s, got %s %s", currency, amount, step.Amount.GetCurrency(), step.Amount.GetAmount())
}
}

View File

@@ -1,101 +0,0 @@
package plan_builder
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
override := railOverrideFromAttributes(attrs, isSource)
if override != model.RailUnspecified {
return override, networkFromEndpoint(endpoint), nil
}
switch endpoint.Type {
case model.EndpointTypeLedger:
return model.RailLedger, "", nil
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
return model.RailCrypto, networkFromEndpoint(endpoint), nil
case model.EndpointTypeCard:
return model.RailCardPayout, "", nil
default:
return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint")
}
}
func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail {
if len(attrs) == 0 {
return model.RailUnspecified
}
keys := []string{"source_rail", "sourceRail"}
if !isSource {
keys = []string{"destination_rail", "destinationRail"}
}
lookup := map[string]struct{}{}
for _, key := range keys {
lookup[strings.ToLower(key)] = struct{}{}
}
for key, value := range attrs {
if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok {
continue
}
rail := parseRailValue(value)
if rail != model.RailUnspecified {
return rail
}
}
return model.RailUnspecified
}
func parseRailValue(value string) model.Rail {
val := strings.ToUpper(strings.TrimSpace(value))
switch val {
case string(model.RailCrypto):
return model.RailCrypto
case string(model.RailProviderSettlement):
return model.RailProviderSettlement
case string(model.RailLedger):
return model.RailLedger
case string(model.RailCardPayout):
return model.RailCardPayout
case string(model.RailFiatOnRamp):
return model.RailFiatOnRamp
default:
return model.RailUnspecified
}
}
func gatewayNetworkForRail(rail model.Rail, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string {
switch rail {
case model.RailCrypto:
if sourceRail == model.RailCrypto {
return strings.ToUpper(strings.TrimSpace(sourceNetwork))
}
if destRail == model.RailCrypto {
return strings.ToUpper(strings.TrimSpace(destNetwork))
}
case model.RailFiatOnRamp:
if sourceRail == model.RailFiatOnRamp {
return strings.ToUpper(strings.TrimSpace(sourceNetwork))
}
if destRail == model.RailFiatOnRamp {
return strings.ToUpper(strings.TrimSpace(destNetwork))
}
}
return ""
}
func networkFromEndpoint(endpoint model.PaymentEndpoint) string {
switch endpoint.Type {
case model.EndpointTypeManagedWallet:
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()))
}
case model.EndpointTypeExternalChain:
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain()))
}
}
return ""
}

View File

@@ -1,142 +0,0 @@
package plan_builder
import (
"context"
"sort"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
func ensureGatewayForAction(ctx context.Context, logger mlogger.Logger, 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 instanceID == "" || strings.EqualFold(gw.InstanceID, instanceID) {
if err := validateGatewayAction(gw, network, amount, action, dir); err != nil {
logger.Warn("Failed to validate gateway", zap.Error(err),
zap.String("instance_id", instanceID), zap.String("rail", string(rail)),
zap.String("network", network), zap.String("action", string(action)),
zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)),
)
return nil, err
}
return gw, nil
}
}
gw, err := selectGateway(ctx, registry, rail, network, amount, action, instanceID, dir)
if err != nil {
logger.Warn("Failed to select gateway", zap.Error(err),
zap.String("instance_id", instanceID), zap.String("rail", string(rail)),
zap.String("network", network), zap.String("action", string(action)),
zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)),
)
return nil, err
}
cache[rail] = gw
return gw, nil
}
func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) error {
if gw == nil {
return merrors.InvalidArgument("plan builder: gateway instance is required")
}
currency := ""
amt := decimal.Zero
if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" {
value, err := decimalFromMoney(amount)
if err != nil {
return err
}
amt = value
currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
}
if err := model.IsGatewayEligible(gw, gw.Rail, network, currency, action, toGatewayDirection(dir), amt); err != nil {
return merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir)))
}
return nil
}
type sendDirection int
const (
sendDirectionAny sendDirection = iota
sendDirectionOut
sendDirectionIn
)
func sendDirectionForRail(rail model.Rail) sendDirection {
switch rail {
case model.RailFiatOnRamp:
return sendDirectionIn
default:
return sendDirectionOut
}
}
func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) {
if registry == nil {
return nil, merrors.InvalidArgument("plan builder: gateway registry is required")
}
all, err := registry.List(ctx)
if err != nil {
return nil, err
}
if len(all) == 0 {
return nil, merrors.InvalidArgument("plan builder: no gateway instances available")
}
currency := ""
amt := decimal.Zero
if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" {
amt, err = decimalFromMoney(amount)
if err != nil {
return nil, err
}
currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
}
network = strings.ToUpper(strings.TrimSpace(network))
eligible := make([]*model.GatewayInstanceDescriptor, 0)
for _, gw := range all {
if err := model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(dir), amt); err != nil {
continue
}
eligible = append(eligible, gw)
}
if len(eligible) == 0 {
return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir)))
}
sort.Slice(eligible, func(i, j int) bool {
return eligible[i].ID < eligible[j].ID
})
if instanceID != "" {
for _, gw := range eligible {
if strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
return gw, nil
}
}
}
return eligible[0], nil
}
func sendDirectionLabel(dir sendDirection) string {
return toGatewayDirection(dir).String()
}
func toGatewayDirection(dir sendDirection) model.GatewayDirection {
switch dir {
case sendDirectionOut:
return model.GatewayDirectionOut
case sendDirectionIn:
return model.GatewayDirectionIn
default:
return model.GatewayDirectionAny
}
}

View File

@@ -1,463 +0,0 @@
package plan_builder
import (
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model/account_role"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
type moneyGetter interface {
GetAmount() string
GetCurrency() string
}
func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money {
if input == nil {
return nil
}
return &paymenttypes.Money{
Currency: input.GetCurrency(),
Amount: input.GetAmount(),
}
}
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 cloneMetadata(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
clone := make(map[string]string, len(input))
for k, v := range input {
clone[k] = v
}
return clone
}
func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
if role == nil {
return nil
}
cloned := *role
return &cloned
}
func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) {
if m == nil {
return decimal.Zero, nil
}
return decimal.NewFromString(m.GetAmount())
}
func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money {
if m == nil {
return nil
}
return &paymenttypes.Money{
Currency: m.GetCurrency(),
Amount: m.GetAmount(),
}
}
func protoMoney(m *paymenttypes.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{
Currency: m.GetCurrency(),
Amount: m.GetAmount(),
}
}
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
if input == nil {
return nil
}
return &moneyv1.Money{
Currency: input.GetCurrency(),
Amount: input.GetAmount(),
}
}
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
return &moneyv1.Money{
Currency: currency,
Amount: value.String(),
}
}
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
if m == nil || strings.TrimSpace(targetCurrency) == "" {
return nil, nil
}
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
return cloneProtoMoney(m), nil
}
return convertWithQuote(m, quote, targetCurrency)
}
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
return nil, nil
}
base := strings.TrimSpace(quote.GetPair().GetBase())
qt := strings.TrimSpace(quote.GetPair().GetQuote())
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
return nil, nil
}
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
if err != nil || price.IsZero() {
return nil, err
}
value, err := decimalFromMoney(m)
if err != nil {
return nil, err
}
switch {
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
return makeMoney(targetCurrency, value.Mul(price)), nil
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
return makeMoney(targetCurrency, value.Div(price)), nil
default:
return nil, nil
}
}
func attributeLookup(attrs map[string]string, keys ...string) string {
if len(keys) == 0 {
return ""
}
for _, key := range keys {
if key == "" || attrs == nil {
continue
}
if val := strings.TrimSpace(attrs[key]); val != "" {
return val
}
}
return ""
}
func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) {
if payment == nil {
return nil, merrors.InvalidArgument("payment is required")
}
amount := cloneMoney(payment.Intent.Amount)
if payment.LastQuote != nil {
settlement := payment.LastQuote.ExpectedSettlementAmount
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
amount = cloneMoney(settlement)
}
}
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("card payout: amount is required")
}
return amount, nil
}
func executionQuote(payment *model.Payment, quote *sharedv1.PaymentQuote) *sharedv1.PaymentQuote {
if quote != nil {
return quote
}
if payment != nil && payment.LastQuote != nil {
return modelQuoteToProto(payment.LastQuote)
}
return &sharedv1.PaymentQuote{}
}
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote {
if src == nil {
return nil
}
return &sharedv1.PaymentQuote{
DebitAmount: protoMoney(src.DebitAmount),
DebitSettlementAmount: protoMoney(src.DebitSettlementAmount),
ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount),
ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal),
FeeLines: feeLinesToProto(src.FeeLines),
FeeRules: feeRulesToProto(src.FeeRules),
FxQuote: fxQuoteToProto(src.FXQuote),
NetworkFee: networkFeeToProto(src.NetworkFee),
QuoteRef: strings.TrimSpace(src.QuoteRef),
}
}
func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse {
if resp == nil {
return nil
}
return &chainv1.EstimateTransferFeeResponse{
NetworkFee: protoMoney(resp.NetworkFee),
EstimationContext: strings.TrimSpace(resp.EstimationContext),
}
}
func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote {
if quote == nil {
return nil
}
pricedAtUnixMs := int64(0)
if ts := quote.GetPricedAt(); ts != nil {
pricedAtUnixMs = ts.AsTime().UnixMilli()
}
return &paymenttypes.FXQuote{
QuoteRef: strings.TrimSpace(quote.GetQuoteRef()),
Pair: pairFromProto(quote.GetPair()),
Side: fxSideFromProto(quote.GetSide()),
Price: decimalFromProto(quote.GetPrice()),
BaseAmount: moneyFromProto(quote.GetBaseAmount()),
QuoteAmount: moneyFromProto(quote.GetQuoteAmount()),
ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(),
PricedAtUnixMs: pricedAtUnixMs,
Provider: strings.TrimSpace(quote.GetProvider()),
RateRef: strings.TrimSpace(quote.GetRateRef()),
Firm: quote.GetFirm(),
}
}
func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote {
if quote == nil {
return nil
}
var pricedAt *timestamppb.Timestamp
if quote.PricedAtUnixMs > 0 {
pricedAt = timestamppb.New(time.UnixMilli(quote.PricedAtUnixMs).UTC())
}
return &oraclev1.Quote{
QuoteRef: strings.TrimSpace(quote.QuoteRef),
Pair: pairToProto(quote.Pair),
Side: fxSideToProto(quote.Side),
Price: decimalToProto(quote.Price),
BaseAmount: protoMoney(quote.BaseAmount),
QuoteAmount: protoMoney(quote.QuoteAmount),
ExpiresAtUnixMs: quote.ExpiresAtUnixMs,
PricedAt: pricedAt,
Provider: strings.TrimSpace(quote.Provider),
RateRef: strings.TrimSpace(quote.RateRef),
Firm: quote.Firm,
}
}
func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine {
if len(lines) == 0 {
return nil
}
result := make([]*paymenttypes.FeeLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
result = append(result, &paymenttypes.FeeLine{
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
Money: moneyFromProto(line.GetMoney()),
LineType: postingLineTypeFromProto(line.GetLineType()),
Side: entrySideFromProto(line.GetSide()),
Meta: cloneMetadata(line.GetMeta()),
})
}
if len(result) == 0 {
return nil
}
return result
}
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
if len(lines) == 0 {
return nil
}
result := make([]*feesv1.DerivedPostingLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
result = append(result, &feesv1.DerivedPostingLine{
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
Money: protoMoney(line.Money),
LineType: postingLineTypeToProto(line.LineType),
Side: entrySideToProto(line.Side),
Meta: cloneMetadata(line.Meta),
})
}
if len(result) == 0 {
return nil
}
return result
}
func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule {
if len(rules) == 0 {
return nil
}
result := make([]*feesv1.AppliedRule, 0, len(rules))
for _, rule := range rules {
if rule == nil {
continue
}
result = append(result, &feesv1.AppliedRule{
RuleId: strings.TrimSpace(rule.RuleID),
RuleVersion: strings.TrimSpace(rule.RuleVersion),
Formula: strings.TrimSpace(rule.Formula),
Rounding: roundingModeToProto(rule.Rounding),
TaxCode: strings.TrimSpace(rule.TaxCode),
TaxRate: strings.TrimSpace(rule.TaxRate),
Parameters: cloneMetadata(rule.Parameters),
})
}
if len(result) == 0 {
return nil
}
return result
}
func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair {
if pair == nil {
return nil
}
return &paymenttypes.CurrencyPair{
Base: pair.GetBase(),
Quote: pair.GetQuote(),
}
}
func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair {
if pair == nil {
return nil
}
return &fxv1.CurrencyPair{
Base: pair.GetBase(),
Quote: pair.GetQuote(),
}
}
func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide {
switch side {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
return paymenttypes.FXSideBuyBaseSellQuote
case fxv1.Side_SELL_BASE_BUY_QUOTE:
return paymenttypes.FXSideSellBaseBuyQuote
default:
return paymenttypes.FXSideUnspecified
}
}
func fxSideToProto(side paymenttypes.FXSide) fxv1.Side {
switch side {
case paymenttypes.FXSideBuyBaseSellQuote:
return fxv1.Side_BUY_BASE_SELL_QUOTE
case paymenttypes.FXSideSellBaseBuyQuote:
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal {
if value == nil {
return nil
}
return &paymenttypes.Decimal{Value: value.GetValue()}
}
func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal {
if value == nil {
return nil
}
return &moneyv1.Decimal{Value: value.GetValue()}
}
func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType {
switch lineType {
case accountingv1.PostingLineType_POSTING_LINE_FEE:
return paymenttypes.PostingLineTypeFee
case accountingv1.PostingLineType_POSTING_LINE_TAX:
return paymenttypes.PostingLineTypeTax
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
return paymenttypes.PostingLineTypeSpread
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
return paymenttypes.PostingLineTypeReversal
default:
return paymenttypes.PostingLineTypeUnspecified
}
}
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
switch lineType {
case paymenttypes.PostingLineTypeFee:
return accountingv1.PostingLineType_POSTING_LINE_FEE
case paymenttypes.PostingLineTypeTax:
return accountingv1.PostingLineType_POSTING_LINE_TAX
case paymenttypes.PostingLineTypeSpread:
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case paymenttypes.PostingLineTypeReversal:
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
}
}
func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide {
switch side {
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
return paymenttypes.EntrySideDebit
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
return paymenttypes.EntrySideCredit
default:
return paymenttypes.EntrySideUnspecified
}
}
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
switch side {
case paymenttypes.EntrySideDebit:
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case paymenttypes.EntrySideCredit:
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode {
switch mode {
case paymenttypes.RoundingModeHalfEven:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
case paymenttypes.RoundingModeHalfUp:
return moneyv1.RoundingMode_ROUND_HALF_UP
case paymenttypes.RoundingModeDown:
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
}
}

View File

@@ -1,22 +0,0 @@
package plan_builder
import (
"context"
"github.com/tech/sendico/payments/storage/model"
)
// RouteStore exposes routing definitions for plan construction.
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)
}

View File

@@ -1,78 +0,0 @@
package plan_builder
import (
"time"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
)
func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
if payment == nil {
return nil, merrors.InvalidArgument("plan builder: payment is required")
}
step := &model.PaymentStep{
StepID: "fx_convert",
Rail: model.RailLedger,
Action: model.RailOperationFXConvert,
ReportVisibility: model.ReportVisibilityUser,
CommitPolicy: model.CommitPolicyImmediate,
Amount: cloneMoney(payment.Intent.Amount),
}
return &model.PaymentPlan{
ID: payment.PaymentRef,
Steps: []*model.PaymentStep{step},
IdempotencyKey: payment.IdempotencyKey,
CreatedAt: planTimestamp(payment),
}, nil
}
func resolveSettlementAmount(payment *model.Payment, quote *sharedv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money {
if quote != nil && quote.GetExpectedSettlementAmount() != nil {
return moneyFromProto(quote.GetExpectedSettlementAmount())
}
if payment != nil && payment.LastQuote != nil && payment.LastQuote.ExpectedSettlementAmount != nil {
return cloneMoney(payment.LastQuote.ExpectedSettlementAmount)
}
return cloneMoney(fallback)
}
func resolveDebitAmount(payment *model.Payment, quote *sharedv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money {
if quote != nil && quote.GetDebitAmount() != nil {
return moneyFromProto(quote.GetDebitAmount())
}
if payment != nil && payment.LastQuote != nil && payment.LastQuote.DebitAmount != nil {
return cloneMoney(payment.LastQuote.DebitAmount)
}
return cloneMoney(fallback)
}
func resolveFeeAmount(payment *model.Payment, quote *sharedv1.PaymentQuote) *paymenttypes.Money {
if quote != nil && quote.GetExpectedFeeTotal() != nil {
return moneyFromProto(quote.GetExpectedFeeTotal())
}
if payment != nil && payment.LastQuote != nil {
return cloneMoney(payment.LastQuote.ExpectedFeeTotal)
}
return nil
}
func isPositiveMoney(amount *paymenttypes.Money) bool {
if amount == nil {
return false
}
val, err := decimalFromMoney(amount)
if err != nil {
return false
}
return val.IsPositive()
}
func planTimestamp(payment *model.Payment) time.Time {
if payment != nil && !payment.CreatedAt.IsZero() {
return payment.CreatedAt.UTC()
}
return time.Now().UTC()
}

View File

@@ -1,123 +0,0 @@
package plan_builder
import (
"context"
"sort"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
// ResolveRouteNetwork determines the network for route selection from source/destination
// networks and attribute overrides.
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{
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")
}
candidates := make([]*model.PaymentRoute, 0, len(result.Items))
for _, route := range result.Items {
if route == nil || !route.IsEnabled {
continue
}
if route.FromRail != sourceRail || route.ToRail != destRail {
continue
}
if !routeMatchesNetwork(route, network) {
continue
}
candidates = append(candidates, route)
}
if len(candidates) == 0 {
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
}
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 {
if route == nil {
return false
}
routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network))
net := strings.ToUpper(strings.TrimSpace(network))
if routeNetwork == "" {
return true
}
if net == "" {
return false
}
return strings.EqualFold(routeNetwork, net)
}
func routePriority(route *model.PaymentRoute, network string) int {
if route == nil {
return 2
}
routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network))
net := strings.ToUpper(strings.TrimSpace(network))
if net != "" && strings.EqualFold(routeNetwork, net) {
return 0
}
if routeNetwork == "" {
return 1
}
return 2
}

View File

@@ -1,446 +0,0 @@
package plan_builder
import (
"context"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mutil/mzap"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
)
func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *sharedv1.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")
}
logger := b.logger.With(
zap.String("payment_ref", payment.PaymentRef),
mzap.ObjRef("template_id", template.ID),
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
)
logger.Debug("Building plan from template", zap.Int("template_steps", len(template.Steps)))
intentAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount")
if err != nil {
logger.Warn("Invalid intent amount", zap.Error(err))
return nil, err
}
sourceAmount, err := requireMoney(resolveDebitAmount(payment, quote, intentAmount), "debit amount")
if err != nil {
logger.Warn("Failed to resolve debit amount", zap.Error(err))
return nil, err
}
settlementAmount, err := requireMoney(resolveSettlementAmount(payment, quote, sourceAmount), "settlement amount")
if err != nil {
logger.Warn("Failed to resolve settlement amount", zap.Error(err))
return nil, err
}
feeAmount := resolveFeeAmount(payment, quote)
feeRequired := isPositiveMoney(feeAmount)
sourceSendAmount, err := netSourceAmount(sourceAmount, feeAmount, quote)
if err != nil {
logger.Warn("Failed to calculate net source amount", zap.Error(err))
return nil, err
}
providerSettlementAmount := settlementAmount
if payment.Intent.SettlementMode == model.SettlementModeFixReceived && feeRequired {
providerSettlementAmount, err = netSettlementAmount(settlementAmount, feeAmount, quote)
if err != nil {
logger.Warn("Failed to calculate net settlement amount", zap.Error(err))
return nil, err
}
}
logger.Debug("Amounts calculated",
zap.String("intent_amount", moneyString(intentAmount)),
zap.String("source_amount", moneyString(sourceAmount)),
zap.String("settlement_amount", moneyString(settlementAmount)),
zap.String("fee_amount", moneyString(feeAmount)),
zap.Bool("fee_required", feeRequired),
)
payoutAmount := settlementAmount
if destRail == model.RailCardPayout {
payoutAmount, err = cardPayoutAmount(payment)
if err != nil {
logger.Warn("Failed to calculate card payout amount", zap.Error(err))
return nil, err
}
}
ledgerCreditAmount := settlementAmount
ledgerDebitAmount := settlementAmount
if destRail == model.RailCardPayout && payoutAmount != nil {
ledgerDebitAmount = payoutAmount
}
steps := make([]*model.PaymentStep, 0, len(template.Steps))
gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{}
stepIDs := map[string]bool{}
sourceManagedWalletNetwork := ""
destManagedWalletNetwork := ""
if payment.Intent.Source.Type == model.EndpointTypeManagedWallet {
sourceManagedWalletNetwork = networkFromEndpoint(payment.Intent.Source)
}
if payment.Intent.Destination.Type == model.EndpointTypeManagedWallet {
destManagedWalletNetwork = networkFromEndpoint(payment.Intent.Destination)
}
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 {
b.logger.Warn("Plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err))
return nil, err
}
amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired)
if err != nil {
return nil, err
}
if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement {
amount = cloneMoney(providerSettlementAmount)
}
if amount == nil &&
action != model.RailOperationObserveConfirm &&
action != model.RailOperationFee {
logger.Warn("Plan template step has no amount for action, skipping",
zap.String("step_id", stepID), zap.String("action", string(action)))
continue
}
policy := tpl.CommitPolicy
if strings.TrimSpace(string(policy)) == "" {
policy = model.CommitPolicyImmediate
}
step := &model.PaymentStep{
StepID: stepID,
Rail: tpl.Rail,
Action: action,
ReportVisibility: tpl.ReportVisibility,
DependsOn: cloneStringList(tpl.DependsOn),
CommitPolicy: policy,
CommitAfter: cloneStringList(tpl.CommitAfter),
Amount: cloneMoney(amount),
FromRole: cloneAccountRole(tpl.FromRole),
ToRole: cloneAccountRole(tpl.ToRole),
}
needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm
if (action == model.RailOperationBlock || action == model.RailOperationRelease) && tpl.Rail != model.RailLedger {
needsGateway = true
}
if needsGateway {
network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork)
managedWalletNetwork := ""
if tpl.Rail == sourceRail && sourceManagedWalletNetwork != "" {
managedWalletNetwork = sourceManagedWalletNetwork
} else if tpl.Rail == destRail && destManagedWalletNetwork != "" {
managedWalletNetwork = destManagedWalletNetwork
}
if managedWalletNetwork != "" {
logger.Debug("Managed wallet network resolved for gateway selection",
zap.String("step_id", stepID),
zap.String("rail", string(tpl.Rail)),
zap.String("managed_wallet_network", managedWalletNetwork),
zap.String("gateway_network", network),
)
}
instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail)
checkAmount := amount
if action == model.RailOperationObserveConfirm {
checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount)
if tpl.Rail == model.RailProviderSettlement {
checkAmount = cloneMoney(providerSettlementAmount)
}
}
gw, err := ensureGatewayForAction(ctx, b.logger, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail))
if err != nil {
logger.Warn("Failed to ensure gateway for plan step", zap.Error(err),
zap.String("step_id", stepID), zap.String("rail", string(tpl.Rail)),
zap.String("gateway_network", network), zap.String("managed_wallet_network", managedWalletNetwork),
zap.Int("gateways_by_rail_count", len(gatewaysByRail)),
)
return nil, err
}
step.GatewayID = strings.TrimSpace(gw.ID)
step.InstanceID = strings.TrimSpace(gw.InstanceID)
step.GatewayInvokeURI = strings.TrimSpace(gw.InvokeURI)
}
logger.Debug("Plan step added",
zap.String("step_id", step.StepID),
zap.String("rail", string(step.Rail)),
zap.String("action", string(step.Action)),
zap.String("commit_policy", string(step.CommitPolicy)),
zap.String("amount", moneyString(step.Amount)),
zap.Strings("depends_on", step.DependsOn),
)
steps = append(steps, step)
}
if len(steps) == 0 {
logger.Warn("Empty payment plan after processing template")
return nil, merrors.InvalidArgument("plan builder: empty payment plan")
}
execQuote := executionQuote(payment, quote)
plan := &model.PaymentPlan{
ID: payment.PaymentRef,
FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()),
Fees: feeLinesFromProto(execQuote.GetFeeLines()),
Steps: steps,
IdempotencyKey: payment.IdempotencyKey,
CreatedAt: planTimestamp(payment),
}
logger.Info("Payment plan built", zap.Int("steps", len(plan.Steps)),
zap.Int("fees", len(plan.Fees)), zap.Bool("has_fx_quote", plan.FXQuote != nil),
)
return plan, nil
}
func moneyString(m *paymenttypes.Money) string {
if m == nil {
return "nil"
}
return m.Amount + " " + m.Currency
}
func actionForOperation(operation string) (model.RailOperation, error) {
op := strings.ToLower(strings.TrimSpace(operation))
if op == "ledger.block" || op == "ledger.release" {
return model.RailOperationUnspecified, merrors.InvalidArgument("unsupported legacy ledger operation, use ledger.move with roles")
}
switch op {
case "ledger.move":
return model.RailOperationMove, nil
case "external.debit":
return model.RailOperationExternalDebit, nil
case "external.credit":
return model.RailOperationExternalCredit, nil
case "debit", "wallet.debit":
return model.RailOperationExternalDebit, nil
case "credit", "wallet.credit":
return model.RailOperationExternalCredit, 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
case "block", "hold", "reserve":
return model.RailOperationBlock, nil
case "release", "unblock":
return model.RailOperationRelease, nil
}
switch strings.ToUpper(strings.TrimSpace(operation)) {
case string(model.RailOperationExternalDebit), string(model.RailOperationDebit):
return model.RailOperationExternalDebit, nil
case string(model.RailOperationExternalCredit), string(model.RailOperationCredit):
return model.RailOperationExternalCredit, nil
case string(model.RailOperationMove):
return model.RailOperationMove, 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
case string(model.RailOperationBlock):
return model.RailOperationBlock, nil
case string(model.RailOperationRelease):
return model.RailOperationRelease, nil
}
return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation")
}
func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) {
switch action {
case model.RailOperationDebit, model.RailOperationExternalDebit:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationCredit, model.RailOperationExternalCredit:
if rail == model.RailLedger {
return cloneMoney(ledgerCreditAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationMove:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationSend:
switch rail {
case sourceRail:
return cloneMoney(sourceSendAmount), nil
case destRail:
return cloneMoney(payoutAmount), nil
default:
return cloneMoney(settlementAmount), nil
}
case model.RailOperationBlock, model.RailOperationRelease:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
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 nil, merrors.InvalidArgument("plan builder: unsupported action")
}
}
func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string {
if rail == sourceRail {
return strings.TrimSpace(intent.Source.InstanceID)
}
if rail == destRail {
return strings.TrimSpace(intent.Destination.InstanceID)
}
return ""
}
func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money {
switch rail {
case model.RailCrypto, model.RailFiatOnRamp:
if source != nil {
return source
}
if settlement != nil {
return settlement
}
case model.RailProviderSettlement:
if settlement != nil {
return settlement
}
case model.RailCardPayout:
if payout != nil {
return payout
}
}
if settlement != nil {
return settlement
}
return source
}
func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) {
if sourceAmount == nil {
return nil, merrors.InvalidArgument("plan builder: source amount is required")
}
netAmount := cloneMoney(sourceAmount)
if !isPositiveMoney(feeAmount) {
return netAmount, nil
}
currency := strings.TrimSpace(sourceAmount.GetCurrency())
if currency == "" {
return netAmount, nil
}
var fxQuote *oraclev1.Quote
if quote != nil {
fxQuote = quote.GetFxQuote()
}
convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote)
if err != nil {
return nil, err
}
if convertedFee == nil {
return netAmount, nil
}
sourceValue, err := decimalFromMoney(sourceAmount)
if err != nil {
return nil, err
}
feeValue, err := decimalFromMoney(convertedFee)
if err != nil {
return nil, err
}
netValue := sourceValue.Sub(feeValue)
if netValue.IsNegative() {
return nil, merrors.InvalidArgument("plan builder: fee exceeds source amount")
}
return &paymenttypes.Money{
Currency: currency,
Amount: netValue.String(),
}, nil
}
func netSettlementAmount(settlementAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) {
if settlementAmount == nil {
return nil, merrors.InvalidArgument("plan builder: settlement amount is required")
}
netAmount := cloneMoney(settlementAmount)
if !isPositiveMoney(feeAmount) {
return netAmount, nil
}
currency := strings.TrimSpace(settlementAmount.GetCurrency())
if currency == "" {
return netAmount, nil
}
var fxQuote *oraclev1.Quote
if quote != nil {
fxQuote = quote.GetFxQuote()
}
convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote)
if err != nil {
return nil, err
}
if convertedFee == nil {
return netAmount, nil
}
settlementValue, err := decimalFromMoney(settlementAmount)
if err != nil {
return nil, err
}
feeValue, err := decimalFromMoney(convertedFee)
if err != nil {
return nil, err
}
netValue := settlementValue.Sub(feeValue)
if netValue.IsNegative() {
return nil, merrors.InvalidArgument("plan builder: fee exceeds settlement amount")
}
return &paymenttypes.Money{
Currency: currency,
Amount: netValue.String(),
}, nil
}
func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) {
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("plan builder: " + label + " is required")
}
return amount, nil
}

View File

@@ -1,212 +0,0 @@
package plan_builder
import (
"context"
"sort"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.uber.org/zap"
)
// SelectPlanTemplate selects the best plan template matching the given rails and network.
func SelectPlanTemplate(ctx context.Context, logger mlogger.Logger, 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")
}
logger = logger.With(
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
zap.String("network", network),
)
logger.Debug("Selecting plan template")
enabled := true
result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{
FromRail: sourceRail,
ToRail: destRail,
IsEnabled: &enabled,
})
if err != nil {
logger.Warn("Failed to list plan templates", zap.Error(err))
return nil, err
}
if result == nil || len(result.Items) == 0 {
logger.Warn("No plan templates found for route")
return nil, merrors.InvalidArgument("plan builder: plan template missing")
}
logger.Debug("Fetched plan templates", zap.Int("total", len(result.Items)))
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) {
logger.Debug("Template network mismatch, skipping",
mzap.StorableRef(tpl),
zap.String("template_network", tpl.Network))
continue
}
if err := validatePlanTemplate(logger, tpl); err != nil {
return nil, err
}
candidates = append(candidates, tpl)
}
if len(candidates) == 0 {
logger.Warn("No valid plan template candidates after filtering")
return nil, merrors.InvalidArgument("plan builder: plan template missing")
}
logger.Debug("Plan template candidates filtered", zap.Int("candidates", len(candidates)))
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()
})
selected := candidates[0]
logger.Debug("Plan template selected",
mzap.StorableRef(selected),
zap.String("template_network", selected.Network),
zap.Int("steps", len(selected.Steps)),
zap.Int("priority", templatePriority(selected, network)))
return selected, 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(logger mlogger.Logger, template *model.PaymentPlanTemplate) error {
if template == nil {
return merrors.InvalidArgument("plan builder: plan template is required")
}
logger = logger.With(
mzap.StorableRef(template),
zap.String("from_rail", string(template.FromRail)),
zap.String("to_rail", string(template.ToRail)),
zap.String("network", template.Network),
)
logger.Debug("Validating plan template")
if len(template.Steps) == 0 {
logger.Warn("Plan template has no steps")
return merrors.InvalidArgument("plan builder: plan template steps are required")
}
seen := map[string]struct{}{}
for idx, step := range template.Steps {
id := strings.TrimSpace(step.StepID)
if id == "" {
logger.Warn("Plan template step missing ID", zap.Int("step_index", idx))
return merrors.InvalidArgument("plan builder: plan template step id is required")
}
if _, exists := seen[id]; exists {
logger.Warn("Duplicate plan template step ID", zap.String("step_id", id))
return merrors.InvalidArgument("plan builder: plan template step id must be unique")
}
seen[id] = struct{}{}
if strings.TrimSpace(step.Operation) == "" {
logger.Warn("Plan template step missing operation", zap.String("step_id", id),
zap.Int("step_index", idx))
return merrors.InvalidArgument("plan builder: plan template operation is required")
}
if !model.IsValidReportVisibility(step.ReportVisibility) {
logger.Warn("Plan template step has invalid report visibility",
zap.String("step_id", id),
zap.String("report_visibility", string(step.ReportVisibility)))
return merrors.InvalidArgument("plan builder: plan template report visibility is invalid")
}
action, err := actionForOperation(step.Operation)
if err != nil {
logger.Warn("Plan template step has invalid operation", zap.String("step_id", id),
zap.String("operation", step.Operation), zap.Error(err))
return err
}
if step.Rail == model.RailLedger && action == model.RailOperationMove {
if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" {
logger.Warn("Ledger move step missing fromRole", zap.String("step_id", id),
zap.String("operation", step.Operation))
return merrors.InvalidArgument("plan builder: ledger.move fromRole is required")
}
if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" {
logger.Warn("Ledger move step missing toRole", zap.String("step_id", id),
zap.String("operation", step.Operation))
return merrors.InvalidArgument("plan builder: ledger.move toRole is required")
}
from := strings.ToLower(strings.TrimSpace(string(*step.FromRole)))
to := strings.ToLower(strings.TrimSpace(string(*step.ToRole)))
if from == "" || to == "" || strings.EqualFold(from, to) {
logger.Warn("Ledger move step has invalid roles", zap.String("step_id", id),
zap.String("from_role", from), zap.String("to_role", to))
return merrors.InvalidArgument("plan builder: ledger.move fromRole and toRole must differ")
}
}
}
for _, step := range template.Steps {
for _, dep := range step.DependsOn {
depID := strings.TrimSpace(dep)
if _, ok := seen[depID]; !ok {
logger.Warn("Plan template step has missing dependency", zap.String("step_id", step.StepID),
zap.String("missing_dependency", depID))
return merrors.InvalidArgument("plan builder: plan template dependency missing")
}
}
for _, dep := range step.CommitAfter {
depID := strings.TrimSpace(dep)
if _, ok := seen[depID]; !ok {
logger.Warn("Plan template step has missing commit dependency", zap.String("step_id", step.StepID),
zap.String("missing_commit_dependency", depID))
return merrors.InvalidArgument("plan builder: plan template commit dependency missing")
}
}
}
logger.Debug("Plan template validation successful", zap.Int("steps", len(template.Steps)))
return nil
}

View File

@@ -32,7 +32,7 @@ Create initial aggregate: state=CREATED, version=1, immutable snapshots, and ini
execution_plan_compiler_v2 execution_plan_compiler_v2
Compile runtime step graph from quote route + execution conditions + intent. Compile runtime step graph from quote route + execution conditions + intent.
Important: do not reuse old route/template lookup path as primary (plan_builder route/template selection) because v2 quote already selected route. Important: do not reuse old route lookup path as primary because v2 quote already selected route.
orchestration_state_machine orchestration_state_machine
Single source of truth for aggregate transitions (CREATED/EXECUTING/NEEDS_ATTENTION/SETTLED/FAILED) and step transitions (PENDING/RUNNING/COMPLETED/...). Single source of truth for aggregate transitions (CREATED/EXECUTING/NEEDS_ATTENTION/SETTLED/FAILED) and step transitions (PENDING/RUNNING/COMPLETED/...).

View File

@@ -5,8 +5,6 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
) )
// RouteStore exposes routing definitions for plan construction. // RouteStore exposes routing definitions for plan construction.
@@ -14,21 +12,11 @@ type RouteStore interface {
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
} }
// PlanTemplateStore exposes 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. // GatewayRegistry exposes gateway instances for capability-based selection.
type GatewayRegistry interface { type GatewayRegistry interface {
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
} }
// Builder constructs ordered payment plans from intents, quotes, and routing policy.
type Builder interface {
Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
}
type SendDirection = sendDirection type SendDirection = sendDirection
const ( const (
@@ -37,10 +25,6 @@ const (
SendDirectionIn SendDirection = sendDirectionIn SendDirectionIn SendDirection = sendDirectionIn
) )
func NewDefaultBuilder(logger mlogger.Logger) Builder {
return newDefaultBuilder(logger)
}
func RailFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { func RailFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
return railFromEndpoint(endpoint, attrs, isSource) return railFromEndpoint(endpoint, attrs, isSource)
} }
@@ -49,10 +33,6 @@ func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork str
return resolveRouteNetwork(attrs, sourceNetwork, destNetwork) return resolveRouteNetwork(attrs, sourceNetwork, destNetwork)
} }
func SelectTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
return selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
}
func SendDirectionForRail(rail model.Rail) SendDirection { func SendDirectionForRail(rail model.Rail) SendDirection {
return sendDirectionForRail(rail) return sendDirectionForRail(rail)
} }

View File

@@ -1,102 +0,0 @@
package plan
import (
"context"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/mzap"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
)
type defaultBuilder struct {
logger mlogger.Logger
}
func newDefaultBuilder(logger mlogger.Logger) *defaultBuilder {
return &defaultBuilder{
logger: logger.Named("plan_builder"),
}
}
func (b *defaultBuilder) Build(ctx context.Context, payment *model.Payment, quote *sharedv1.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")
}
logger := b.logger.With(
zap.String("payment_ref", payment.PaymentRef),
zap.String("payment_kind", string(payment.Intent.Kind)),
)
logger.Debug("Building payment plan")
intent := payment.Intent
if intent.Kind == model.PaymentKindFXConversion {
logger.Debug("Building fx conversion plan")
plan, err := buildFXConversionPlan(payment)
if err != nil {
logger.Warn("Failed to build fx conversion plan", zap.Error(err))
return nil, err
}
logger.Info("Fx conversion plan built", zap.Int("steps", len(plan.Steps)))
return plan, nil
}
sourceRail, sourceNetwork, err := railFromEndpoint(intent.Source, intent.Attributes, true)
if err != nil {
logger.Warn("Failed to resolve source rail", zap.Error(err))
return nil, err
}
destRail, destNetwork, err := railFromEndpoint(intent.Destination, intent.Attributes, false)
if err != nil {
logger.Warn("Failed to resolve destination rail", zap.Error(err))
return nil, err
}
logger = logger.With(
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
zap.String("source_network", sourceNetwork),
zap.String("dest_network", destNetwork),
)
if sourceRail == model.RailUnspecified || destRail == model.RailUnspecified {
logger.Warn("Source and destination rails are required")
return nil, merrors.InvalidArgument("plan builder: source and destination rails are required")
}
if sourceRail == destRail && sourceRail != model.RailLedger {
logger.Warn("Unsupported same-rail payment")
return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment")
}
network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
if err != nil {
logger.Warn("Failed to resolve route network", zap.Error(err))
return nil, err
}
logger = logger.With(zap.String("network", network))
route, err := selectRoute(ctx, routes, sourceRail, destRail, network)
if err != nil {
logger.Warn("Failed to select route", zap.Error(err))
return nil, err
}
logger.Debug("Route selected", mzap.StorableRef(route))
template, err := selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
if err != nil {
logger.Warn("Failed to select plan template", zap.Error(err))
return nil, err
}
logger.Debug("Plan template selected", mzap.StorableRef(template))
return b.buildPlanFromTemplate(ctx, payment, quote, template, sourceRail, destRail, sourceNetwork, destNetwork, gateways)
}

View File

@@ -1,447 +0,0 @@
package plan
import (
"context"
"strings"
"github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mutil/mzap"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
)
func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *sharedv1.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")
}
logger := b.logger.With(
zap.String("payment_ref", payment.PaymentRef),
mzap.ObjRef("template_id", template.ID),
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
)
logger.Debug("Building plan from template", zap.Int("template_steps", len(template.Steps)))
intentAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount")
if err != nil {
logger.Warn("Invalid intent amount", zap.Error(err))
return nil, err
}
sourceAmount, err := requireMoney(resolveDebitAmount(payment, quote, intentAmount), "debit amount")
if err != nil {
logger.Warn("Failed to resolve debit amount", zap.Error(err))
return nil, err
}
settlementAmount, err := requireMoney(resolveSettlementAmount(payment, quote, sourceAmount), "settlement amount")
if err != nil {
logger.Warn("Failed to resolve settlement amount", zap.Error(err))
return nil, err
}
feeAmount := resolveFeeAmount(payment, quote)
feeRequired := isPositiveMoney(feeAmount)
sourceSendAmount, err := netSourceAmount(sourceAmount, feeAmount, quote)
if err != nil {
logger.Warn("Failed to calculate net source amount", zap.Error(err))
return nil, err
}
providerSettlementAmount := settlementAmount
if payment.Intent.SettlementMode == model.SettlementModeFixReceived && feeRequired {
providerSettlementAmount, err = netSettlementAmount(settlementAmount, feeAmount, quote)
if err != nil {
logger.Warn("Failed to calculate net settlement amount", zap.Error(err))
return nil, err
}
}
logger.Debug("Amounts calculated",
zap.String("intent_amount", moneyString(intentAmount)),
zap.String("source_amount", moneyString(sourceAmount)),
zap.String("settlement_amount", moneyString(settlementAmount)),
zap.String("fee_amount", moneyString(feeAmount)),
zap.Bool("fee_required", feeRequired),
)
payoutAmount := settlementAmount
if destRail == model.RailCardPayout {
payoutAmount, err = cardPayoutAmount(payment)
if err != nil {
logger.Warn("Failed to calculate card payout amount", zap.Error(err))
return nil, err
}
}
ledgerCreditAmount := settlementAmount
ledgerDebitAmount := settlementAmount
if destRail == model.RailCardPayout && payoutAmount != nil {
ledgerDebitAmount = payoutAmount
}
steps := make([]*model.PaymentStep, 0, len(template.Steps))
gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{}
stepIDs := map[string]bool{}
sourceManagedWalletNetwork := ""
destManagedWalletNetwork := ""
if payment.Intent.Source.Type == model.EndpointTypeManagedWallet {
sourceManagedWalletNetwork = networkFromEndpoint(payment.Intent.Source)
}
if payment.Intent.Destination.Type == model.EndpointTypeManagedWallet {
destManagedWalletNetwork = networkFromEndpoint(payment.Intent.Destination)
}
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 {
b.logger.Warn("Plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err))
return nil, err
}
amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired)
if err != nil {
return nil, err
}
if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement {
amount = cloneMoney(providerSettlementAmount)
}
if amount == nil &&
action != model.RailOperationObserveConfirm &&
action != model.RailOperationFee {
logger.Warn("Plan template step has no amount for action, skipping",
zap.String("step_id", stepID), zap.String("action", string(action)))
continue
}
policy := tpl.CommitPolicy
if strings.TrimSpace(string(policy)) == "" {
policy = model.CommitPolicyImmediate
}
step := &model.PaymentStep{
StepID: stepID,
Rail: tpl.Rail,
Action: action,
ReportVisibility: tpl.ReportVisibility,
DependsOn: cloneStringList(tpl.DependsOn),
CommitPolicy: policy,
CommitAfter: cloneStringList(tpl.CommitAfter),
Amount: cloneMoney(amount),
FromRole: shared.CloneAccountRole(tpl.FromRole),
ToRole: shared.CloneAccountRole(tpl.ToRole),
}
needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm
if (action == model.RailOperationBlock || action == model.RailOperationRelease) && tpl.Rail != model.RailLedger {
needsGateway = true
}
if needsGateway {
network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork)
managedWalletNetwork := ""
if tpl.Rail == sourceRail && sourceManagedWalletNetwork != "" {
managedWalletNetwork = sourceManagedWalletNetwork
} else if tpl.Rail == destRail && destManagedWalletNetwork != "" {
managedWalletNetwork = destManagedWalletNetwork
}
if managedWalletNetwork != "" {
logger.Debug("Managed wallet network resolved for gateway selection",
zap.String("step_id", stepID),
zap.String("rail", string(tpl.Rail)),
zap.String("managed_wallet_network", managedWalletNetwork),
zap.String("gateway_network", network),
)
}
instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail)
checkAmount := amount
if action == model.RailOperationObserveConfirm {
checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount)
if tpl.Rail == model.RailProviderSettlement {
checkAmount = cloneMoney(providerSettlementAmount)
}
}
gw, err := ensureGatewayForAction(ctx, b.logger, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail))
if err != nil {
logger.Warn("Failed to ensure gateway for plan step", zap.Error(err),
zap.String("step_id", stepID), zap.String("rail", string(tpl.Rail)),
zap.String("gateway_network", network), zap.String("managed_wallet_network", managedWalletNetwork),
zap.Int("gateways_by_rail_count", len(gatewaysByRail)),
)
return nil, err
}
step.GatewayID = strings.TrimSpace(gw.ID)
step.InstanceID = strings.TrimSpace(gw.InstanceID)
step.GatewayInvokeURI = strings.TrimSpace(gw.InvokeURI)
}
logger.Debug("Plan step added",
zap.String("step_id", step.StepID),
zap.String("rail", string(step.Rail)),
zap.String("action", string(step.Action)),
zap.String("commit_policy", string(step.CommitPolicy)),
zap.String("amount", moneyString(step.Amount)),
zap.Strings("depends_on", step.DependsOn),
)
steps = append(steps, step)
}
if len(steps) == 0 {
logger.Warn("Empty payment plan after processing template")
return nil, merrors.InvalidArgument("plan builder: empty payment plan")
}
execQuote := executionQuote(payment, quote)
plan := &model.PaymentPlan{
ID: payment.PaymentRef,
FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()),
Fees: feeLinesFromProto(execQuote.GetFeeLines()),
Steps: steps,
IdempotencyKey: payment.IdempotencyKey,
CreatedAt: planTimestamp(payment),
}
logger.Info("Payment plan built", zap.Int("steps", len(plan.Steps)),
zap.Int("fees", len(plan.Fees)), zap.Bool("has_fx_quote", plan.FXQuote != nil),
)
return plan, nil
}
func moneyString(m *paymenttypes.Money) string {
if m == nil {
return "nil"
}
return m.Amount + " " + m.Currency
}
func actionForOperation(operation string) (model.RailOperation, error) {
op := strings.ToLower(strings.TrimSpace(operation))
if op == "ledger.block" || op == "ledger.release" {
return model.RailOperationUnspecified, merrors.InvalidArgument("unsupported legacy ledger operation, use ledger.move with roles")
}
switch op {
case "ledger.move":
return model.RailOperationMove, nil
case "external.debit":
return model.RailOperationExternalDebit, nil
case "external.credit":
return model.RailOperationExternalCredit, nil
case "debit", "wallet.debit":
return model.RailOperationExternalDebit, nil
case "credit", "wallet.credit":
return model.RailOperationExternalCredit, 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
case "block", "hold", "reserve":
return model.RailOperationBlock, nil
case "release", "unblock":
return model.RailOperationRelease, nil
}
switch strings.ToUpper(strings.TrimSpace(operation)) {
case string(model.RailOperationExternalDebit), string(model.RailOperationDebit):
return model.RailOperationExternalDebit, nil
case string(model.RailOperationExternalCredit), string(model.RailOperationCredit):
return model.RailOperationExternalCredit, nil
case string(model.RailOperationMove):
return model.RailOperationMove, 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
case string(model.RailOperationBlock):
return model.RailOperationBlock, nil
case string(model.RailOperationRelease):
return model.RailOperationRelease, nil
}
return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation")
}
func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) {
switch action {
case model.RailOperationDebit, model.RailOperationExternalDebit:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationCredit, model.RailOperationExternalCredit:
if rail == model.RailLedger {
return cloneMoney(ledgerCreditAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationMove:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
return cloneMoney(settlementAmount), nil
case model.RailOperationSend:
switch rail {
case sourceRail:
return cloneMoney(sourceSendAmount), nil
case destRail:
return cloneMoney(payoutAmount), nil
default:
return cloneMoney(settlementAmount), nil
}
case model.RailOperationBlock, model.RailOperationRelease:
if rail == model.RailLedger {
return cloneMoney(ledgerDebitAmount), nil
}
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 nil, merrors.InvalidArgument("plan builder: unsupported action")
}
}
func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string {
if rail == sourceRail {
return strings.TrimSpace(intent.Source.InstanceID)
}
if rail == destRail {
return strings.TrimSpace(intent.Destination.InstanceID)
}
return ""
}
func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money {
switch rail {
case model.RailCrypto, model.RailFiatOnRamp:
if source != nil {
return source
}
if settlement != nil {
return settlement
}
case model.RailProviderSettlement:
if settlement != nil {
return settlement
}
case model.RailCardPayout:
if payout != nil {
return payout
}
}
if settlement != nil {
return settlement
}
return source
}
func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) {
if sourceAmount == nil {
return nil, merrors.InvalidArgument("plan builder: source amount is required")
}
netAmount := cloneMoney(sourceAmount)
if !isPositiveMoney(feeAmount) {
return netAmount, nil
}
currency := strings.TrimSpace(sourceAmount.GetCurrency())
if currency == "" {
return netAmount, nil
}
var fxQuote *oraclev1.Quote
if quote != nil {
fxQuote = quote.GetFxQuote()
}
convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote)
if err != nil {
return nil, err
}
if convertedFee == nil {
return netAmount, nil
}
sourceValue, err := decimalFromMoney(sourceAmount)
if err != nil {
return nil, err
}
feeValue, err := decimalFromMoney(convertedFee)
if err != nil {
return nil, err
}
netValue := sourceValue.Sub(feeValue)
if netValue.IsNegative() {
return nil, merrors.InvalidArgument("plan builder: fee exceeds source amount")
}
return &paymenttypes.Money{
Currency: currency,
Amount: netValue.String(),
}, nil
}
func netSettlementAmount(settlementAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) {
if settlementAmount == nil {
return nil, merrors.InvalidArgument("plan builder: settlement amount is required")
}
netAmount := cloneMoney(settlementAmount)
if !isPositiveMoney(feeAmount) {
return netAmount, nil
}
currency := strings.TrimSpace(settlementAmount.GetCurrency())
if currency == "" {
return netAmount, nil
}
var fxQuote *oraclev1.Quote
if quote != nil {
fxQuote = quote.GetFxQuote()
}
convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote)
if err != nil {
return nil, err
}
if convertedFee == nil {
return netAmount, nil
}
settlementValue, err := decimalFromMoney(settlementAmount)
if err != nil {
return nil, err
}
feeValue, err := decimalFromMoney(convertedFee)
if err != nil {
return nil, err
}
netValue := settlementValue.Sub(feeValue)
if netValue.IsNegative() {
return nil, merrors.InvalidArgument("plan builder: fee exceeds settlement amount")
}
return &paymenttypes.Money{
Currency: currency,
Amount: netValue.String(),
}, nil
}
func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) {
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("plan builder: " + label + " is required")
}
return amount, nil
}

View File

@@ -1,211 +0,0 @@
package plan
import (
"context"
"sort"
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.uber.org/zap"
)
func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, 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")
}
logger = logger.With(
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
zap.String("network", network),
)
logger.Debug("Selecting plan template")
enabled := true
result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{
FromRail: sourceRail,
ToRail: destRail,
IsEnabled: &enabled,
})
if err != nil {
logger.Warn("Failed to list plan templates", zap.Error(err))
return nil, err
}
if result == nil || len(result.Items) == 0 {
logger.Warn("No plan templates found for route")
return nil, merrors.InvalidArgument("plan builder: plan template missing")
}
logger.Debug("Fetched plan templates", zap.Int("total", len(result.Items)))
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) {
logger.Debug("Template network mismatch, skipping",
mzap.StorableRef(tpl),
zap.String("template_network", tpl.Network))
continue
}
if err := validatePlanTemplate(logger, tpl); err != nil {
return nil, err
}
candidates = append(candidates, tpl)
}
if len(candidates) == 0 {
logger.Warn("No valid plan template candidates after filtering")
return nil, merrors.InvalidArgument("plan builder: plan template missing")
}
logger.Debug("Plan template candidates filtered", zap.Int("candidates", len(candidates)))
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()
})
selected := candidates[0]
logger.Debug("Plan template selected",
mzap.StorableRef(selected),
zap.String("template_network", selected.Network),
zap.Int("steps", len(selected.Steps)),
zap.Int("priority", templatePriority(selected, network)))
return selected, 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(logger mlogger.Logger, template *model.PaymentPlanTemplate) error {
if template == nil {
return merrors.InvalidArgument("plan builder: plan template is required")
}
logger = logger.With(
mzap.StorableRef(template),
zap.String("from_rail", string(template.FromRail)),
zap.String("to_rail", string(template.ToRail)),
zap.String("network", template.Network),
)
logger.Debug("Validating plan template")
if len(template.Steps) == 0 {
logger.Warn("Plan template has no steps")
return merrors.InvalidArgument("plan builder: plan template steps are required")
}
seen := map[string]struct{}{}
for idx, step := range template.Steps {
id := strings.TrimSpace(step.StepID)
if id == "" {
logger.Warn("Plan template step missing ID", zap.Int("step_index", idx))
return merrors.InvalidArgument("plan builder: plan template step id is required")
}
if _, exists := seen[id]; exists {
logger.Warn("Duplicate plan template step ID", zap.String("step_id", id))
return merrors.InvalidArgument("plan builder: plan template step id must be unique")
}
seen[id] = struct{}{}
if strings.TrimSpace(step.Operation) == "" {
logger.Warn("Plan template step missing operation", zap.String("step_id", id),
zap.Int("step_index", idx))
return merrors.InvalidArgument("plan builder: plan template operation is required")
}
if !model.IsValidReportVisibility(step.ReportVisibility) {
logger.Warn("Plan template step has invalid report visibility",
zap.String("step_id", id),
zap.String("report_visibility", string(step.ReportVisibility)))
return merrors.InvalidArgument("plan builder: plan template report visibility is invalid")
}
action, err := actionForOperation(step.Operation)
if err != nil {
logger.Warn("Plan template step has invalid operation", zap.String("step_id", id),
zap.String("operation", step.Operation), zap.Error(err))
return err
}
if step.Rail == model.RailLedger && action == model.RailOperationMove {
if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" {
logger.Warn("Ledger move step missing fromRole", zap.String("step_id", id),
zap.String("operation", step.Operation))
return merrors.InvalidArgument("plan builder: ledger.move fromRole is required")
}
if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" {
logger.Warn("Ledger move step missing toRole", zap.String("step_id", id),
zap.String("operation", step.Operation))
return merrors.InvalidArgument("plan builder: ledger.move toRole is required")
}
from := strings.ToLower(strings.TrimSpace(string(*step.FromRole)))
to := strings.ToLower(strings.TrimSpace(string(*step.ToRole)))
if from == "" || to == "" || strings.EqualFold(from, to) {
logger.Warn("Ledger move step has invalid roles", zap.String("step_id", id),
zap.String("from_role", from), zap.String("to_role", to))
return merrors.InvalidArgument("plan builder: ledger.move fromRole and toRole must differ")
}
}
}
for _, step := range template.Steps {
for _, dep := range step.DependsOn {
depID := strings.TrimSpace(dep)
if _, ok := seen[depID]; !ok {
logger.Warn("Plan template step has missing dependency", zap.String("step_id", step.StepID),
zap.String("missing_dependency", depID))
return merrors.InvalidArgument("plan builder: plan template dependency missing")
}
}
for _, dep := range step.CommitAfter {
depID := strings.TrimSpace(dep)
if _, ok := seen[depID]; !ok {
logger.Warn("Plan template step has missing commit dependency", zap.String("step_id", step.StepID),
zap.String("missing_commit_dependency", depID))
return merrors.InvalidArgument("plan builder: plan template commit dependency missing")
}
}
}
logger.Debug("Plan template validation successful", zap.Int("steps", len(template.Steps)))
return nil
}

View File

@@ -1,11 +1,8 @@
package quotation package quotation
import ( import (
"context"
"github.com/tech/sendico/payments/quotation/internal/service/plan" "github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
) )
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
@@ -15,14 +12,3 @@ func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, i
func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork) return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork)
} }
func selectPlanTemplate(
ctx context.Context,
logger mlogger.Logger,
templates plan.PlanTemplateStore,
sourceRail model.Rail,
destRail model.Rail,
network string,
) (*model.PaymentPlanTemplate, error) {
return plan.SelectTemplate(ctx, logger, templates, sourceRail, destRail, network)
}

View File

@@ -91,7 +91,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo
} }
} }
conversionFeeQuote := &feesv1.PrecomputeFeesResponse{} conversionFeeQuote := &feesv1.PrecomputeFeesResponse{}
if s.shouldQuoteConversionFee(ctx, req.GetIntent()) { if s.shouldQuoteConversionFee(req.GetIntent()) {
conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount) conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount)
if err != nil { if err != nil {
return nil, time.Time{}, err return nil, time.Time{}, err
@@ -230,7 +230,7 @@ func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *q
return resp, nil return resp, nil
} }
func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1.PaymentIntent) bool { func (s *Service) shouldQuoteConversionFee(intent *sharedv1.PaymentIntent) bool {
if intent == nil { if intent == nil {
return false return false
} }
@@ -240,48 +240,7 @@ func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1
if isLedgerEndpoint(intent.GetDestination()) { if isLedgerEndpoint(intent.GetDestination()) {
return false return false
} }
if s.storage == nil {
return false
}
templates := s.storage.PlanTemplates()
if templates == nil {
return false
}
intentModel := intentFromProto(intent)
sourceRail, sourceNetwork, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true)
if err != nil {
return false
}
destRail, destNetwork, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false)
if err != nil {
return false
}
network, err := resolveRouteNetwork(intentModel.Attributes, sourceNetwork, destNetwork)
if err != nil {
return false
}
template, err := selectPlanTemplate(ctx, s.logger.Named("quote_payment"), templates, sourceRail, destRail, network)
if err != nil {
return false
}
return templateHasLedgerMove(template)
}
func templateHasLedgerMove(template *model.PaymentPlanTemplate) bool {
if template == nil {
return false
}
for _, step := range template.Steps {
if step.Rail != model.RailLedger {
continue
}
if strings.EqualFold(strings.TrimSpace(step.Operation), "ledger.move") {
return true return true
}
}
return false
} }
func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule { func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule {

View File

@@ -0,0 +1,264 @@
package quotation
import (
"context"
"strings"
"testing"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
func TestBuildPaymentQuote_RequestsConversionFeesWithoutPlanTemplates(t *testing.T) {
feeClient := &stubFeeEngineClient{
precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{
"payments.orchestrator.quote": {
Lines: []*feesv1.DerivedPostingLine{
testFeeLine("1.00", "USDT"),
},
Applied: []*feesv1.AppliedRule{{RuleId: "rule.base"}},
},
"payments.orchestrator.conversion_quote": {
Lines: []*feesv1.DerivedPostingLine{
testFeeLine("2.00", "USDT"),
},
Applied: []*feesv1.AppliedRule{{RuleId: "rule.conversion"}},
},
},
}
svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0))
req := &quoteRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"},
IdempotencyKey: "idem_1",
Intent: testManagedWalletToCardIntent(),
}
quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req)
if err != nil {
t.Fatalf("buildPaymentQuote returned error: %v", err)
}
if quote == nil {
t.Fatalf("expected quote")
}
if got, want := len(feeClient.precomputeReqs), 2; got != want {
t.Fatalf("unexpected precompute call count: got=%d want=%d", got, want)
}
if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.quote"), 1; got != want {
t.Fatalf("unexpected base precompute calls: got=%d want=%d", got, want)
}
if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 1; got != want {
t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want)
}
if got, want := len(quote.GetFeeLines()), 2; got != want {
t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want)
}
if quote.GetExpectedFeeTotal() == nil {
t.Fatalf("expected fee total")
}
if got, want := quote.GetExpectedFeeTotal().GetCurrency(), "USDT"; got != want {
t.Fatalf("unexpected fee total currency: got=%q want=%q", got, want)
}
if got, want := quote.GetExpectedFeeTotal().GetAmount(), "3"; got != want {
t.Fatalf("unexpected fee total amount: got=%q want=%q", got, want)
}
if got := quote.GetFeeLines()[1].GetMeta()[feeLineMetaTarget]; got != feeLineTargetWallet {
t.Fatalf("expected conversion line target %q, got %q", feeLineTargetWallet, got)
}
if got, want := quote.GetFeeLines()[1].GetMeta()[feeLineMetaWalletRef], "mw_src_1"; got != want {
t.Fatalf("unexpected conversion fee wallet_ref: got=%q want=%q", got, want)
}
if got, want := len(quote.GetFeeRules()), 2; got != want {
t.Fatalf("unexpected fee rules count: got=%d want=%d", got, want)
}
}
func TestBuildPaymentQuote_ConversionFeeReturnedEmptyDoesNotAddLines(t *testing.T) {
feeClient := &stubFeeEngineClient{
precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{
"payments.orchestrator.quote": {
Lines: []*feesv1.DerivedPostingLine{
testFeeLine("1.00", "USDT"),
},
},
"payments.orchestrator.conversion_quote": {},
},
}
svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0))
req := &quoteRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"},
IdempotencyKey: "idem_1",
Intent: testManagedWalletToCardIntent(),
}
quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req)
if err != nil {
t.Fatalf("buildPaymentQuote returned error: %v", err)
}
if quote == nil {
t.Fatalf("expected quote")
}
if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 1; got != want {
t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want)
}
if got, want := len(quote.GetFeeLines()), 1; got != want {
t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want)
}
if quote.GetFeeLines()[0].GetMeta()[feeLineMetaTarget] == feeLineTargetWallet {
t.Fatalf("base fee line should not be tagged as wallet conversion line")
}
}
func TestBuildPaymentQuote_DoesNotRequestConversionFeesForManagedWalletToLedger(t *testing.T) {
feeClient := &stubFeeEngineClient{
precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{
"payments.orchestrator.quote": {
Lines: []*feesv1.DerivedPostingLine{
testFeeLine("1.00", "USDT"),
},
},
},
}
svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0))
req := &quoteRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"},
IdempotencyKey: "idem_1",
Intent: testManagedWalletToLedgerIntent(),
}
quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req)
if err != nil {
t.Fatalf("buildPaymentQuote returned error: %v", err)
}
if quote == nil {
t.Fatalf("expected quote")
}
if got, want := len(feeClient.precomputeReqs), 1; got != want {
t.Fatalf("unexpected precompute call count: got=%d want=%d", got, want)
}
if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 0; got != want {
t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want)
}
}
type stubFeeEngineClient struct {
precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse
precomputeReqs []*feesv1.PrecomputeFeesRequest
}
func (s *stubFeeEngineClient) QuoteFees(context.Context, *feesv1.QuoteFeesRequest, ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) {
return nil, merrors.InvalidArgument("unexpected QuoteFees call")
}
func (s *stubFeeEngineClient) PrecomputeFees(_ context.Context, in *feesv1.PrecomputeFeesRequest, _ ...grpc.CallOption) (*feesv1.PrecomputeFeesResponse, error) {
if s == nil {
return &feesv1.PrecomputeFeesResponse{}, nil
}
if in != nil {
if cloned, ok := proto.Clone(in).(*feesv1.PrecomputeFeesRequest); ok {
s.precomputeReqs = append(s.precomputeReqs, cloned)
} else {
s.precomputeReqs = append(s.precomputeReqs, in)
}
}
if in == nil || in.GetIntent() == nil {
return &feesv1.PrecomputeFeesResponse{}, nil
}
originType := strings.TrimSpace(in.GetIntent().GetOriginType())
resp, ok := s.precomputeByOrigin[originType]
if !ok || resp == nil {
return &feesv1.PrecomputeFeesResponse{}, nil
}
if cloned, ok := proto.Clone(resp).(*feesv1.PrecomputeFeesResponse); ok {
return cloned, nil
}
return resp, nil
}
func (s *stubFeeEngineClient) ValidateFeeToken(context.Context, *feesv1.ValidateFeeTokenRequest, ...grpc.CallOption) (*feesv1.ValidateFeeTokenResponse, error) {
return nil, merrors.InvalidArgument("unexpected ValidateFeeToken call")
}
func precomputeCallsByOrigin(reqs []*feesv1.PrecomputeFeesRequest, originType string) int {
count := 0
for _, req := range reqs {
if req == nil || req.GetIntent() == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(req.GetIntent().GetOriginType()), strings.TrimSpace(originType)) {
count++
}
}
return count
}
func testFeeLine(amount, currency string) *feesv1.DerivedPostingLine {
return &feesv1.DerivedPostingLine{
Money: &moneyv1.Money{
Amount: amount,
Currency: currency,
},
LineType: accountingv1.PostingLineType_POSTING_LINE_FEE,
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
}
}
func testManagedWalletToCardIntent() *sharedv1.PaymentIntent {
return &sharedv1.PaymentIntent{
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Source: &sharedv1.PaymentEndpoint{
Endpoint: &sharedv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &sharedv1.ManagedWalletEndpoint{
ManagedWalletRef: "mw_src_1",
},
},
},
Destination: &sharedv1.PaymentEndpoint{
Endpoint: &sharedv1.PaymentEndpoint_Card{
Card: &sharedv1.CardEndpoint{
Card: &sharedv1.CardEndpoint_Pan{Pan: "4111111111111111"},
},
},
},
Amount: &moneyv1.Money{
Amount: "100",
Currency: "USDT",
},
}
}
func testManagedWalletToLedgerIntent() *sharedv1.PaymentIntent {
return &sharedv1.PaymentIntent{
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Source: &sharedv1.PaymentEndpoint{
Endpoint: &sharedv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &sharedv1.ManagedWalletEndpoint{
ManagedWalletRef: "mw_src_1",
},
},
},
Destination: &sharedv1.PaymentEndpoint{
Endpoint: &sharedv1.PaymentEndpoint_Ledger{
Ledger: &sharedv1.LedgerEndpoint{
LedgerAccountRef: "ledger_dst_1",
},
},
},
Amount: &moneyv1.Money{
Amount: "100",
Currency: "USDT",
},
}
}

View File

@@ -1,95 +0,0 @@
package model
import (
"strings"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model/account_role"
"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"`
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"`
ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,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 = normalizeRail(t.FromRail)
t.ToRail = normalizeRail(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 = normalizeRail(step.Rail)
step.Operation = strings.ToLower(strings.TrimSpace(step.Operation))
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
step.DependsOn = normalizeStringList(step.DependsOn)
step.CommitAfter = normalizeStringList(step.CommitAfter)
step.FromRole = normalizeAccountRole(step.FromRole)
step.ToRole = normalizeAccountRole(step.ToRole)
}
}
func normalizeAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
if role == nil {
return nil
}
trimmed := strings.TrimSpace(string(*role))
if trimmed == "" {
return nil
}
if parsed, ok := account_role.Parse(trimmed); ok {
if parsed == "" {
return nil
}
normalized := parsed
return &normalized
}
normalized := account_role.AccountRole(strings.ToLower(trimmed))
return &normalized
}
// 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

@@ -29,7 +29,6 @@ type Store struct {
methods storage.PaymentMethodsStore methods storage.PaymentMethodsStore
quotes quotestorage.QuotesStore quotes quotestorage.QuotesStore
routes storage.RoutesStore routes storage.RoutesStore
plans storage.PlanTemplatesStore
} }
type paymentMethodsConfig struct { type paymentMethodsConfig struct {
@@ -70,22 +69,21 @@ func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Stor
paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection()) paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection()) quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection()) routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection())
plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection())
methodsRepo := repository.CreateMongoRepository(conn.Database(), mservice.PaymentMethods) methodsRepo := repository.CreateMongoRepository(conn.Database(), mservice.PaymentMethods)
return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...) return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, opts...)
} }
// NewWithRepository constructs a payments repository using the provided primitives. // 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, plansRepo repository.Repository, opts ...Option) (*Store, error) { func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, opts ...Option) (*Store, error) {
return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...) return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, opts...)
} }
func newWithRepository( func newWithRepository(
logger mlogger.Logger, logger mlogger.Logger,
ping func(context.Context) error, ping func(context.Context) error,
database *mongo.Database, database *mongo.Database,
paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo repository.Repository, paymentsRepo, methodsRepo, quotesRepo, routesRepo repository.Repository,
opts ...Option, opts ...Option,
) (*Store, error) { ) (*Store, error) {
if ping == nil { if ping == nil {
@@ -100,9 +98,6 @@ func newWithRepository(
if routesRepo == nil { if routesRepo == nil {
return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is 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")
}
cfg := options{} cfg := options{}
for _, opt := range opts { for _, opt := range opts {
@@ -124,10 +119,6 @@ func newWithRepository(
if err != nil { if err != nil {
return nil, err return nil, err
} }
plansStore, err := store.NewPlanTemplates(childLogger, plansRepo)
if err != nil {
return nil, err
}
var methodsStore storage.PaymentMethodsStore var methodsStore storage.PaymentMethodsStore
if cfg.paymentMethodsAuth != nil { if cfg.paymentMethodsAuth != nil {
@@ -155,7 +146,6 @@ func newWithRepository(
methods: methodsStore, methods: methodsStore,
quotes: quotesRepoStore.Quotes(), quotes: quotesRepoStore.Quotes(),
routes: routesStore, routes: routesStore,
plans: plansStore,
} }
return result, nil return result, nil
@@ -189,11 +179,6 @@ func (s *Store) Routes() storage.RoutesStore {
return s.routes return s.routes
} }
// PlanTemplates returns the plan templates store.
func (s *Store) PlanTemplates() storage.PlanTemplatesStore {
return s.plans
}
// MongoDatabase returns underlying Mongo database when available. // MongoDatabase returns underlying Mongo database when available.
func (s *Store) MongoDatabase() *mongo.Database { func (s *Store) MongoDatabase() *mongo.Database {
if s == nil { if s == nil {

View File

@@ -1,174 +0,0 @@
package store
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/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/v2/bson"
"go.mongodb.org/mongo-driver/v2/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(bson.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 bson.ObjectID) (*model.PaymentPlanTemplate, error) {
if id == bson.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
}
entity.Normalize()
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 := normalizedRailFilterValues(filter.FromRail); len(from) == 1 {
query = query.Filter(repository.Field("fromRail"), from[0])
} else if len(from) > 1 {
query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...)
}
if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 {
query = query.Filter(repository.Field("toRail"), to[0])
} else if len(to) > 1 {
query = query.In(repository.Field("toRail"), stringSliceToAny(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
}
item.Normalize()
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

@@ -19,10 +19,6 @@ var (
ErrRouteNotFound = errors.New("payments.storage: route not found") ErrRouteNotFound = errors.New("payments.storage: route not found")
// ErrDuplicateRoute signals that a route already exists for the same transition. // ErrDuplicateRoute signals that a route already exists for the same transition.
ErrDuplicateRoute = errors.New("payments.storage: duplicate route") ErrDuplicateRoute = errors.New("payments.storage: duplicate route")
// ErrPlanTemplateNotFound signals that a plan template record does not exist.
ErrPlanTemplateNotFound = errors.New("payments.storage: plan template not found")
// ErrDuplicatePlanTemplate signals that a plan template already exists for the same transition.
ErrDuplicatePlanTemplate = errors.New("payments.storage: duplicate plan template")
) )
// Repository exposes persistence primitives for the payments domain. // Repository exposes persistence primitives for the payments domain.
@@ -32,7 +28,6 @@ type Repository interface {
PaymentMethods() PaymentMethodsStore PaymentMethods() PaymentMethodsStore
Quotes() quotestorage.QuotesStore Quotes() quotestorage.QuotesStore
Routes() RoutesStore Routes() RoutesStore
PlanTemplates() PlanTemplatesStore
} }
// PaymentsStore manages payment lifecycle state. // PaymentsStore manages payment lifecycle state.
@@ -68,11 +63,3 @@ type RoutesStore interface {
GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentRoute, error) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentRoute, error)
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, 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 bson.ObjectID) (*model.PaymentPlanTemplate, error)
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
}

View File

@@ -39,7 +39,6 @@ const (
Organizations Type = "organizations" // Represents organizations in the system Organizations Type = "organizations" // Represents organizations in the system
Payments Type = "payments" // Represents payments service Payments Type = "payments" // Represents payments service
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates
PaymentMethods Type = "payment_methods" // Represents payment methods service PaymentMethods Type = "payment_methods" // Represents payment methods service
Permissions Type = "permissions" // Represents permissiosns service Permissions Type = "permissions" // Represents permissiosns service
Policies Type = "policies" // Represents access control policies Policies Type = "policies" // Represents access control policies
@@ -61,7 +60,7 @@ func StringToSType(s string) (Type, error) {
case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances, case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances,
ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger, ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
Organizations, Payments, PaymentRoutes, PaymentPlanTemplates, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements, Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements,
Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery: Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:
return Type(s), nil return Type(s), nil
default: default: