From 008427483c53be534414974f2297a7729b94cee1 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 25 Feb 2026 23:20:03 +0100 Subject: [PATCH] - legacy payment template fee lines picking --- .../service/orchestrator/service_v2_test.go | 4 - .../internal/service/plan_builder/default.go | 103 ---- .../service/plan_builder/default_test.go | 462 ----------------- .../service/plan_builder/endpoints.go | 101 ---- .../internal/service/plan_builder/gateways.go | 142 ------ .../internal/service/plan_builder/helpers.go | 463 ------------------ .../service/plan_builder/plan_builder.go | 22 - .../internal/service/plan_builder/plans.go | 78 --- .../internal/service/plan_builder/routes.go | 123 ----- .../internal/service/plan_builder/steps.go | 446 ----------------- .../service/plan_builder/templates.go | 212 -------- api/payments/orchestrator/main.go | 2 +- .../internal/service/plan/builder.go | 20 - .../service/plan/plan_builder_default.go | 102 ---- .../service/plan/plan_builder_steps.go | 447 ----------------- .../service/plan/plan_builder_templates.go | 211 -------- .../quotation/plan_builder_adapters.go | 14 - .../service/quotation/quote_engine.go | 47 +- .../quote_engine_conversion_fee_test.go | 264 ++++++++++ api/payments/storage/model/plan_template.go | 95 ---- api/payments/storage/mongo/repository.go | 23 +- .../storage/mongo/store/plan_templates.go | 174 ------- api/payments/storage/storage.go | 13 - api/pkg/mservice/services.go | 99 ++-- 24 files changed, 321 insertions(+), 3346 deletions(-) delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/default.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/default_test.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/endpoints.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/gateways.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/helpers.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/plan_builder.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/plans.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/routes.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/steps.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/templates.go delete mode 100644 api/payments/quotation/internal/service/plan/plan_builder_default.go delete mode 100644 api/payments/quotation/internal/service/plan/plan_builder_steps.go delete mode 100644 api/payments/quotation/internal/service/plan/plan_builder_templates.go create mode 100644 api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go delete mode 100644 api/payments/storage/model/plan_template.go delete mode 100644 api/payments/storage/mongo/store/plan_templates.go diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go index f5a61010..9eed5915 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go @@ -76,8 +76,4 @@ func (fakeStorageRepo) Routes() storage.RoutesStore { return nil } -func (fakeStorageRepo) PlanTemplates() storage.PlanTemplatesStore { - return nil -} - var _ storage.Repository = fakeStorageRepo{} diff --git a/api/payments/orchestrator/internal/service/plan_builder/default.go b/api/payments/orchestrator/internal/service/plan_builder/default.go deleted file mode 100644 index 3401e887..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/default.go +++ /dev/null @@ -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) -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/default_test.go b/api/payments/orchestrator/internal/service/plan_builder/default_test.go deleted file mode 100644 index 6c47772d..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/default_test.go +++ /dev/null @@ -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()) - } -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/endpoints.go b/api/payments/orchestrator/internal/service/plan_builder/endpoints.go deleted file mode 100644 index 4785fa75..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/endpoints.go +++ /dev/null @@ -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 "" -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/gateways.go b/api/payments/orchestrator/internal/service/plan_builder/gateways.go deleted file mode 100644 index 50de480f..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/gateways.go +++ /dev/null @@ -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 - } -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/helpers.go b/api/payments/orchestrator/internal/service/plan_builder/helpers.go deleted file mode 100644 index 865eaa6f..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/helpers.go +++ /dev/null @@ -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 - } -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/plan_builder.go b/api/payments/orchestrator/internal/service/plan_builder/plan_builder.go deleted file mode 100644 index dfec9fed..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/plan_builder.go +++ /dev/null @@ -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) -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/plans.go b/api/payments/orchestrator/internal/service/plan_builder/plans.go deleted file mode 100644 index 61e41b5f..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/plans.go +++ /dev/null @@ -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() -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/routes.go b/api/payments/orchestrator/internal/service/plan_builder/routes.go deleted file mode 100644 index 8b0bf2d5..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/routes.go +++ /dev/null @@ -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 -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/steps.go b/api/payments/orchestrator/internal/service/plan_builder/steps.go deleted file mode 100644 index f1d0bbad..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/steps.go +++ /dev/null @@ -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 -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/templates.go b/api/payments/orchestrator/internal/service/plan_builder/templates.go deleted file mode 100644 index 3ec399cc..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/templates.go +++ /dev/null @@ -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 -} diff --git a/api/payments/orchestrator/main.go b/api/payments/orchestrator/main.go index 655b1b7d..cb1f3f89 100644 --- a/api/payments/orchestrator/main.go +++ b/api/payments/orchestrator/main.go @@ -32,7 +32,7 @@ Create initial aggregate: state=CREATED, version=1, immutable snapshots, and ini execution_plan_compiler_v2 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 Single source of truth for aggregate transitions (CREATED/EXECUTING/NEEDS_ATTENTION/SETTLED/FAILED) and step transitions (PENDING/RUNNING/COMPLETED/...). diff --git a/api/payments/quotation/internal/service/plan/builder.go b/api/payments/quotation/internal/service/plan/builder.go index b8b6a1d5..9ad317a8 100644 --- a/api/payments/quotation/internal/service/plan/builder.go +++ b/api/payments/quotation/internal/service/plan/builder.go @@ -5,8 +5,6 @@ import ( "github.com/shopspring/decimal" "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. @@ -14,21 +12,11 @@ type RouteStore interface { 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. type GatewayRegistry interface { 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 const ( @@ -37,10 +25,6 @@ const ( 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) { return railFromEndpoint(endpoint, attrs, isSource) } @@ -49,10 +33,6 @@ func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork str 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 { return sendDirectionForRail(rail) } diff --git a/api/payments/quotation/internal/service/plan/plan_builder_default.go b/api/payments/quotation/internal/service/plan/plan_builder_default.go deleted file mode 100644 index ac28fe1a..00000000 --- a/api/payments/quotation/internal/service/plan/plan_builder_default.go +++ /dev/null @@ -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) -} diff --git a/api/payments/quotation/internal/service/plan/plan_builder_steps.go b/api/payments/quotation/internal/service/plan/plan_builder_steps.go deleted file mode 100644 index 8e7396fe..00000000 --- a/api/payments/quotation/internal/service/plan/plan_builder_steps.go +++ /dev/null @@ -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 -} diff --git a/api/payments/quotation/internal/service/plan/plan_builder_templates.go b/api/payments/quotation/internal/service/plan/plan_builder_templates.go deleted file mode 100644 index cb4fee24..00000000 --- a/api/payments/quotation/internal/service/plan/plan_builder_templates.go +++ /dev/null @@ -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 -} diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go index 30217ced..f7f16fd5 100644 --- a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go @@ -1,11 +1,8 @@ package quotation import ( - "context" - "github.com/tech/sendico/payments/quotation/internal/service/plan" "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) { @@ -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) { 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) -} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index 08c15aa6..192823d6 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -91,7 +91,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo } } conversionFeeQuote := &feesv1.PrecomputeFeesResponse{} - if s.shouldQuoteConversionFee(ctx, req.GetIntent()) { + if s.shouldQuoteConversionFee(req.GetIntent()) { conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount) if err != nil { return nil, time.Time{}, err @@ -230,7 +230,7 @@ func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *q 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 { return false } @@ -240,48 +240,7 @@ func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1 if isLedgerEndpoint(intent.GetDestination()) { 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 false + return true } func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule { diff --git a/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go b/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go new file mode 100644 index 00000000..020939af --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go @@ -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 := "eRequest{ + 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 := "eRequest{ + 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 := "eRequest{ + 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", + }, + } +} diff --git a/api/payments/storage/model/plan_template.go b/api/payments/storage/model/plan_template.go deleted file mode 100644 index b106ebd4..00000000 --- a/api/payments/storage/model/plan_template.go +++ /dev/null @@ -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 -} diff --git a/api/payments/storage/mongo/repository.go b/api/payments/storage/mongo/repository.go index d9c1cd74..7d048d7f 100644 --- a/api/payments/storage/mongo/repository.go +++ b/api/payments/storage/mongo/repository.go @@ -29,7 +29,6 @@ type Store struct { methods storage.PaymentMethodsStore quotes quotestorage.QuotesStore routes storage.RoutesStore - plans storage.PlanTemplatesStore } 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()) quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection()) routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection()) - plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection()) 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. -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) { - return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...) +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, opts...) } func newWithRepository( logger mlogger.Logger, ping func(context.Context) error, database *mongo.Database, - paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo repository.Repository, + paymentsRepo, methodsRepo, quotesRepo, routesRepo repository.Repository, opts ...Option, ) (*Store, error) { if ping == nil { @@ -100,9 +98,6 @@ func newWithRepository( if routesRepo == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil") } - if plansRepo == nil { - return nil, merrors.InvalidArgument("payments.storage.mongo: plan templates repository is nil") - } cfg := options{} for _, opt := range opts { @@ -124,10 +119,6 @@ func newWithRepository( if err != nil { return nil, err } - plansStore, err := store.NewPlanTemplates(childLogger, plansRepo) - if err != nil { - return nil, err - } var methodsStore storage.PaymentMethodsStore if cfg.paymentMethodsAuth != nil { @@ -155,7 +146,6 @@ func newWithRepository( methods: methodsStore, quotes: quotesRepoStore.Quotes(), routes: routesStore, - plans: plansStore, } return result, nil @@ -189,11 +179,6 @@ func (s *Store) Routes() storage.RoutesStore { 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. func (s *Store) MongoDatabase() *mongo.Database { if s == nil { diff --git a/api/payments/storage/mongo/store/plan_templates.go b/api/payments/storage/mongo/store/plan_templates.go deleted file mode 100644 index 8b5f7682..00000000 --- a/api/payments/storage/mongo/store/plan_templates.go +++ /dev/null @@ -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) diff --git a/api/payments/storage/storage.go b/api/payments/storage/storage.go index c40d683e..30cbb710 100644 --- a/api/payments/storage/storage.go +++ b/api/payments/storage/storage.go @@ -19,10 +19,6 @@ var ( ErrRouteNotFound = errors.New("payments.storage: route not found") // ErrDuplicateRoute signals that a route already exists for the same transition. 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. @@ -32,7 +28,6 @@ type Repository interface { PaymentMethods() PaymentMethodsStore Quotes() quotestorage.QuotesStore Routes() RoutesStore - PlanTemplates() PlanTemplatesStore } // PaymentsStore manages payment lifecycle state. @@ -68,11 +63,3 @@ type RoutesStore interface { GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentRoute, error) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) } - -// PlanTemplatesStore manages orchestration plan templates. -type PlanTemplatesStore interface { - Create(ctx context.Context, template *model.PaymentPlanTemplate) error - Update(ctx context.Context, template *model.PaymentPlanTemplate) error - GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentPlanTemplate, error) - List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) -} diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index 5ac96793..68d1f42a 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -5,55 +5,54 @@ import "github.com/tech/sendico/pkg/merrors" type Type = string const ( - Accounts Type = "accounts" // Represents user accounts in the system - Verification Type = "verification" // Represents verification code flows - Amplitude Type = "amplitude" // Represents analytics integration with Amplitude - Discovery Type = "discovery" // Represents service discovery registry - Site Type = "site" // Represents public site endpoints - Changes Type = "changes" // Tracks changes made to resources - Clients Type = "clients" // Represents client information - ChainGateway Type = "chain_gateway" // Represents chain gateway microservice - MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice - PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice - FXOracle Type = "fx_oracle" // Represents FX oracle microservice - FeePlans Type = "fee_plans" // Represents fee plans microservice - BillingDocuments Type = "billing_documents" // Represents billing documents microservice - FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources - Invitations Type = "invitations" // Represents invitations sent to users - Invoices Type = "invoices" // Represents invoices - Logo Type = "logo" // Represents logos for organizations or projects - Ledger Type = "ledger" // Represents ledger microservice - LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice - LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice - LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice - LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice - LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice - LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice - PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice - ChainAssets Type = "chain_assets" // Represents managed chain assets - ChainWallets Type = "chain_wallets" // Represents managed chain wallets - ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances - ChainTransfers Type = "chain_transfers" // Represents chain transfers - ChainDeposits Type = "chain_deposits" // Represents chain deposits - Notifications Type = "notifications" // Represents notifications sent to users - Organizations Type = "organizations" // Represents organizations in the system - Payments Type = "payments" // Represents payments service - PaymentRoutes Type = "payment_routes" // Represents payment routing definitions - PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates - PaymentMethods Type = "payment_methods" // Represents payment methods service - Permissions Type = "permissions" // Represents permissiosns service - Policies Type = "policies" // Represents access control policies - PolicyAssignements Type = "policy_assignments" // Represents policy assignments database - Recipients Type = "recipients" // Represents payment recipients - RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication - Roles Type = "roles" // Represents roles in access control - Storage Type = "storage" // Represents statuses of tasks or projects - TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway - Tenants Type = "tenants" // Represents tenants managed in the system - VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system - Wallets Type = "wallets" // Represents workflows for tasks or projects - WalletRoutes Type = "wallet_routes" // Represents authoritative chain wallet gateway routing - Workflows Type = "workflows" // Represents workflows for tasks or projects + Accounts Type = "accounts" // Represents user accounts in the system + Verification Type = "verification" // Represents verification code flows + Amplitude Type = "amplitude" // Represents analytics integration with Amplitude + Discovery Type = "discovery" // Represents service discovery registry + Site Type = "site" // Represents public site endpoints + Changes Type = "changes" // Tracks changes made to resources + Clients Type = "clients" // Represents client information + ChainGateway Type = "chain_gateway" // Represents chain gateway microservice + MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice + PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice + FXOracle Type = "fx_oracle" // Represents FX oracle microservice + FeePlans Type = "fee_plans" // Represents fee plans microservice + BillingDocuments Type = "billing_documents" // Represents billing documents microservice + FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources + Invitations Type = "invitations" // Represents invitations sent to users + Invoices Type = "invoices" // Represents invoices + Logo Type = "logo" // Represents logos for organizations or projects + Ledger Type = "ledger" // Represents ledger microservice + LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice + LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice + LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice + LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice + LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice + LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice + PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice + ChainAssets Type = "chain_assets" // Represents managed chain assets + ChainWallets Type = "chain_wallets" // Represents managed chain wallets + ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances + ChainTransfers Type = "chain_transfers" // Represents chain transfers + ChainDeposits Type = "chain_deposits" // Represents chain deposits + Notifications Type = "notifications" // Represents notifications sent to users + Organizations Type = "organizations" // Represents organizations in the system + Payments Type = "payments" // Represents payments service + PaymentRoutes Type = "payment_routes" // Represents payment routing definitions + PaymentMethods Type = "payment_methods" // Represents payment methods service + Permissions Type = "permissions" // Represents permissiosns service + Policies Type = "policies" // Represents access control policies + PolicyAssignements Type = "policy_assignments" // Represents policy assignments database + Recipients Type = "recipients" // Represents payment recipients + RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication + Roles Type = "roles" // Represents roles in access control + Storage Type = "storage" // Represents statuses of tasks or projects + TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway + Tenants Type = "tenants" // Represents tenants managed in the system + VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system + Wallets Type = "wallets" // Represents workflows for tasks or projects + WalletRoutes Type = "wallet_routes" // Represents authoritative chain wallet gateway routing + Workflows Type = "workflows" // Represents workflows for tasks or projects ) func StringToSType(s string) (Type, error) { @@ -61,7 +60,7 @@ func StringToSType(s string) (Type, error) { case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances, ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger, 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: return Type(s), nil default: