From e11653592618f80d0a7d27db9ff56f440113ac8c Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 11 Feb 2026 17:25:44 +0100 Subject: [PATCH] Fully separated payment quotation and orchestration flows --- api/payments/orchestrator/client/client.go | 6 +- api/payments/orchestrator/go.mod | 45 +- api/payments/orchestrator/go.sum | 60 +- .../internal/server/internal/dependencies.go | 8 +- .../server/internal/discovery_clients.go | 45 + .../server/internal/discovery_wrappers.go | 28 + .../execution_plan.go | 9 +- .../internal/service/execution/export.go | 123 +++ .../payment_plan_analyzer.go | 2 +- .../execution}/payment_plan_helpers.go | 12 +- .../service/execution}/payment_plan_order.go | 2 +- .../service/orchestrator/command_factory.go | 24 +- .../service/orchestrator/execution_compat.go | 103 ++ .../orchestrator/gateway_eligibility.go | 184 ++++ .../service/orchestrator/handlers_commands.go | 600 +----------- .../internal/service/orchestrator/helpers.go | 169 +--- .../service/orchestrator/internal_helpers.go | 64 -- .../internal/service/orchestrator/options.go | 61 +- .../service/orchestrator/payment_executor.go | 82 +- .../orchestrator/payment_plan_executor.go | 8 - .../orchestrator/payment_plan_helpers.go | 215 ---- .../orchestrator/payment_plan_order.go | 226 ----- .../orchestrator/payment_plan_steps.go | 19 - .../orchestrator/payment_plan_storage.go | 109 +++ .../service/orchestrator/plan_builder.go | 29 +- .../service/orchestrator/quote_batch.go | 145 --- .../service/orchestrator/quote_batch_test.go | 102 -- .../service/orchestrator/quote_engine.go | 579 ----------- .../service/orchestrator/quote_engine_test.go | 151 --- ...te_payment_idempotency_integration_test.go | 137 --- .../orchestrator/quote_request_test.go | 151 --- .../service/orchestrator/rail_endpoints.go | 99 ++ .../internal/service/orchestrator/service.go | 42 +- .../service/orchestrator/service_helpers.go | 70 +- .../orchestrator/service_helpers_test.go | 167 ++-- .../service/orchestrator/service_test.go | 25 +- .../default.go} | 9 +- .../default_test.go} | 14 +- .../endpoints.go} | 2 +- .../gateways.go} | 2 +- .../internal/service/plan_builder/helpers.go | 451 +++++++++ .../service/plan_builder/plan_builder.go | 22 + .../internal/service/plan_builder/plans.go} | 2 +- .../internal/service/plan_builder/routes.go} | 6 +- .../internal/service/plan_builder/steps.go} | 11 +- .../service/plan_builder/templates.go} | 5 +- .../internal/service/shared/idempotency.go | 17 + api/payments/quotation/go.mod | 4 +- .../internal/server/internal/discovery.go | 40 + .../internal/server/internal/serverimp.go | 8 +- .../internal/server/internal/types.go | 2 + .../orchestrator/card_payout_constants.go | 10 - .../orchestrator/card_payout_funding.go | 367 ------- .../orchestrator/card_payout_helpers.go | 80 -- .../orchestrator/card_payout_routes.go | 29 - .../orchestrator/card_payout_submit.go | 351 ------- .../service/orchestrator/execution_plan.go | 163 ---- .../gateway_execution_consumer.go | 295 ------ .../service/orchestrator/handlers_commands.go | 922 ------------------ .../service/orchestrator/handlers_events.go | 318 ------ .../service/orchestrator/handlers_queries.go | 80 -- .../service/orchestrator/payment_executor.go | 237 ----- .../orchestrator/payment_plan_analyzer.go | 123 --- .../service/orchestrator/payment_plan_card.go | 196 ---- .../orchestrator/payment_plan_chain.go | 116 --- .../orchestrator/payment_plan_executor.go | 208 ---- .../orchestrator/payment_plan_ledger.go | 596 ----------- .../orchestrator/payment_plan_release.go | 50 - .../orchestrator/payment_plan_steps.go | 446 --------- .../orchestrator/provider_settlement.go | 132 --- .../internal/service/orchestrator/service.go | 210 ---- .../internal/service/plan/builder.go | 70 ++ .../internal/service/plan/helpers.go | 365 +++++++ .../plan_builder_default.go | 10 +- .../plan_builder_endpoints.go | 2 +- .../plan_builder_gateways.go | 2 +- .../service/plan}/plan_builder_plans.go | 2 +- .../service/plan}/plan_builder_routes.go | 2 +- .../service/plan}/plan_builder_steps.go | 18 +- .../service/plan}/plan_builder_templates.go | 2 +- .../quotation/card_payout_constants.go | 5 + .../command_factory.go | 47 +- .../service/quotation/compat_helpers.go | 6 + .../composite_gateway_registry.go | 2 +- .../{orchestrator => quotation}/convert.go | 263 +---- .../discovery_gateway_registry.go | 2 +- .../quotation/gateway_execution_consumer.go | 12 + .../gateway_registry.go | 2 +- .../gateway_resolution.go | 5 +- .../service/quotation/handlers_commands.go | 602 ++++++++++++ .../{orchestrator => quotation}/helpers.go | 74 +- .../internal_helpers.go | 27 +- .../{orchestrator => quotation}/metrics.go | 2 +- .../model_money.go | 2 +- .../{orchestrator => quotation}/options.go | 159 +-- .../service/quotation/payment_plan_factory.go | 159 +++ .../plan_builder.go | 2 +- .../quotation/plan_builder_adapters.go | 34 + .../service/quotation/plan_builder_compat.go | 19 + .../provider_settlement_gateway.go | 2 +- .../quotation_app.go | 6 +- .../quotation_service.go | 10 +- .../quote_batch.go | 2 +- .../quote_engine.go | 2 +- .../internal/service/quotation/service.go | 114 +++ .../service_helpers.go | 95 +- .../internal/service/shared/account.go | 11 + api/payments/storage/model/quote.go | 2 + .../orchestration/v1/orchestration.proto | 120 +++ .../payments/quotation/v1/quotation.proto | 38 + .../v1/shared.proto} | 146 +-- ci/scripts/proto/generate.sh | 20 +- 112 files changed, 3204 insertions(+), 8686 deletions(-) rename api/payments/orchestrator/internal/service/{orchestrator => execution}/execution_plan.go (94%) create mode 100644 api/payments/orchestrator/internal/service/execution/export.go rename api/payments/orchestrator/internal/service/{orchestrator => execution}/payment_plan_analyzer.go (99%) rename api/payments/{quotation/internal/service/orchestrator => orchestrator/internal/service/execution}/payment_plan_helpers.go (93%) rename api/payments/{quotation/internal/service/orchestrator => orchestrator/internal/service/execution}/payment_plan_order.go (99%) create mode 100644 api/payments/orchestrator/internal/service/orchestrator/execution_compat.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/quote_batch.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/quote_batch_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/quote_engine.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/quote_payment_idempotency_integration_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go rename api/payments/orchestrator/internal/service/{orchestrator/plan_builder_default.go => plan_builder/default.go} (93%) rename api/payments/orchestrator/internal/service/{orchestrator/plan_builder_default_test.go => plan_builder/default_test.go} (98%) rename api/payments/orchestrator/internal/service/{orchestrator/plan_builder_endpoints.go => plan_builder/endpoints.go} (99%) rename api/payments/orchestrator/internal/service/{orchestrator/plan_builder_gateways.go => plan_builder/gateways.go} (99%) create mode 100644 api/payments/orchestrator/internal/service/plan_builder/helpers.go create mode 100644 api/payments/orchestrator/internal/service/plan_builder/plan_builder.go rename api/payments/{quotation/internal/service/orchestrator/plan_builder_plans.go => orchestrator/internal/service/plan_builder/plans.go} (99%) rename api/payments/{quotation/internal/service/orchestrator/plan_builder_routes.go => orchestrator/internal/service/plan_builder/routes.go} (93%) rename api/payments/{quotation/internal/service/orchestrator/plan_builder_steps.go => orchestrator/internal/service/plan_builder/steps.go} (98%) rename api/payments/{quotation/internal/service/orchestrator/plan_builder_templates.go => orchestrator/internal/service/plan_builder/templates.go} (97%) create mode 100644 api/payments/orchestrator/internal/service/shared/idempotency.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_constants.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_funding.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_helpers.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_routes.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_submit.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/execution_plan.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/gateway_execution_consumer.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/handlers_commands.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/handlers_events.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/handlers_queries.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/payment_executor.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_analyzer.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_card.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_chain.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_executor.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_ledger.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_release.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_steps.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/provider_settlement.go delete mode 100644 api/payments/quotation/internal/service/orchestrator/service.go create mode 100644 api/payments/quotation/internal/service/plan/builder.go create mode 100644 api/payments/quotation/internal/service/plan/helpers.go rename api/payments/quotation/internal/service/{orchestrator => plan}/plan_builder_default.go (89%) rename api/payments/quotation/internal/service/{orchestrator => plan}/plan_builder_endpoints.go (99%) rename api/payments/quotation/internal/service/{orchestrator => plan}/plan_builder_gateways.go (99%) rename api/payments/{orchestrator/internal/service/orchestrator => quotation/internal/service/plan}/plan_builder_plans.go (99%) rename api/payments/{orchestrator/internal/service/orchestrator => quotation/internal/service/plan}/plan_builder_routes.go (99%) rename api/payments/{orchestrator/internal/service/orchestrator => quotation/internal/service/plan}/plan_builder_steps.go (96%) rename api/payments/{orchestrator/internal/service/orchestrator => quotation/internal/service/plan}/plan_builder_templates.go (99%) create mode 100644 api/payments/quotation/internal/service/quotation/card_payout_constants.go rename api/payments/quotation/internal/service/{orchestrator => quotation}/command_factory.go (59%) create mode 100644 api/payments/quotation/internal/service/quotation/compat_helpers.go rename api/payments/quotation/internal/service/{orchestrator => quotation}/composite_gateway_registry.go (98%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/convert.go (70%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/discovery_gateway_registry.go (99%) create mode 100644 api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go rename api/payments/quotation/internal/service/{orchestrator => quotation}/gateway_registry.go (99%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/gateway_resolution.go (94%) create mode 100644 api/payments/quotation/internal/service/quotation/handlers_commands.go rename api/payments/quotation/internal/service/{orchestrator => quotation}/helpers.go (82%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/internal_helpers.go (80%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/metrics.go (98%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/model_money.go (92%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/options.go (62%) create mode 100644 api/payments/quotation/internal/service/quotation/payment_plan_factory.go rename api/payments/quotation/internal/service/{orchestrator => quotation}/plan_builder.go (98%) create mode 100644 api/payments/quotation/internal/service/quotation/plan_builder_adapters.go create mode 100644 api/payments/quotation/internal/service/quotation/plan_builder_compat.go rename api/payments/quotation/internal/service/{orchestrator => quotation}/provider_settlement_gateway.go (99%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/quotation_app.go (86%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/quotation_service.go (53%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/quote_batch.go (99%) rename api/payments/quotation/internal/service/{orchestrator => quotation}/quote_engine.go (99%) create mode 100644 api/payments/quotation/internal/service/quotation/service.go rename api/payments/quotation/internal/service/{orchestrator => quotation}/service_helpers.go (70%) create mode 100644 api/payments/quotation/internal/service/shared/account.go create mode 100644 api/proto/payments/orchestration/v1/orchestration.proto create mode 100644 api/proto/payments/quotation/v1/quotation.proto rename api/proto/payments/{orchestrator/v1/orchestrator.proto => shared/v1/shared.proto} (60%) diff --git a/api/payments/orchestrator/client/client.go b/api/payments/orchestrator/client/client.go index df1b6193..eafda8a6 100644 --- a/api/payments/orchestrator/client/client.go +++ b/api/payments/orchestrator/client/client.go @@ -8,7 +8,9 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" + orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" @@ -81,8 +83,8 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro cfg: cfg, conn: conn, quoteConn: quoteConn, - client: orchestratorv1.NewPaymentOrchestratorClient(conn), - quoteClient: orchestratorv1.NewPaymentQuotationClient(quoteConn), + client: orchestrationv1.NewPaymentExecutionServiceClient(conn), + quoteClient: quotationv1.NewQuotationServiceClient(quoteConn), }, nil } diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index 2662e1cc..56fa2b5d 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -26,8 +26,6 @@ require ( github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/payments/storage v0.0.0-00010101000000-000000000000 github.com/tech/sendico/pkg v0.1.0 - github.com/testcontainers/testcontainers-go v0.33.0 - github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 go.mongodb.org/mongo-driver/v2 v2.5.0 go.uber.org/zap v1.27.1 google.golang.org/grpc v1.78.0 @@ -36,72 +34,31 @@ require ( ) require ( - dario.cat/mergo v1.0.1 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/casbin/casbin/v2 v2.135.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect - github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v27.3.1+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect - github.com/docker/go-units v0.5.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/klauspost/compress v1.18.4 // indirect - github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect - github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/patternmatcher v0.6.0 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/sys/user v0.3.0 // indirect - github.com/moby/sys/userns v0.1.0 // indirect - github.com/moby/term v0.5.0 // indirect - github.com/montanaflynn/stats v0.7.1 // indirect - github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect - github.com/shirou/gopsutil/v3 v3.24.5 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index 60a7d790..e6f2e0f0 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -1,7 +1,5 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -28,9 +26,6 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -45,12 +40,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -60,16 +53,10 @@ github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= -github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -100,8 +87,6 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= -github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -132,20 +117,17 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= @@ -164,13 +146,9 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.mongodb.org/mongo-driver v1.17.8 h1:BDP3+U3Y8K0vTrpqDJIRaXNhb/bKyoVeg6tIJsW5EhM= -go.mongodb.org/mongo-driver v1.17.8/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -179,10 +157,6 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuH go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= @@ -191,8 +165,6 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -202,70 +174,43 @@ go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= -google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= @@ -275,8 +220,5 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/api/payments/orchestrator/internal/server/internal/dependencies.go b/api/payments/orchestrator/internal/server/internal/dependencies.go index 238ea93d..2bccd833 100644 --- a/api/payments/orchestrator/internal/server/internal/dependencies.go +++ b/api/payments/orchestrator/internal/server/internal/dependencies.go @@ -6,6 +6,7 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" ) type orchestratorDeps struct { @@ -13,6 +14,7 @@ type orchestratorDeps struct { ledgerClient ledgerclient.Client mntxClient mntxclient.Client oracleClient oracleclient.Client + quotationClient quotationv1.QuotationServiceClient gatewayInvokeResolver orchestrator.GatewayInvokeResolver } @@ -30,6 +32,7 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps { deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients} deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients} deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients} + deps.quotationClient = &discoveryQuotationClient{resolver: i.discoveryClients} deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients} return deps } @@ -48,8 +51,9 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest if deps.mntxClient != nil { opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient)) } - if deps.oracleClient != nil { - opts = append(opts, orchestrator.WithOracleClient(deps.oracleClient)) + + if deps.quotationClient != nil { + opts = append(opts, orchestrator.WithQuotationService(deps.quotationClient)) } if deps.gatewayInvokeResolver != nil { opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver)) diff --git a/api/payments/orchestrator/internal/server/internal/discovery_clients.go b/api/payments/orchestrator/internal/server/internal/discovery_clients.go index 21a4a4d0..0f90ae60 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_clients.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_clients.go @@ -19,6 +19,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "go.uber.org/zap" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -32,6 +33,7 @@ var ( ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)} + quoteServiceNames = []string{"PAYMENT_QUOTATION", "payment_quotation"} ) type discoveryEndpoint struct { @@ -53,6 +55,9 @@ type discoveryClientResolver struct { feesConn *grpc.ClientConn feesEndpoint discoveryEndpoint + quoteConn *grpc.ClientConn + quoteEndpoint discoveryEndpoint + ledgerClient ledgerclient.Client ledgerEndpoint discoveryEndpoint @@ -88,6 +93,10 @@ func (r *discoveryClientResolver) Close() { _ = r.feesConn.Close() r.feesConn = nil } + if r.quoteConn != nil { + _ = r.quoteConn.Close() + r.quoteConn = nil + } if r.ledgerClient != nil { _ = r.ledgerClient.Close() r.ledgerClient = nil @@ -128,6 +137,11 @@ func (r *discoveryClientResolver) MntxAvailable() bool { return ok } +func (r *discoveryClientResolver) QuotationAvailable() bool { + _, ok := r.findEntry("quotation", quoteServiceNames, "", "") + return ok +} + func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) { entry, ok := r.findEntry("fees", feesServiceNames, "", "") if !ok { @@ -159,6 +173,37 @@ func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEng return feesv1.NewFeeEngineClient(r.feesConn), nil } +func (r *discoveryClientResolver) QuotationClient(ctx context.Context) (quotationv1.QuotationServiceClient, error) { + entry, ok := r.findEntry("quotation", quoteServiceNames, "", "") + if !ok { + return nil, merrors.NoData("discovery: quotation service unavailable") + } + endpoint, err := parseDiscoveryEndpoint(entry.InvokeURI) + if err != nil { + r.logMissing("quotation", "invalid quotation invoke uri", entry.InvokeURI, err) + return nil, err + } + + r.mu.Lock() + defer r.mu.Unlock() + + if r.quoteConn == nil || r.quoteEndpoint.key() != endpoint.key() || r.quoteEndpoint.address != endpoint.address { + if r.quoteConn != nil { + _ = r.quoteConn.Close() + r.quoteConn = nil + } + conn, dialErr := dialGrpc(ctx, endpoint) + if dialErr != nil { + r.logMissing("quotation", "failed to dial quotation service", endpoint.raw, dialErr) + return nil, dialErr + } + r.quoteConn = conn + r.quoteEndpoint = endpoint + } + + return quotationv1.NewQuotationServiceClient(r.quoteConn), nil +} + func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) { entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "") if !ok { diff --git a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go index 625c1b85..904b519b 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go @@ -13,6 +13,7 @@ import ( chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "google.golang.org/grpc" ) @@ -51,6 +52,33 @@ func (c *discoveryFeeClient) ValidateFeeToken(ctx context.Context, req *feesv1.V return client.ValidateFeeToken(ctx, req, opts...) } +type discoveryQuotationClient struct { + resolver *discoveryClientResolver +} + +func (c *discoveryQuotationClient) Available() bool { + if c == nil || c.resolver == nil { + return false + } + return c.resolver.QuotationAvailable() +} + +func (c *discoveryQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { + client, err := c.resolver.QuotationClient(ctx) + if err != nil { + return nil, err + } + return client.QuotePayment(ctx, req, opts...) +} + +func (c *discoveryQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) { + client, err := c.resolver.QuotationClient(ctx) + if err != nil { + return nil, err + } + return client.QuotePayments(ctx, req, opts...) +} + type discoveryLedgerClient struct { resolver *discoveryClientResolver } diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go b/api/payments/orchestrator/internal/service/execution/execution_plan.go similarity index 94% rename from api/payments/orchestrator/internal/service/orchestrator/execution_plan.go rename to api/payments/orchestrator/internal/service/execution/execution_plan.go index fb093504..158bcc6a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go +++ b/api/payments/orchestrator/internal/service/execution/execution_plan.go @@ -1,4 +1,4 @@ -package orchestrator +package execution import ( "strings" @@ -11,8 +11,9 @@ const ( executionStepMetadataRole = "role" executionStepMetadataStatus = "status" - executionStepRoleSource = "source" - executionStepRoleConsumer = "consumer" + executionStepRoleSource = "source" + executionStepRoleConsumer = "consumer" + executionStepCodeCardPayout = "card_payout" ) func setExecutionStepRole(step *model.ExecutionStep, role string) { @@ -32,7 +33,7 @@ func executionStepRole(step *model.ExecutionStep) string { if role := strings.TrimSpace(step.Metadata[executionStepMetadataRole]); role != "" { return strings.ToLower(role) } - if strings.EqualFold(step.Code, stepCodeCardPayout) { + if strings.EqualFold(step.Code, executionStepCodeCardPayout) { return executionStepRoleConsumer } return executionStepRoleSource diff --git a/api/payments/orchestrator/internal/service/execution/export.go b/api/payments/orchestrator/internal/service/execution/export.go new file mode 100644 index 00000000..f842830e --- /dev/null +++ b/api/payments/orchestrator/internal/service/execution/export.go @@ -0,0 +1,123 @@ +package execution + +import ( + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model/account_role" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +const ( + ExecutionStepRoleSource = executionStepRoleSource + ExecutionStepRoleConsumer = executionStepRoleConsumer +) + +func SetExecutionStepRole(step *model.ExecutionStep, role string) { + setExecutionStepRole(step, role) +} + +func SetExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) { + setExecutionStepStatus(step, state) +} + +func ExecutionStepRole(step *model.ExecutionStep) string { + return executionStepRole(step) +} + +func IsSourceExecutionStep(step *model.ExecutionStep) bool { + return isSourceExecutionStep(step) +} + +func SourceStepsConfirmed(plan *model.ExecutionPlan) bool { + return sourceStepsConfirmed(plan) +} + +func FindExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep { + return findExecutionStepByTransferRef(plan, transferRef) +} + +func UpdateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep { + return updateExecutionStepFromTransfer(plan, event) +} + +func ExecutionStepStatusFromTransferStatus(status chainv1.TransferStatus) model.OperationState { + return executionStepStatusFromTransferStatus(status) +} + +func SetExecutionStepMetadata(step *model.ExecutionStep, key, value string) { + setExecutionStepMetadata(step, key, value) +} + +func EnsureExecutionRefs(payment *model.Payment) *model.ExecutionRefs { + return ensureExecutionRefs(payment) +} + +func ExecutionQuote( + payment *model.Payment, + quote *orchestratorv1.PaymentQuote, + quoteFromSnapshot func(*model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote, +) *orchestratorv1.PaymentQuote { + return executionQuote(payment, quote, quoteFromSnapshot) +} + +func EnsureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan { + return ensureExecutionPlanForPlan(payment, plan) +} + +func ExecutionPlanComplete(plan *model.ExecutionPlan) bool { + return executionPlanComplete(plan) +} + +func BlockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { + return blockStepConfirmed(plan, execPlan) +} + +func RoleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) { + return roleHintsForStep(plan, idx) +} + +func LinkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) { + linkRailObservation(payment, rail, referenceID, dependsOn) +} + +func PlanStepID(step *model.PaymentStep, idx int) string { + return planStepID(step, idx) +} + +func DescribePlanStep(step *model.PaymentStep) string { + return describePlanStep(step) +} + +func PlanStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string { + return planStepIdempotencyKey(payment, idx, step) +} + +func FailureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode { + return failureCodeForStep(step) +} + +func ExecutionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep { + return executionStepsByCode(plan) +} + +func PlanStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep { + return planStepsByID(plan) +} + +func StepDependenciesReady( + step *model.PaymentStep, + execSteps map[string]*model.ExecutionStep, + planSteps map[string]*model.PaymentStep, + requireSuccess bool, +) (ready bool, waiting bool, blocked bool, err error) { + return stepDependenciesReady(step, execSteps, planSteps, requireSuccess) +} + +func CardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { + return cardPayoutDependenciesConfirmed(plan, execPlan) +} + +func AnalyzeExecutionPlan(logger mlogger.Logger, payment *model.Payment) (bool, bool, error) { + return analyzeExecutionPlan(logger, payment) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_analyzer.go b/api/payments/orchestrator/internal/service/execution/payment_plan_analyzer.go similarity index 99% rename from api/payments/orchestrator/internal/service/orchestrator/payment_plan_analyzer.go rename to api/payments/orchestrator/internal/service/execution/payment_plan_analyzer.go index 0e19e24b..2e1b646a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_analyzer.go +++ b/api/payments/orchestrator/internal/service/execution/payment_plan_analyzer.go @@ -1,4 +1,4 @@ -package orchestrator +package execution import ( "errors" diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_helpers.go b/api/payments/orchestrator/internal/service/execution/payment_plan_helpers.go similarity index 93% rename from api/payments/quotation/internal/service/orchestrator/payment_plan_helpers.go rename to api/payments/orchestrator/internal/service/execution/payment_plan_helpers.go index 1e590775..a49fd53f 100644 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_helpers.go +++ b/api/payments/orchestrator/internal/service/execution/payment_plan_helpers.go @@ -1,4 +1,4 @@ -package orchestrator +package execution import ( "fmt" @@ -17,12 +17,16 @@ func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs { return payment.Execution } -func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote { +func executionQuote( + payment *model.Payment, + quote *orchestratorv1.PaymentQuote, + quoteFromSnapshot func(*model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote, +) *orchestratorv1.PaymentQuote { if quote != nil { return quote } - if payment != nil && payment.LastQuote != nil { - return modelQuoteToProto(payment.LastQuote) + if payment != nil && payment.LastQuote != nil && quoteFromSnapshot != nil { + return quoteFromSnapshot(payment.LastQuote) } return &orchestratorv1.PaymentQuote{} } diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_order.go b/api/payments/orchestrator/internal/service/execution/payment_plan_order.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/payment_plan_order.go rename to api/payments/orchestrator/internal/service/execution/payment_plan_order.go index 064f7971..68d869fb 100644 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_order.go +++ b/api/payments/orchestrator/internal/service/execution/payment_plan_order.go @@ -1,4 +1,4 @@ -package orchestrator +package execution import ( "strings" diff --git a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go index ed7b6c09..aa3e0129 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go +++ b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go @@ -2,7 +2,6 @@ package orchestrator import ( "context" - "time" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/payments/storage/model" @@ -12,8 +11,7 @@ import ( type paymentEngine interface { EnsureRepository(ctx context.Context) error - BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) - ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) + ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error Repository() storage.Repository } @@ -26,11 +24,7 @@ func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error { return e.svc.ensureRepository(ctx) } -func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) { - return e.svc.buildPaymentQuote(ctx, orgRef, req) -} - -func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) { +func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) { return e.svc.resolvePaymentQuote(ctx, in) } @@ -54,20 +48,6 @@ func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paym } } -func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand { - return "ePaymentCommand{ - engine: f.engine, - logger: f.logger.Named("quote_payment"), - } -} - -func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand { - return "ePaymentsCommand{ - engine: f.engine, - logger: f.logger.Named("quote_payments"), - } -} - func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand { return &initiatePaymentCommand{ engine: f.engine, diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go b/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go new file mode 100644 index 00000000..d4d77467 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go @@ -0,0 +1,103 @@ +package orchestrator + +import ( + "github.com/tech/sendico/payments/orchestrator/internal/service/execution" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model/account_role" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +type Liveness = execution.Liveness + +const ( + StepFinal Liveness = execution.StepFinal + StepRunnable Liveness = execution.StepRunnable + StepBlocked Liveness = execution.StepBlocked + StepDead Liveness = execution.StepDead + + executionStepRoleSource = execution.ExecutionStepRoleSource + executionStepRoleConsumer = execution.ExecutionStepRoleConsumer +) + +func setExecutionStepRole(step *model.ExecutionStep, role string) { + execution.SetExecutionStepRole(step, role) +} + +func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) { + execution.SetExecutionStepStatus(step, state) +} + +func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep { + return execution.FindExecutionStepByTransferRef(plan, transferRef) +} + +func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep { + return execution.UpdateExecutionStepFromTransfer(plan, event) +} + +func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs { + return execution.EnsureExecutionRefs(payment) +} + +func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote { + return execution.ExecutionQuote(payment, quote, modelQuoteToProto) +} + +func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan { + return execution.EnsureExecutionPlanForPlan(payment, plan) +} + +func executionPlanComplete(plan *model.ExecutionPlan) bool { + return execution.ExecutionPlanComplete(plan) +} + +func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { + return execution.BlockStepConfirmed(plan, execPlan) +} + +func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) { + return execution.RoleHintsForStep(plan, idx) +} + +func linkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) { + execution.LinkRailObservation(payment, rail, referenceID, dependsOn) +} + +func planStepID(step *model.PaymentStep, idx int) string { + return execution.PlanStepID(step, idx) +} + +func describePlanStep(step *model.PaymentStep) string { + return execution.DescribePlanStep(step) +} + +func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string { + return execution.PlanStepIdempotencyKey(payment, idx, step) +} + +func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep { + return execution.ExecutionStepsByCode(plan) +} + +func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep { + return execution.PlanStepsByID(plan) +} + +func stepDependenciesReady( + step *model.PaymentStep, + execSteps map[string]*model.ExecutionStep, + planSteps map[string]*model.PaymentStep, + requireSuccess bool, +) (ready bool, waiting bool, blocked bool, err error) { + return execution.StepDependenciesReady(step, execSteps, planSteps, requireSuccess) +} + +func cardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { + return execution.CardPayoutDependenciesConfirmed(plan, execPlan) +} + +func analyzeExecutionPlan(logger mlogger.Logger, payment *model.Payment) (bool, bool, error) { + return execution.AnalyzeExecutionPlan(logger, payment) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go new file mode 100644 index 00000000..1c2eb824 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go @@ -0,0 +1,184 @@ +package orchestrator + +import ( + "fmt" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/storage/model" +) + +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 isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) error { + if gw == nil { + return gatewayIneligible(gw, "gateway instance is required") + } + if !gw.IsEnabled { + return gatewayIneligible(gw, "gateway instance is disabled") + } + if gw.Rail != rail { + return gatewayIneligible(gw, fmt.Sprintf("rail mismatch: want %s got %s", rail, gw.Rail)) + } + if network != "" && gw.Network != "" && !strings.EqualFold(gw.Network, network) { + return gatewayIneligible(gw, fmt.Sprintf("network mismatch: want %s got %s", network, gw.Network)) + } + if currency != "" && len(gw.Currencies) > 0 { + found := false + for _, c := range gw.Currencies { + if strings.EqualFold(c, currency) { + found = true + break + } + } + if !found { + return gatewayIneligible(gw, "currency not supported: "+currency) + } + } + + if !capabilityAllowsAction(gw.Capabilities, action, dir) { + return gatewayIneligible(gw, fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir))) + } + + if currency != "" { + if err := amountWithinLimits(gw, gw.Limits, currency, amount, action); err != nil { + return err + } + } + return nil +} + +type gatewayIneligibleError struct { + reason string +} + +func (e gatewayIneligibleError) Error() string { + return e.reason +} + +func gatewayIneligible(gw *model.GatewayInstanceDescriptor, reason string) error { + if strings.TrimSpace(reason) == "" { + reason = "gateway instance is not eligible" + } + return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", gw.InstanceID, reason)} +} + +func sendDirectionLabel(dir sendDirection) string { + switch dir { + case sendDirectionOut: + return "out" + case sendDirectionIn: + return "in" + default: + return "any" + } +} + +func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool { + switch action { + case model.RailOperationSend: + switch dir { + case sendDirectionOut: + return cap.CanPayOut + case sendDirectionIn: + return cap.CanPayIn + default: + return cap.CanPayIn || cap.CanPayOut + } + case model.RailOperationFee: + return cap.CanSendFee + case model.RailOperationObserveConfirm: + return cap.RequiresObserveConfirm + case model.RailOperationBlock: + return cap.CanBlock + case model.RailOperationRelease: + return cap.CanRelease + default: + return true + } +} + +func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) error { + min := firstLimitValue(limits.MinAmount, "") + max := firstLimitValue(limits.MaxAmount, "") + perTxMin := firstLimitValue(limits.PerTxMinAmount, "") + perTxMax := firstLimitValue(limits.PerTxMaxAmount, "") + maxFee := firstLimitValue(limits.PerTxMaxFee, "") + + if override, ok := limits.CurrencyLimits[currency]; ok { + min = firstLimitValue(override.MinAmount, min) + max = firstLimitValue(override.MaxAmount, max) + if action == model.RailOperationFee { + maxFee = firstLimitValue(override.MaxFee, maxFee) + } + } + + if min != "" { + if val, err := decimal.NewFromString(min); err == nil && amount.LessThan(val) { + return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below min limit %s", amount.String(), currency, val.String())) + } + } + if perTxMin != "" { + if val, err := decimal.NewFromString(perTxMin); err == nil && amount.LessThan(val) { + return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below per-tx min limit %s", amount.String(), currency, val.String())) + } + } + if max != "" { + if val, err := decimal.NewFromString(max); err == nil && amount.GreaterThan(val) { + return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds max limit %s", amount.String(), currency, val.String())) + } + } + if perTxMax != "" { + if val, err := decimal.NewFromString(perTxMax); err == nil && amount.GreaterThan(val) { + return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds per-tx max limit %s", amount.String(), currency, val.String())) + } + } + if action == model.RailOperationFee && maxFee != "" { + if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) { + return gatewayIneligible(gw, fmt.Sprintf("fee amount %s %s exceeds max fee limit %s", amount.String(), currency, val.String())) + } + } + + return nil +} + +func firstLimitValue(primary, fallback string) string { + val := strings.TrimSpace(primary) + if val != "" { + return val + } + return strings.TrimSpace(fallback) +} + +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 + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go index 57e9cb40..2b07b4c7 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -2,14 +2,11 @@ package orchestrator import ( "context" - "crypto/sha256" - "encoding/hex" "errors" - "sort" "strings" - "time" "github.com/google/uuid" + "github.com/tech/sendico/payments/orchestrator/internal/service/shared" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" @@ -18,561 +15,9 @@ import ( "github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mutil/mzap" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" - "google.golang.org/protobuf/proto" ) -type quotePaymentCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -var ( - errIdempotencyRequired = errors.New("idempotency key is required") - errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") - errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") -) - -type quoteCtx struct { - orgID string - orgRef bson.ObjectID - intent *orchestratorv1.PaymentIntent - previewOnly bool - idempotencyKey string - hash string -} - -func (h *quotePaymentCommand) Execute( - ctx context.Context, - req *orchestratorv1.QuotePaymentRequest, -) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { - - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - - qc, err := h.prepareQuoteCtx(req) - if err != nil { - return h.mapQuoteErr(err) - } - - quotesStore, err := ensureQuotesStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req) - if err != nil { - return h.mapQuoteErr(err) - } - - return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{ - IdempotencyKey: req.GetIdempotencyKey(), - Quote: quoteProto, - }) -} - -func (h *quotePaymentCommand) prepareQuoteCtx(req *orchestratorv1.QuotePaymentRequest) (*quoteCtx, error) { - orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return nil, err - } - if err := requireNonNilIntent(req.GetIntent()); err != nil { - return nil, err - } - - intent := req.GetIntent() - preview := req.GetPreviewOnly() - idem := strings.TrimSpace(req.GetIdempotencyKey()) - - if preview && idem != "" { - return nil, errPreviewWithIdempotency - } - if !preview && idem == "" { - return nil, errIdempotencyRequired - } - - return "eCtx{ - orgID: orgRef, - orgRef: orgID, - intent: intent, - previewOnly: preview, - idempotencyKey: idem, - hash: hashQuoteRequest(req), - }, nil -} - -func (h *quotePaymentCommand) quotePayment( - ctx context.Context, - quotesStore storage.QuotesStore, - qc *quoteCtx, - req *orchestratorv1.QuotePaymentRequest, -) (*orchestratorv1.PaymentQuote, error) { - - if qc.previewOnly { - quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) - if err != nil { - h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID)) - return nil, err - } - quote.QuoteRef = bson.NewObjectID().Hex() - return quote, nil - } - - existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) { - h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err), - mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey), - ) - return nil, err - } - if existing != nil { - if existing.Hash != qc.hash { - return nil, errIdempotencyParamMismatch - } - h.logger.Debug( - "Idempotent quote reused", - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("quote_ref", existing.QuoteRef), - ) - return modelQuoteToProto(existing.Quote), nil - } - - quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) - if err != nil { - h.logger.Warn( - "Failed to build payment quote", - zap.Error(err), - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - ) - return nil, err - } - - quoteRef := bson.NewObjectID().Hex() - quote.QuoteRef = quoteRef - - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - IdempotencyKey: qc.idempotencyKey, - Hash: qc.hash, - Intent: intentFromProto(qc.intent), - Quote: quoteSnapshotToModel(quote), - ExpiresAt: expiresAt, - } - record.SetID(bson.NewObjectID()) - record.SetOrganizationRef(qc.orgRef) - - if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, storage.ErrDuplicateQuote) { - existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if getErr == nil && existing != nil { - if existing.Hash != qc.hash { - return nil, errIdempotencyParamMismatch - } - return modelQuoteToProto(existing.Quote), nil - } - } - return nil, err - } - - h.logger.Info( - "Stored payment quote", - zap.String("quote_ref", quoteRef), - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("kind", qc.intent.GetKind().String()), - ) - - return quote, nil -} - -func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { - if errors.Is(err, errIdempotencyRequired) || - errors.Is(err, errPreviewWithIdempotency) || - errors.Is(err, errIdempotencyParamMismatch) { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) -} - -// TODO: temprorarary hashing function, replace with a proper solution later -func hashQuoteRequest(req *orchestratorv1.QuotePaymentRequest) string { - cloned := proto.Clone(req).(*orchestratorv1.QuotePaymentRequest) - cloned.Meta = nil - cloned.IdempotencyKey = "" - cloned.PreviewOnly = false - - b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned) - if err != nil { - sum := sha256.Sum256([]byte("marshal_error")) - return hex.EncodeToString(sum[:]) - } - - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} - -type quotePaymentsCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -var ( - errBatchIdempotencyRequired = errors.New("idempotency key is required") - errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") - errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") - errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape") -) - -type quotePaymentsCtx struct { - orgID string - orgRef bson.ObjectID - previewOnly bool - idempotencyKey string - hash string - intentCount int -} - -func (h *quotePaymentsCommand) Execute( - ctx context.Context, - req *orchestratorv1.QuotePaymentsRequest, -) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] { - - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - - qc, intents, err := h.prepare(req) - if err != nil { - return h.mapErr(err) - } - - quotesStore, err := ensureQuotesStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if qc.previewOnly { - quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, true) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - aggregate, expiresAt, err := h.aggregate(quotes, expires) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - _ = expiresAt - return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{ - QuoteRef: "", - Aggregate: aggregate, - Quotes: quotes, - }) - } - - if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } else if ok { - return gsresponse.Success(h.responseFromRecord(rec)) - } - - quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, false) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - aggregate, expiresAt, err := h.aggregate(quotes, expires) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - quoteRef := bson.NewObjectID().Hex() - for _, q := range quotes { - if q != nil { - q.QuoteRef = quoteRef - } - } - - rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, expiresAt) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if rec != nil { - return gsresponse.Success(h.responseFromRecord(rec)) - } - - h.logger.Info( - "Stored payment quotes", - h.logFields(qc, quoteRef, expiresAt, len(quotes))..., - ) - - return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{ - IdempotencyKey: req.GetIdempotencyKey(), - QuoteRef: quoteRef, - Aggregate: aggregate, - Quotes: quotes, - }) -} - -func (h *quotePaymentsCommand) prepare(req *orchestratorv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*orchestratorv1.PaymentIntent, error) { - orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return nil, nil, err - } - - intents := req.GetIntents() - if len(intents) == 0 { - return nil, nil, merrors.InvalidArgument("intents are required") - } - for _, intent := range intents { - if err := requireNonNilIntent(intent); err != nil { - return nil, nil, err - } - } - - preview := req.GetPreviewOnly() - idem := strings.TrimSpace(req.GetIdempotencyKey()) - - if preview && idem != "" { - return nil, nil, errBatchPreviewWithIdempotency - } - if !preview && idem == "" { - return nil, nil, errBatchIdempotencyRequired - } - - hash, err := hashQuotePaymentsIntents(intents) - if err != nil { - return nil, nil, err - } - - return "ePaymentsCtx{ - orgID: orgRefStr, - orgRef: orgID, - previewOnly: preview, - idempotencyKey: idem, - hash: hash, - intentCount: len(intents), - }, intents, nil -} - -func (h *quotePaymentsCommand) tryReuse( - ctx context.Context, - quotesStore storage.QuotesStore, - qc *quotePaymentsCtx, -) (*model.PaymentQuoteRecord, bool, error) { - - rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil { - if errors.Is(err, storage.ErrQuoteNotFound) { - return nil, false, nil - } - h.logger.Warn( - "Failed to lookup payment quotes by idempotency key", - h.logFields(qc, "", time.Time{}, 0)..., - ) - return nil, false, err - } - - if len(rec.Quotes) == 0 { - return nil, false, errBatchIdempotencyShapeMismatch - } - if rec.Hash != qc.hash { - return nil, false, errBatchIdempotencyParamMismatch - } - - h.logger.Debug( - "Idempotent payment quotes reused", - h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))..., - ) - - return rec, true, nil -} - -func (h *quotePaymentsCommand) buildQuotes( - ctx context.Context, - meta *orchestratorv1.RequestMeta, - baseKey string, - intents []*orchestratorv1.PaymentIntent, - preview bool, -) ([]*orchestratorv1.PaymentQuote, []time.Time, error) { - - quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents)) - expires := make([]time.Time, 0, len(intents)) - - for i, intent := range intents { - req := &orchestratorv1.QuotePaymentRequest{ - Meta: meta, - IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)), - Intent: intent, - PreviewOnly: preview, - } - q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req) - if err != nil { - h.logger.Warn( - "Failed to build payment quote (batch item)", - zap.Int("idx", i), - zap.Error(err), - ) - return nil, nil, err - } - quotes = append(quotes, q) - expires = append(expires, exp) - } - - return quotes, expires, nil -} - -func (h *quotePaymentsCommand) aggregate( - quotes []*orchestratorv1.PaymentQuote, - expires []time.Time, -) (*orchestratorv1.PaymentQuoteAggregate, time.Time, error) { - - agg, err := aggregatePaymentQuotes(quotes) - if err != nil { - return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed") - } - - expiresAt, ok := minQuoteExpiry(expires) - if !ok { - return nil, time.Time{}, merrors.Internal("quote expiry missing") - } - - return agg, expiresAt, nil -} - -func (h *quotePaymentsCommand) storeBatch( - ctx context.Context, - quotesStore storage.QuotesStore, - qc *quotePaymentsCtx, - quoteRef string, - intents []*orchestratorv1.PaymentIntent, - quotes []*orchestratorv1.PaymentQuote, - expiresAt time.Time, -) (*model.PaymentQuoteRecord, error) { - - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - IdempotencyKey: qc.idempotencyKey, - Hash: qc.hash, - Intents: intentsFromProto(intents), - Quotes: quoteSnapshotsFromProto(quotes), - ExpiresAt: expiresAt, - } - record.SetID(bson.NewObjectID()) - record.SetOrganizationRef(qc.orgRef) - - if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, storage.ErrDuplicateQuote) { - rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc) - if reuseErr != nil { - return nil, reuseErr - } - if ok { - return rec, nil - } - return nil, err - } - return nil, err - } - - return nil, nil -} - -func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *orchestratorv1.QuotePaymentsResponse { - quotes := modelQuotesToProto(rec.Quotes) - for _, q := range quotes { - if q != nil { - q.QuoteRef = rec.QuoteRef - } - } - aggregate, _ := aggregatePaymentQuotes(quotes) - - return &orchestratorv1.QuotePaymentsResponse{ - QuoteRef: rec.QuoteRef, - Aggregate: aggregate, - Quotes: quotes, - } -} - -func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field { - fields := []zap.Field{ - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("org_ref_str", qc.orgID), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("hash", qc.hash), - zap.Bool("preview_only", qc.previewOnly), - zap.Int("intent_count", qc.intentCount), - } - if quoteRef != "" { - fields = append(fields, zap.String("quote_ref", quoteRef)) - } - if !expiresAt.IsZero() { - fields = append(fields, zap.Time("expires_at", expiresAt)) - } - if quoteCount > 0 { - fields = append(fields, zap.Int("quote_count", quoteCount)) - } - return fields -} - -func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] { - if errors.Is(err, errBatchIdempotencyRequired) || - errors.Is(err, errBatchPreviewWithIdempotency) || - errors.Is(err, errBatchIdempotencyParamMismatch) || - errors.Is(err, errBatchIdempotencyShapeMismatch) { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) -} - -func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*orchestratorv1.PaymentQuote { - if len(snaps) == 0 { - return nil - } - out := make([]*orchestratorv1.PaymentQuote, 0, len(snaps)) - for _, s := range snaps { - out = append(out, modelQuoteToProto(s)) - } - return out -} - -func hashQuotePaymentsIntents(intents []*orchestratorv1.PaymentIntent) (string, error) { - type item struct { - Idx int - H [32]byte - } - items := make([]item, 0, len(intents)) - - for i, intent := range intents { - b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent) - if err != nil { - return "", err - } - items = append(items, item{Idx: i, H: sha256.Sum256(b)}) - } - - sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx }) - - h := sha256.New() - h.Write([]byte("quote-payments-fp/v1")) - h.Write([]byte{0}) - for _, it := range items { - h.Write(it.H[:]) - h.Write([]byte{0}) - } - - return hex.EncodeToString(h.Sum(nil)), nil -} - type initiatePaymentsCommand struct { engine paymentEngine logger mlogger.Logger @@ -612,15 +57,22 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator intents := record.Intents quotes := record.Quotes + plans := record.Plans if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified { intents = []model.PaymentIntent{record.Intent} } if len(quotes) == 0 && record.Quote != nil { quotes = []*model.PaymentQuoteSnapshot{record.Quote} } + if len(plans) == 0 && record.Plan != nil { + plans = []*model.PaymentPlan{record.Plan} + } if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) { return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete")) } + if len(plans) > 0 && len(plans) != len(intents) { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plans are incomplete")) + } store, err := ensurePaymentsStore(h.engine.Repository()) if err != nil { @@ -639,7 +91,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator } quoteProto.QuoteRef = quoteRef - perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents)) + perKey := shared.PerIntentIdempotencyKey(idempotencyKey, i, len(intents)) if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil { payments = append(payments, toProtoPayment(existing)) continue @@ -648,6 +100,14 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator } entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto) + var plan *model.PaymentPlan + if i < len(plans) { + plan = plans[i] + } + if plan == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plans are incomplete")) + } + attachStoredPlan(entity, plan, perKey) if err = store.Create(ctx, entity); err != nil { if errors.Is(err, storage.ErrDuplicatePayment) { return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) @@ -733,7 +193,7 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) } - quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ + quoteSnapshot, resolvedIntent, plan, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ OrgRef: orgRef, OrgID: orgID, Meta: req.GetMeta(), @@ -770,6 +230,10 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv ) entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot) + if plan == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plan is required")) + } + attachStoredPlan(entity, plan, idempotencyKey) if err = store.Create(ctx, entity); err != nil { if errors.Is(err, storage.ErrDuplicatePayment) { @@ -845,7 +309,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat if req == nil { return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) } - orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + _, orgID, err := validateMetaAndOrgRef(req.GetMeta()) if err != nil { return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) } @@ -893,16 +357,28 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat SettlementCurrency: strings.TrimSpace(amount.GetCurrency()), } - quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ + quote, resolvedIntent, plan, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ + OrgRef: req.GetMeta().GetOrganizationRef(), + OrgID: orgID, Meta: req.GetMeta(), - IdempotencyKey: req.GetIdempotencyKey(), Intent: intentProto, + IdempotencyKey: req.GetIdempotencyKey(), }) if err != nil { return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) } + if quote == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote is required")) + } + if resolvedIntent == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required")) + } + if plan == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plan is required")) + } - entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote) + entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quote) + attachStoredPlan(entity, plan, idempotencyKey) if err = store.Create(ctx, entity); err != nil { if errors.Is(err, storage.ErrDuplicatePayment) { diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go index 3b42580d..8144b605 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/helpers.go @@ -2,16 +2,13 @@ package orchestrator import ( "strings" - "time" "github.com/shopspring/decimal" - oracleclient "github.com/tech/sendico/fx/oracle/client" "github.com/tech/sendico/pkg/merrors" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "google.golang.org/protobuf/proto" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" @@ -25,10 +22,8 @@ type moneyGetter interface { } const ( - feeLineMetaTarget = "fee_target" - feeLineTargetWallet = "wallet" - feeLineMetaWalletRef = "fee_wallet_ref" - feeLineMetaWalletType = "fee_wallet_type" + feeLineMetaTarget = "fee_target" + feeLineTargetWallet = "wallet" ) func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money { @@ -70,76 +65,6 @@ func cloneStringList(values []string) []string { return result } -func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine { - if len(lines) == 0 { - return nil - } - out := make([]*feesv1.DerivedPostingLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - continue - } - if cloned, ok := proto.Clone(line).(*feesv1.DerivedPostingLine); ok { - out = append(out, cloned) - } - } - if len(out) == 0 { - return nil - } - return out -} - -func cloneFeeRules(rules []*feesv1.AppliedRule) []*feesv1.AppliedRule { - if len(rules) == 0 { - return nil - } - out := make([]*feesv1.AppliedRule, 0, len(rules)) - for _, rule := range rules { - if rule == nil { - continue - } - if cloned, ok := proto.Clone(rule).(*feesv1.AppliedRule); ok { - out = append(out, cloned) - } - } - if len(out) == 0 { - return nil - } - return out -} - -func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *moneyv1.Money { - if len(lines) == 0 || currency == "" { - return nil - } - total := decimal.Zero - for _, line := range lines { - if line == nil || line.GetMoney() == nil { - continue - } - if !strings.EqualFold(line.GetMoney().GetCurrency(), currency) { - continue - } - amount, err := decimal.NewFromString(line.GetMoney().GetAmount()) - if err != nil { - continue - } - switch line.GetSide() { - case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: - total = total.Sub(amount.Abs()) - default: - total = total.Add(amount.Abs()) - } - } - if total.IsZero() { - return nil - } - return &moneyv1.Money{ - Currency: currency, - Amount: total.String(), - } -} - func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) { if fxQuote == nil { return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount) @@ -291,43 +216,6 @@ func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency st } } -func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote { - if src == nil { - return nil - } - return &oraclev1.Quote{ - QuoteRef: src.QuoteRef, - Pair: src.Pair, - Side: src.Side, - Price: &moneyv1.Decimal{Value: src.Price}, - BaseAmount: cloneProtoMoney(src.BaseAmount), - QuoteAmount: cloneProtoMoney(src.QuoteAmount), - ExpiresAtUnixMs: src.ExpiresAt.UnixMilli(), - Provider: src.Provider, - RateRef: src.RateRef, - Firm: src.Firm, - } -} - -func setFeeLineTarget(lines []*feesv1.DerivedPostingLine, target string) { - target = strings.TrimSpace(target) - if target == "" || len(lines) == 0 { - return - } - for _, line := range lines { - if line == nil { - continue - } - if line.Meta == nil { - line.Meta = map[string]string{} - } - line.Meta[feeLineMetaTarget] = target - if strings.EqualFold(target, feeLineTargetWallet) { - line.LedgerAccountRef = "" - } - } -} - func feeLineTarget(line *feesv1.DerivedPostingLine) string { if line == nil { return "" @@ -339,26 +227,6 @@ func isWalletTargetFeeLine(line *feesv1.DerivedPostingLine) bool { return strings.EqualFold(feeLineTarget(line), feeLineTargetWallet) } -func setFeeLineWalletRef(lines []*feesv1.DerivedPostingLine, walletRef, walletType string) { - walletRef = strings.TrimSpace(walletRef) - walletType = strings.TrimSpace(walletType) - if walletRef == "" || len(lines) == 0 { - return - } - for _, line := range lines { - if line == nil { - continue - } - if line.Meta == nil { - line.Meta = map[string]string{} - } - line.Meta[feeLineMetaWalletRef] = walletRef - if walletType != "" { - line.Meta[feeLineMetaWalletType] = walletType - } - } -} - func ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.PostingLine { if len(lines) == 0 { return nil @@ -398,39 +266,6 @@ func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv } } -func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time { - expiry := time.Time{} - if feeQuote != nil && feeQuote.GetExpiresAt() != nil { - expiry = feeQuote.GetExpiresAt().AsTime() - } - if expiry.IsZero() { - expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond) - } - if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 { - fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC() - if fxExpiry.Before(expiry) { - expiry = fxExpiry - } - } - return expiry -} - -func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine { - if account == "" || len(lines) == 0 { - return lines - } - for _, line := range lines { - if line == nil || isWalletTargetFeeLine(line) { - continue - } - if strings.TrimSpace(line.GetLedgerAccountRef()) != "" { - continue - } - line.LedgerAccountRef = account - } - return lines -} - func moneyEquals(a, b moneyGetter) bool { if a == nil || b == nil { return false diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go index 94d5b22d..291602da 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go @@ -2,14 +2,10 @@ package orchestrator import ( "context" - "strings" - "time" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/mservice" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -21,13 +17,6 @@ func (s *Service) ensureRepository(ctx context.Context) error { return s.storage.Ping(ctx) } -func (s *Service) withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { - if d <= 0 { - return context.WithCancel(ctx) - } - return context.WithTimeout(ctx, d) -} - func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) { start := svc.clock.Now() resp, err := gsresponse.Unary(svc.logger, mservice.PaymentOrchestrator, handler)(ctx, req) @@ -35,22 +24,6 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method return resp, err } -func triggerFromKind(kind orchestratorv1.PaymentKind, requiresFX bool) feesv1.Trigger { - switch kind { - case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT: - return feesv1.Trigger_TRIGGER_PAYOUT - case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER: - return feesv1.Trigger_TRIGGER_CAPTURE - case orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION: - return feesv1.Trigger_TRIGGER_FX_CONVERSION - default: - if requiresFX { - return feesv1.Trigger_TRIGGER_FX_CONVERSION - } - return feesv1.Trigger_TRIGGER_UNSPECIFIED - } -} - func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool { if intent == nil { return false @@ -71,43 +44,6 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool { return false } -func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool { - if intent == nil { - return false - } - if fxIntentForQuote(intent) != nil { - return true - } - return intent.GetRequiresFx() -} - -func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIntent { - if intent == nil { - return nil - } - if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil { - return fx - } - amount := intent.GetAmount() - if amount == nil { - return nil - } - settlementCurrency := strings.TrimSpace(intent.GetSettlementCurrency()) - if settlementCurrency == "" { - return nil - } - if strings.EqualFold(amount.GetCurrency(), settlementCurrency) { - return nil - } - return &orchestratorv1.FXIntent{ - Pair: &fxv1.CurrencyPair{ - Base: strings.TrimSpace(amount.GetCurrency()), - Quote: settlementCurrency, - }, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - } -} - func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState { switch status { diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index ccc2833f..3e8db49b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -7,10 +7,10 @@ import ( "time" "github.com/shopspring/decimal" - oracleclient "github.com/tech/sendico/fx/oracle/client" chainclient "github.com/tech/sendico/gateway/chain/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" "github.com/tech/sendico/payments/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" "github.com/tech/sendico/pkg/merrors" @@ -18,6 +18,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "go.uber.org/zap" ) @@ -34,21 +35,25 @@ type ChainGatewayResolver interface { Resolve(ctx context.Context, network string) (chainclient.Client, error) } -type feesDependency struct { - client feesv1.FeeEngineClient - timeout time.Duration +type quotationDependency struct { + client quotationv1.QuotationServiceClient } -func (f feesDependency) available() bool { - if f.client == nil { +func (q quotationDependency) available() bool { + if q.client == nil { return false } - if checker, ok := f.client.(interface{ Available() bool }); ok { + if checker, ok := q.client.(interface{ Available() bool }); ok { return checker.Available() } return true } +type feesDependency struct { + client feesv1.FeeEngineClient + timeout time.Duration +} + type ledgerDependency struct { client ledgerclient.Client internal rail.InternalLedger @@ -200,20 +205,6 @@ func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.P } } -type oracleDependency struct { - client oracleclient.Client -} - -func (o oracleDependency) available() bool { - if o.client == nil { - return false - } - if checker, ok := o.client.(interface{ Available() bool }); ok { - return checker.Available() - } - return true -} - type mntxDependency struct { client mntxclient.Client } @@ -268,6 +259,13 @@ func WithPaymentGatewayBroker(broker mb.Broker) Option { } } +// WithQuotationService wires the quotation gRPC client. +func WithQuotationService(client quotationv1.QuotationServiceClient) Option { + return func(s *Service) { + s.deps.quotation = quotationDependency{client: client} + } +} + // WithLedgerClient wires the ledger client. func WithLedgerClient(client ledgerclient.Client) Option { return func(s *Service) { @@ -332,13 +330,6 @@ func WithRailGateways(gateways map[string]rail.RailGateway) Option { } } -// WithOracleClient wires the FX oracle client. -func WithOracleClient(client oracleclient.Client) Option { - return func(s *Service) { - s.deps.oracle = oracleDependency{client: client} - } -} - // WithMntxGateway wires the Monetix gateway client. func WithMntxGateway(client mntxclient.Client) Option { return func(s *Service) { @@ -377,17 +368,8 @@ func WithFeeLedgerAccounts(routes map[string]string) Option { } } -// WithPlanBuilder wires a payment plan builder implementation. -func WithPlanBuilder(builder PlanBuilder) Option { - return func(s *Service) { - if builder != nil { - s.deps.planBuilder = builder - } - } -} - // WithGatewayRegistry wires a registry of gateway instances for routing. -func WithGatewayRegistry(registry GatewayRegistry) Option { +func WithGatewayRegistry(registry plan_builder.GatewayRegistry) Option { return func(s *Service) { if registry != nil { s.deps.gatewayRegistry = registry @@ -395,9 +377,6 @@ func WithGatewayRegistry(registry GatewayRegistry) Option { s.deps.railGateways.chainResolver = s.deps.gatewayInvokeResolver s.deps.railGateways.providerResolver = s.deps.gatewayInvokeResolver s.deps.railGateways.logger = s.logger.Named("rail_gateways") - if s.deps.planBuilder == nil { - s.deps.planBuilder = newDefaultPlanBuilder(s.logger) - } } } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go index c6e09490..563c67ed 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go @@ -29,86 +29,22 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym if store == nil { return errStorageUnavailable } - if p.svc == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "service_unavailable", errStorageUnavailable) + if payment == nil { + return merrors.InvalidArgument("payment is required") } - if p.svc.storage == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable) + if payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_missing", merrors.InvalidArgument("payment plan is required")) } - routeStore := p.svc.storage.Routes() - if routeStore == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable) + if strings.TrimSpace(payment.PaymentPlan.ID) == "" { + payment.PaymentPlan.ID = payment.PaymentRef } - planTemplates := p.svc.storage.PlanTemplates() - if planTemplates == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "plan_templates_store_unavailable", errStorageUnavailable) + if strings.TrimSpace(payment.PaymentPlan.IdempotencyKey) == "" { + payment.PaymentPlan.IdempotencyKey = payment.IdempotencyKey } - builder := p.svc.deps.planBuilder - if builder == nil { - builder = newDefaultPlanBuilder(p.logger) - } - plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry) - if err != nil { - p.logPlanBuilderFailure(payment, err) - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) - } - if plan == nil || len(plan.Steps) == 0 { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_empty", merrors.InvalidArgument("payment plan is required")) - } - payment.PaymentPlan = plan return p.executePaymentPlan(ctx, store, payment, quote) } -func (p *paymentExecutor) logPlanBuilderFailure(payment *model.Payment, err error) { - if p == nil || payment == nil { - return - } - intent := payment.Intent - sourceRail, sourceNetwork, sourceErr := railFromEndpoint(intent.Source, intent.Attributes, true) - destRail, destNetwork, destErr := railFromEndpoint(intent.Destination, intent.Attributes, false) - - fields := []zap.Field{ - zap.Error(err), - zap.String("payment_ref", payment.PaymentRef), - zap.String("org_ref", payment.OrganizationRef.Hex()), - zap.String("idempotency_key", payment.IdempotencyKey), - zap.String("source_rail", string(sourceRail)), - zap.String("destination_rail", string(destRail)), - zap.String("source_network", sourceNetwork), - zap.String("destination_network", destNetwork), - zap.String("source_endpoint_type", string(intent.Source.Type)), - zap.String("destination_endpoint_type", string(intent.Destination.Type)), - } - - missing := make([]string, 0, 2) - if sourceErr != nil || sourceRail == model.RailUnspecified { - missing = append(missing, "source") - if sourceErr != nil { - fields = append(fields, zap.String("source_rail_error", sourceErr.Error())) - } - } - if destErr != nil || destRail == model.RailUnspecified { - missing = append(missing, "destination") - if destErr != nil { - fields = append(fields, zap.String("destination_rail_error", destErr.Error())) - } - } - if len(missing) > 0 { - fields = append(fields, zap.String("missing_rails", strings.Join(missing, ","))) - p.logger.Warn("Payment rail resolution failed", fields...) - return - } - - routeNetwork, routeErr := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) - if routeErr != nil { - fields = append(fields, zap.String("route_network_error", routeErr.Error())) - } else if routeNetwork != "" { - fields = append(fields, zap.String("route_network", routeNetwork)) - } - p.logger.Warn("Payment route missing for rails", fields...) -} - func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { intent := payment.Intent source := intent.Source.Ledger @@ -169,7 +105,7 @@ func (p *paymentExecutor) failPayment(ctx context.Context, store storage.Payment payment.FailureReason = strings.TrimSpace(reason) if store != nil { if updateErr := store.Update(ctx, payment); updateErr != nil { - p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) + p.logger.Warn("Failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) } } if err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go index f90780ad..2eca3e82 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go @@ -29,14 +29,6 @@ func isPlanComplete(payment *model.Payment) bool { } return false } - -func isStepFinal(step *model.ExecutionStep) bool { - if (step.State == model.OperationStateFailed) || (step.State == model.OperationStateSuccess) || (step.State == model.OperationStateCancelled) { - return true - } - return false -} - func (p *paymentExecutor) pickIndependentSteps( ctx context.Context, l *zap.Logger, diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go deleted file mode 100644 index 1e590775..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go +++ /dev/null @@ -1,215 +0,0 @@ -package orchestrator - -import ( - "fmt" - "strings" - - "github.com/google/uuid" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/model/account_role" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" -) - -func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs { - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - return payment.Execution -} - -func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote { - if quote != nil { - return quote - } - if payment != nil && payment.LastQuote != nil { - return modelQuoteToProto(payment.LastQuote) - } - return &orchestratorv1.PaymentQuote{} -} - -func ensureExecutionPlanForPlan( - payment *model.Payment, - plan *model.PaymentPlan, -) *model.ExecutionPlan { - - if payment.ExecutionPlan != nil { - return payment.ExecutionPlan - } - - exec := &model.ExecutionPlan{ - Steps: make([]*model.ExecutionStep, 0, len(plan.Steps)), - } - - for _, step := range plan.Steps { - if step == nil { - continue - } - - exec.Steps = append(exec.Steps, &model.ExecutionStep{ - Code: step.StepID, - State: model.OperationStatePlanned, - OperationRef: uuid.New().String(), - }) - } - - return exec -} - -func executionPlanComplete(plan *model.ExecutionPlan) bool { - if plan == nil || len(plan.Steps) == 0 { - return false - } - for _, step := range plan.Steps { - if step == nil { - continue - } - if step.State == model.OperationStateSkipped { - continue - } - if step.State != model.OperationStateSuccess { - return false - } - } - return true -} - -func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { - if plan == nil || execPlan == nil || len(plan.Steps) == 0 { - return false - } - execSteps := executionStepsByCode(execPlan) - for idx, step := range plan.Steps { - if step == nil || step.Action != model.RailOperationBlock { - continue - } - execStep := execSteps[planStepID(step, idx)] - if execStep == nil { - continue - } - if execStep.State == model.OperationStateSuccess { - return true - } - } - return false -} - -func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) { - if plan == nil || idx <= 0 { - return nil, nil - } - for i := idx - 1; i >= 0; i-- { - step := plan.Steps[i] - if step == nil { - continue - } - if step.Rail != model.RailLedger || step.Action != model.RailOperationMove { - continue - } - if step.ToRole != nil && strings.TrimSpace(string(*step.ToRole)) != "" { - role := *step.ToRole - return &role, nil - } - } - return nil, nil -} - -func linkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) { - if payment == nil || payment.PaymentPlan == nil { - return - } - ref := strings.TrimSpace(referenceID) - if ref == "" { - return - } - plan := payment.PaymentPlan - execPlan := ensureExecutionPlanForPlan(payment, plan) - if execPlan == nil { - return - } - dep := strings.TrimSpace(dependsOn) - for idx, planStep := range plan.Steps { - if planStep == nil { - continue - } - if planStep.Rail != rail || planStep.Action != model.RailOperationObserveConfirm { - continue - } - if dep != "" { - matched := false - for _, entry := range planStep.DependsOn { - if strings.EqualFold(strings.TrimSpace(entry), dep) { - matched = true - break - } - } - if !matched { - continue - } - } - if idx >= len(execPlan.Steps) { - continue - } - execStep := execPlan.Steps[idx] - if execStep == nil { - execStep = &model.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)} - execPlan.Steps[idx] = execStep - } - if execStep.TransferRef == "" { - execStep.TransferRef = ref - } - } -} - -func planStepID(step *model.PaymentStep, idx int) string { - if step != nil { - if val := strings.TrimSpace(step.StepID); val != "" { - return val - } - } - return fmt.Sprintf("plan_step_%d", idx) -} - -func describePlanStep(step *model.PaymentStep) string { - if step == nil { - return "" - } - return strings.TrimSpace(fmt.Sprintf("%s %s", step.Rail, step.Action)) -} - -func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string { - base := "" - if payment != nil { - base = strings.TrimSpace(payment.IdempotencyKey) - if base == "" { - base = strings.TrimSpace(payment.PaymentRef) - } - } - if base == "" { - base = "payment" - } - if step == nil { - return fmt.Sprintf("%s:plan:%d", base, idx) - } - stepID := strings.TrimSpace(step.StepID) - if stepID == "" { - stepID = fmt.Sprintf("%d", idx) - } - return fmt.Sprintf("%s:plan:%s:%s:%s", base, stepID, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action))) -} - -func failureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode { - if step == nil { - return model.PaymentFailureCodePolicy - } - switch step.Rail { - case model.RailLedger: - if step.Action == model.RailOperationFXConvert { - return model.PaymentFailureCodeFX - } - return model.PaymentFailureCodeLedger - case model.RailCrypto: - return model.PaymentFailureCodeChain - default: - return model.PaymentFailureCodePolicy - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go deleted file mode 100644 index 064f7971..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go +++ /dev/null @@ -1,226 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" -) - -func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep { - result := map[string]*model.ExecutionStep{} - if plan == nil { - return result - } - for _, step := range plan.Steps { - if step == nil { - continue - } - if code := strings.TrimSpace(step.Code); code != "" { - result[code] = step - } - } - return result -} - -func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep { - result := map[string]*model.PaymentStep{} - if plan == nil { - return result - } - for idx, step := range plan.Steps { - if step == nil { - continue - } - id := planStepID(step, idx) - if id == "" { - continue - } - result[id] = step - } - return result -} - -func stepDependenciesReady( - step *model.PaymentStep, - execSteps map[string]*model.ExecutionStep, - planSteps map[string]*model.PaymentStep, - requireSuccess bool, -) (ready bool, waiting bool, blocked bool, err error) { - - if step == nil { - return false, false, false, - merrors.InvalidArgument("payment plan: step is required") - } - - for _, dep := range step.DependsOn { - key := strings.TrimSpace(dep) - if key == "" { - continue - } - - execStep := execSteps[key] - if execStep == nil { - // step has not been started - return false, true, false, nil - } - - if execStep.State == model.OperationStateFailed || - execStep.State == model.OperationStateCancelled { - // dependency dead, step is impossible - return false, false, true, nil - } - - if !execStep.ReadyForNext() { - // step is processed - return false, true, false, nil - } - } - - // ------------------------------------------------------------ - // Commit policies - // ------------------------------------------------------------ - switch step.CommitPolicy { - - case model.CommitPolicyImmediate, model.CommitPolicyUnspecified: - return true, false, false, nil - - case model.CommitPolicyAfterSuccess: - commitAfter := step.CommitAfter - if len(commitAfter) == 0 { - commitAfter = step.DependsOn - } - - for _, dep := range commitAfter { - key := strings.TrimSpace(dep) - if key == "" { - continue - } - - execStep := execSteps[key] - if execStep == nil { - return false, true, false, - merrors.InvalidArgument("commit dependency missing") - } - - if execStep.State == model.OperationStateFailed || - execStep.State == model.OperationStateCancelled { - return false, false, true, nil - } - - if !execStep.IsSuccess() { - return false, true, false, nil - } - } - - return true, false, false, nil - - case model.CommitPolicyAfterFailure: - commitAfter := step.CommitAfter - if len(commitAfter) == 0 { - commitAfter = step.DependsOn - } - - for _, dep := range commitAfter { - key := strings.TrimSpace(dep) - if key == "" { - continue - } - - execStep := execSteps[key] - if execStep == nil { - return false, true, false, - merrors.InvalidArgument("commit dependency missing") - } - - if execStep.State == model.OperationStateFailed { - continue - } - - if execStep.IsTerminal() { - // complete with fail, block - return false, false, true, nil - } - - // still exexuting, wait - return false, true, false, nil - } - - return true, false, false, nil - - case model.CommitPolicyAfterCanceled: - commitAfter := step.CommitAfter - if len(commitAfter) == 0 { - commitAfter = step.DependsOn - } - - for _, dep := range commitAfter { - key := strings.TrimSpace(dep) - if key == "" { - continue - } - - execStep := execSteps[key] - if execStep == nil { - return false, true, false, - merrors.InvalidArgument("commit dependency missing") - } - - if !execStep.IsTerminal() { - return false, true, false, nil - } - } - - return true, false, false, nil - - default: - return true, false, false, nil - } -} - -func cardPayoutDependenciesConfirmed( - plan *model.PaymentPlan, - execPlan *model.ExecutionPlan, -) bool { - - if execPlan == nil { - return false - } - - if plan == nil || len(plan.Steps) == 0 { - return sourceStepsConfirmed(execPlan) - } - - execSteps := executionStepsByCode(execPlan) - planSteps := planStepsByID(plan) - - for _, step := range plan.Steps { - if step == nil { - continue - } - - if step.Rail != model.RailCardPayout || - step.Action != model.RailOperationSend { - continue - } - - ready, waiting, blocked, err := - stepDependenciesReady(step, execSteps, planSteps, true) - - if err != nil || blocked { - // payout definitely cannot run - return false - } - - if waiting { - // dependencies exist but are not finished yet - // payout must NOT run - return false - } - - // only true when dependencies are REALLY satisfied - return ready - } - - return false -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go index 37272a3e..1198afe7 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go @@ -2,8 +2,6 @@ package orchestrator import ( "context" - "fmt" - "math/big" "strings" "github.com/tech/sendico/payments/storage/model" @@ -141,23 +139,6 @@ func (p *paymentExecutor) executePlanStep( } } -func sub(a, b string) (string, error) { - ra, ok := new(big.Rat).SetString(a) - if !ok { - return "", fmt.Errorf("invalid number: %s", a) - } - - rb, ok := new(big.Rat).SetString(b) - if !ok { - return "", fmt.Errorf("invalid number: %s", b) - } - - ra.Sub(ra, rb) - - // 2 знака после запятой (как у тебя) - return ra.FloatString(2), nil -} - func (p *paymentExecutor) executeSendStep( ctx context.Context, payment *model.Payment, diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go new file mode 100644 index 00000000..a5b6eda1 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go @@ -0,0 +1,109 @@ +package orchestrator + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func attachStoredPlan(payment *model.Payment, plan *model.PaymentPlan, idempotencyKey string) { + if payment == nil || plan == nil { + return + } + cloned := cloneStoredPaymentPlan(plan) + if cloned == nil { + return + } + if strings.TrimSpace(cloned.ID) == "" { + cloned.ID = strings.TrimSpace(payment.PaymentRef) + } + if strings.TrimSpace(cloned.IdempotencyKey) == "" { + cloned.IdempotencyKey = strings.TrimSpace(idempotencyKey) + } + payment.PaymentPlan = cloned +} + +func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan { + if src == nil { + return nil + } + clone := &model.PaymentPlan{ + ID: strings.TrimSpace(src.ID), + IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), + CreatedAt: src.CreatedAt, + FXQuote: cloneStoredFXQuote(src.FXQuote), + Fees: cloneStoredFeeLines(src.Fees), + } + if len(src.Steps) > 0 { + clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps)) + for _, step := range src.Steps { + if step == nil { + clone.Steps = append(clone.Steps, nil) + continue + } + stepClone := &model.PaymentStep{ + StepID: strings.TrimSpace(step.StepID), + Rail: step.Rail, + GatewayID: strings.TrimSpace(step.GatewayID), + InstanceID: strings.TrimSpace(step.InstanceID), + Action: step.Action, + DependsOn: cloneStringList(step.DependsOn), + CommitPolicy: step.CommitPolicy, + CommitAfter: cloneStringList(step.CommitAfter), + Amount: cloneMoney(step.Amount), + FromRole: cloneAccountRole(step.FromRole), + ToRole: cloneAccountRole(step.ToRole), + } + clone.Steps = append(clone.Steps, stepClone) + } + } + return clone +} + +func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote { + if src == nil { + return nil + } + result := &paymenttypes.FXQuote{ + QuoteRef: strings.TrimSpace(src.QuoteRef), + Side: src.Side, + ExpiresAtUnixMs: src.ExpiresAtUnixMs, + Provider: strings.TrimSpace(src.Provider), + RateRef: strings.TrimSpace(src.RateRef), + Firm: src.Firm, + BaseAmount: cloneMoney(src.BaseAmount), + QuoteAmount: cloneMoney(src.QuoteAmount), + } + if src.Pair != nil { + result.Pair = &paymenttypes.CurrencyPair{ + Base: strings.TrimSpace(src.Pair.Base), + Quote: strings.TrimSpace(src.Pair.Quote), + } + } + if src.Price != nil { + result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)} + } + return result +} + +func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine { + if len(lines) == 0 { + return nil + } + result := make([]*paymenttypes.FeeLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + result = append(result, nil) + continue + } + result = append(result, &paymenttypes.FeeLine{ + LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), + Money: cloneMoney(line.Money), + LineType: line.LineType, + Side: line.Side, + Meta: cloneMetadata(line.Meta), + }) + } + return result +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go index 40b8414b..1691878b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go @@ -1,28 +1,7 @@ package orchestrator -import ( - "context" +import "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" - "github.com/tech/sendico/payments/storage/model" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" -) - -// RouteStore exposes routing definitions for plan construction. -type RouteStore interface { - List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) -} - -// 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) -} - -// PlanBuilder constructs ordered payment plans from intents, quotes, and routing policy. -type PlanBuilder interface { - Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) -} +// GatewayRegistry re-exports the plan_builder.GatewayRegistry interface for use +// within the orchestrator package (gateway_registry.go, options.go, etc.). +type GatewayRegistry = plan_builder.GatewayRegistry diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_batch.go b/api/payments/orchestrator/internal/service/orchestrator/quote_batch.go deleted file mode 100644 index 33b265e8..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_batch.go +++ /dev/null @@ -1,145 +0,0 @@ -package orchestrator - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" -) - -func perIntentIdempotencyKey(base string, index int, total int) string { - base = strings.TrimSpace(base) - if base == "" { - return "" - } - if total <= 1 { - return base - } - return fmt.Sprintf("%s:%d", base, index+1) -} - -func minQuoteExpiry(expires []time.Time) (time.Time, bool) { - var min time.Time - for _, exp := range expires { - if exp.IsZero() { - continue - } - if min.IsZero() || exp.Before(min) { - min = exp - } - } - if min.IsZero() { - return time.Time{}, false - } - return min, true -} - -func aggregatePaymentQuotes(quotes []*orchestratorv1.PaymentQuote) (*orchestratorv1.PaymentQuoteAggregate, error) { - if len(quotes) == 0 { - return nil, nil - } - debitTotals := map[string]decimal.Decimal{} - settlementTotals := map[string]decimal.Decimal{} - feeTotals := map[string]decimal.Decimal{} - networkTotals := map[string]decimal.Decimal{} - - for _, quote := range quotes { - if quote == nil { - continue - } - if err := accumulateMoney(debitTotals, quote.GetDebitAmount()); err != nil { - return nil, err - } - if err := accumulateMoney(settlementTotals, quote.GetExpectedSettlementAmount()); err != nil { - return nil, err - } - if err := accumulateMoney(feeTotals, quote.GetExpectedFeeTotal()); err != nil { - return nil, err - } - if nf := quote.GetNetworkFee(); nf != nil { - if err := accumulateMoney(networkTotals, nf.GetNetworkFee()); err != nil { - return nil, err - } - } - } - - return &orchestratorv1.PaymentQuoteAggregate{ - DebitAmounts: totalsToMoney(debitTotals), - ExpectedSettlementAmounts: totalsToMoney(settlementTotals), - ExpectedFeeTotals: totalsToMoney(feeTotals), - NetworkFeeTotals: totalsToMoney(networkTotals), - }, nil -} - -func accumulateMoney(totals map[string]decimal.Decimal, money *moneyv1.Money) error { - if money == nil { - return nil - } - currency := strings.TrimSpace(money.GetCurrency()) - if currency == "" { - return nil - } - amount, err := decimal.NewFromString(money.GetAmount()) - if err != nil { - return err - } - if current, ok := totals[currency]; ok { - totals[currency] = current.Add(amount) - return nil - } - totals[currency] = amount - return nil -} - -func totalsToMoney(totals map[string]decimal.Decimal) []*moneyv1.Money { - if len(totals) == 0 { - return nil - } - currencies := make([]string, 0, len(totals)) - for currency := range totals { - currencies = append(currencies, currency) - } - sort.Strings(currencies) - - result := make([]*moneyv1.Money, 0, len(currencies)) - for _, currency := range currencies { - amount := totals[currency] - result = append(result, &moneyv1.Money{ - Amount: amount.String(), - Currency: currency, - }) - } - return result -} - -func intentsFromProto(intents []*orchestratorv1.PaymentIntent) []model.PaymentIntent { - if len(intents) == 0 { - return nil - } - result := make([]model.PaymentIntent, 0, len(intents)) - for _, intent := range intents { - result = append(result, intentFromProto(intent)) - } - return result -} - -func quoteSnapshotsFromProto(quotes []*orchestratorv1.PaymentQuote) []*model.PaymentQuoteSnapshot { - if len(quotes) == 0 { - return nil - } - result := make([]*model.PaymentQuoteSnapshot, 0, len(quotes)) - for _, quote := range quotes { - if quote == nil { - continue - } - if snapshot := quoteSnapshotToModel(quote); snapshot != nil { - result = append(result, snapshot) - } - } - return result -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_batch_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_batch_test.go deleted file mode 100644 index a64cc430..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_batch_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package orchestrator - -import ( - "testing" - "time" - - "github.com/shopspring/decimal" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" -) - -func TestAggregatePaymentQuotes(t *testing.T) { - quotes := []*orchestratorv1.PaymentQuote{ - { - DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"}, - ExpectedSettlementAmount: &moneyv1.Money{Amount: "8", Currency: "EUR"}, - ExpectedFeeTotal: &moneyv1.Money{Amount: "1", Currency: "USD"}, - NetworkFee: &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Amount: "0.5", Currency: "USD"}, - }, - }, - { - DebitAmount: &moneyv1.Money{Amount: "5", Currency: "USD"}, - ExpectedSettlementAmount: &moneyv1.Money{Amount: "1000", Currency: "NGN"}, - ExpectedFeeTotal: &moneyv1.Money{Amount: "2", Currency: "USD"}, - NetworkFee: &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Amount: "100", Currency: "NGN"}, - }, - }, - } - - agg, err := aggregatePaymentQuotes(quotes) - if err != nil { - t.Fatalf("aggregatePaymentQuotes returned error: %v", err) - } - - assertMoneyTotals(t, agg.GetDebitAmounts(), map[string]string{"USD": "15"}) - assertMoneyTotals(t, agg.GetExpectedSettlementAmounts(), map[string]string{"EUR": "8", "NGN": "1000"}) - assertMoneyTotals(t, agg.GetExpectedFeeTotals(), map[string]string{"USD": "3"}) - assertMoneyTotals(t, agg.GetNetworkFeeTotals(), map[string]string{"USD": "0.5", "NGN": "100"}) -} - -func TestAggregatePaymentQuotesInvalidAmount(t *testing.T) { - quotes := []*orchestratorv1.PaymentQuote{ - { - DebitAmount: &moneyv1.Money{Amount: "bad", Currency: "USD"}, - }, - } - if _, err := aggregatePaymentQuotes(quotes); err == nil { - t.Fatal("expected error for invalid amount") - } -} - -func TestMinQuoteExpiry(t *testing.T) { - now := time.Now().UTC() - later := now.Add(10 * time.Minute) - earliest := now.Add(5 * time.Minute) - - min, ok := minQuoteExpiry([]time.Time{later, {}, earliest}) - if !ok { - t.Fatal("expected min expiry to be set") - } - if !min.Equal(earliest) { - t.Fatalf("expected min expiry %v, got %v", earliest, min) - } - - if _, ok := minQuoteExpiry([]time.Time{{}}); ok { - t.Fatal("expected min expiry to be unset") - } -} - -func assertMoneyTotals(t *testing.T, list []*moneyv1.Money, expected map[string]string) { - t.Helper() - got := make(map[string]decimal.Decimal, len(list)) - for _, item := range list { - if item == nil { - continue - } - val, err := decimal.NewFromString(item.GetAmount()) - if err != nil { - t.Fatalf("invalid money amount %q: %v", item.GetAmount(), err) - } - got[item.GetCurrency()] = val - } - if len(got) != len(expected) { - t.Fatalf("expected %d totals, got %d", len(expected), len(got)) - } - for currency, amount := range expected { - val, err := decimal.NewFromString(amount) - if err != nil { - t.Fatalf("invalid expected amount %q: %v", amount, err) - } - gotVal, ok := got[currency] - if !ok { - t.Fatalf("missing currency %s", currency) - } - if !gotVal.Equal(val) { - t.Fatalf("expected %s %s, got %s", amount, currency, gotVal.String()) - } - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go deleted file mode 100644 index e8d0cca2..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go +++ /dev/null @@ -1,579 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - oracleclient "github.com/tech/sendico/fx/oracle/client" - "github.com/tech/sendico/payments/storage/model" - chainpkg "github.com/tech/sendico/pkg/chain" - "github.com/tech/sendico/pkg/merrors" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" - "google.golang.org/protobuf/types/known/timestamppb" -) - -func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) { - intent := req.GetIntent() - amount := intent.GetAmount() - fxSide := fxv1.Side_SIDE_UNSPECIFIED - if fxIntent := fxIntentForQuote(intent); fxIntent != nil { - fxSide = fxIntent.GetSide() - } - - var fxQuote *oraclev1.Quote - var err error - if shouldRequestFX(intent) { - fxQuote, err = s.requestFXQuote(ctx, orgRef, req) - if err != nil { - return nil, time.Time{}, err - } - s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef)) - } - - payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide) - - feeBaseAmount := payAmount - if feeBaseAmount == nil { - feeBaseAmount = cloneProtoMoney(amount) - } - - intentModel := intentFromProto(intent) - sourceRail, _, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true) - if err != nil { - return nil, time.Time{}, err - } - destRail, _, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false) - if err != nil { - return nil, time.Time{}, err - } - feeRequired := feesRequiredForRails(sourceRail, destRail) - feeQuote := &feesv1.PrecomputeFeesResponse{} - if feeRequired { - feeQuote, err = s.quoteFees(ctx, orgRef, req, feeBaseAmount) - if err != nil { - return nil, time.Time{}, err - } - } - conversionFeeQuote := &feesv1.PrecomputeFeesResponse{} - if s.shouldQuoteConversionFee(ctx, req.GetIntent()) { - conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount) - if err != nil { - return nil, time.Time{}, err - } - } - feeCurrency := "" - if feeBaseAmount != nil { - feeCurrency = feeBaseAmount.GetCurrency() - } else if amount != nil { - feeCurrency = amount.GetCurrency() - } - feeLines := cloneFeeLines(feeQuote.GetLines()) - if conversionFeeQuote != nil { - feeLines = append(feeLines, cloneFeeLines(conversionFeeQuote.GetLines())...) - } - s.assignFeeLedgerAccounts(intent, feeLines) - feeTotal := extractFeeTotal(feeLines, feeCurrency) - - var networkFee *chainv1.EstimateTransferFeeResponse - if shouldEstimateNetworkFee(intent) { - networkFee, err = s.estimateNetworkFee(ctx, intent) - if err != nil { - return nil, time.Time{}, err - } - s.logger.Debug("Network fee estimated", zap.String("org_ref", orgRef)) - } - - debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode()) - - quote := &orchestratorv1.PaymentQuote{ - DebitAmount: debitAmount, - DebitSettlementAmount: payAmount, - ExpectedSettlementAmount: settlementAmount, - ExpectedFeeTotal: feeTotal, - FeeLines: feeLines, - FeeRules: mergeFeeRules(feeQuote, conversionFeeQuote), - FxQuote: fxQuote, - NetworkFee: networkFee, - } - - expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote) - if conversionFeeQuote != nil { - convExpiry := quoteExpiry(s.clock.Now(), conversionFeeQuote, fxQuote) - if convExpiry.Before(expiresAt) { - expiresAt = convExpiry - } - } - - return quote, expiresAt, nil -} - -func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { - if !s.deps.fees.available() { - return &feesv1.PrecomputeFeesResponse{}, nil - } - intent := req.GetIntent() - amount := cloneProtoMoney(baseAmount) - if amount == nil { - amount = cloneProtoMoney(intent.GetAmount()) - } - attrs := ensureFeeAttributes(intent, amount, cloneMetadata(intent.GetAttributes())) - feeIntent := &feesv1.Intent{ - Trigger: feeTriggerForIntent(intent), - BaseAmount: amount, - BookedAt: timestamppb.New(s.clock.Now()), - OriginType: "payments.orchestrator.quote", - OriginRef: strings.TrimSpace(req.GetIdempotencyKey()), - Attributes: attrs, - } - timeout := req.GetMeta().GetTrace() - ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout) - defer cancel() - resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{ - Meta: &feesv1.RequestMeta{ - OrganizationRef: orgRef, - Trace: timeout, - }, - Intent: feeIntent, - TtlMs: defaultFeeQuoteTTLMillis, - }) - if err != nil { - s.logger.Warn("Fees precompute failed", zap.Error(err)) - return nil, merrors.Internal("fees_precompute_failed") - } - return resp, nil -} - -func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { - if !s.deps.fees.available() { - return &feesv1.PrecomputeFeesResponse{}, nil - } - intent := req.GetIntent() - amount := cloneProtoMoney(baseAmount) - if amount == nil { - amount = cloneProtoMoney(intent.GetAmount()) - } - attrs := ensureFeeAttributes(intent, amount, cloneMetadata(intent.GetAttributes())) - attrs["product"] = "wallet" - attrs["source_type"] = "managed_wallet" - attrs["destination_type"] = "ledger" - - feeIntent := &feesv1.Intent{ - Trigger: feesv1.Trigger_TRIGGER_CAPTURE, - BaseAmount: amount, - BookedAt: timestamppb.New(s.clock.Now()), - OriginType: "payments.orchestrator.conversion_quote", - OriginRef: strings.TrimSpace(req.GetIdempotencyKey()), - Attributes: attrs, - } - timeout := req.GetMeta().GetTrace() - ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout) - defer cancel() - resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{ - Meta: &feesv1.RequestMeta{ - OrganizationRef: orgRef, - Trace: timeout, - }, - Intent: feeIntent, - TtlMs: defaultFeeQuoteTTLMillis, - }) - if err != nil { - s.logger.Warn("Conversion fee precompute failed", zap.Error(err)) - return nil, merrors.Internal("fees_precompute_failed") - } - setFeeLineTarget(resp.GetLines(), feeLineTargetWallet) - if src := intent.GetSource().GetManagedWallet(); src != nil { - setFeeLineWalletRef(resp.GetLines(), src.GetManagedWalletRef(), "managed_wallet") - } - return resp, nil -} - -func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) bool { - if intent == nil { - return false - } - if !isManagedWalletEndpoint(intent.GetSource()) { - return false - } - 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 -} - -func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule { - rules := cloneFeeRules(nil) - if primary != nil { - rules = append(rules, cloneFeeRules(primary.GetApplied())...) - } - if secondary != nil { - rules = append(rules, cloneFeeRules(secondary.GetApplied())...) - } - if len(rules) == 0 { - return nil - } - return rules -} - -func ensureFeeAttributes(intent *orchestratorv1.PaymentIntent, baseAmount *moneyv1.Money, attrs map[string]string) map[string]string { - if attrs == nil { - attrs = map[string]string{} - } - if intent == nil { - return attrs - } - setFeeAttributeIfMissing(attrs, "product", "wallet") - if op := feeOperationFromKind(intent.GetKind()); op != "" { - setFeeAttributeIfMissing(attrs, "operation", op) - } - if currency := feeCurrencyFromAmount(baseAmount, intent.GetAmount()); currency != "" { - setFeeAttributeIfMissing(attrs, "currency", currency) - } - if srcType := endpointTypeFromProto(intent.GetSource()); srcType != "" { - setFeeAttributeIfMissing(attrs, "source_type", srcType) - } - if dstType := endpointTypeFromProto(intent.GetDestination()); dstType != "" { - setFeeAttributeIfMissing(attrs, "destination_type", dstType) - } - if asset := assetFromIntent(intent); asset != nil { - if token := strings.TrimSpace(asset.GetTokenSymbol()); token != "" { - setFeeAttributeIfMissing(attrs, "asset", token) - } - if chain := asset.GetChain(); chain != chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED { - if network := strings.TrimSpace(chainpkg.NetworkAlias(chain)); network != "" { - setFeeAttributeIfMissing(attrs, "network", network) - } - } - } - return attrs -} - -func feeTriggerForIntent(intent *orchestratorv1.PaymentIntent) feesv1.Trigger { - if intent == nil { - return feesv1.Trigger_TRIGGER_UNSPECIFIED - } - trigger := triggerFromKind(intent.GetKind(), intent.GetRequiresFx()) - if trigger != feesv1.Trigger_TRIGGER_FX_CONVERSION && isManagedWalletEndpoint(intent.GetSource()) && isLedgerEndpoint(intent.GetDestination()) { - return feesv1.Trigger_TRIGGER_CAPTURE - } - return trigger -} - -func isManagedWalletEndpoint(endpoint *orchestratorv1.PaymentEndpoint) bool { - return endpoint != nil && endpoint.GetManagedWallet() != nil -} - -func isLedgerEndpoint(endpoint *orchestratorv1.PaymentEndpoint) bool { - return endpoint != nil && endpoint.GetLedger() != nil -} - -func setFeeAttributeIfMissing(attrs map[string]string, key, value string) { - if attrs == nil { - return - } - if strings.TrimSpace(key) == "" { - return - } - value = strings.TrimSpace(value) - if value == "" { - return - } - if _, exists := attrs[key]; exists { - return - } - attrs[key] = value -} - -func feeOperationFromKind(kind orchestratorv1.PaymentKind) string { - switch kind { - case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT: - return "payout" - case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER: - return "internal_transfer" - case orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION: - return "fx_conversion" - default: - return "" - } -} - -func feeCurrencyFromAmount(baseAmount, intentAmount *moneyv1.Money) string { - if baseAmount != nil { - if currency := strings.TrimSpace(baseAmount.GetCurrency()); currency != "" { - return currency - } - } - if intentAmount != nil { - return strings.TrimSpace(intentAmount.GetCurrency()) - } - return "" -} - -func endpointTypeFromProto(endpoint *orchestratorv1.PaymentEndpoint) string { - if endpoint == nil { - return "" - } - switch { - case endpoint.GetLedger() != nil: - return "ledger" - case endpoint.GetManagedWallet() != nil: - return "managed_wallet" - case endpoint.GetExternalChain() != nil: - return "external_chain" - case endpoint.GetCard() != nil: - return "card" - default: - return "" - } -} - -func assetFromIntent(intent *orchestratorv1.PaymentIntent) *chainv1.Asset { - if intent == nil { - return nil - } - if asset := assetFromEndpoint(intent.GetDestination()); asset != nil { - return asset - } - return assetFromEndpoint(intent.GetSource()) -} - -func assetFromEndpoint(endpoint *orchestratorv1.PaymentEndpoint) *chainv1.Asset { - if endpoint == nil { - return nil - } - if wallet := endpoint.GetManagedWallet(); wallet != nil { - return wallet.GetAsset() - } - if external := endpoint.GetExternalChain(); external != nil { - return external.GetAsset() - } - return nil -} - -func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) { - req := &chainv1.EstimateTransferFeeRequest{ - Amount: cloneProtoMoney(intent.GetAmount()), - } - if src := intent.GetSource().GetManagedWallet(); src != nil { - req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef()) - } - if dst := intent.GetDestination().GetManagedWallet(); dst != nil { - req.Destination = &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())}, - } - } - if dst := intent.GetDestination().GetExternalChain(); dst != nil { - req.Destination = &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())}, - Memo: strings.TrimSpace(dst.GetMemo()), - } - req.Asset = dst.GetAsset() - } - if req.Asset == nil { - if src := intent.GetSource().GetManagedWallet(); src != nil { - req.Asset = src.GetAsset() - } - } - - network := "" - if req.Asset != nil { - network = chainpkg.NetworkName(req.Asset.GetChain()) - } - instanceID := strings.TrimSpace(intent.GetSource().GetInstanceId()) - if instanceID == "" { - instanceID = strings.TrimSpace(intent.GetDestination().GetInstanceId()) - } - client, _, err := s.resolveChainGatewayClient(ctx, network, moneyFromProto(req.Amount), []model.RailOperation{model.RailOperationSend}, instanceID, "") - if err != nil { - if errors.Is(err, merrors.ErrNoData) { - s.logger.Debug("Network fee estimation skipped: gateway unavailable", zap.Error(err)) - return nil, nil - } - s.logger.Warn("Chain gateway resolution failed", zap.Error(err)) - return nil, err - } - if client == nil { - return nil, nil - } - - resp, err := client.EstimateTransferFee(ctx, req) - if err != nil { - s.logger.Warn("chain gateway fee estimation failed", zap.Error(err)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - return resp, nil -} - -func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) { - if !s.deps.oracle.available() { - if req.GetIntent().GetRequiresFx() { - return nil, merrors.Internal("fx_oracle_unavailable") - } - return nil, nil - } - intent := req.GetIntent() - meta := req.GetMeta() - fxIntent := fxIntentForQuote(intent) - if fxIntent == nil { - if intent.GetRequiresFx() { - return nil, merrors.InvalidArgument("fx intent missing") - } - return nil, nil - } - - ttl := fxIntent.GetTtlMs() - if ttl <= 0 { - ttl = defaultOracleTTLMillis - } - - params := oracleclient.GetQuoteParams{ - Meta: oracleclient.RequestMeta{ - OrganizationRef: orgRef, - Trace: meta.GetTrace(), - }, - Pair: fxIntent.GetPair(), - Side: fxIntent.GetSide(), - Firm: fxIntent.GetFirm(), - TTL: time.Duration(ttl) * time.Millisecond, - PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()), - } - - if fxIntent.GetMaxAgeMs() > 0 { - params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond - } - - if amount := intent.GetAmount(); amount != nil { - pair := fxIntent.GetPair() - if pair != nil { - switch { - case strings.EqualFold(amount.GetCurrency(), pair.GetBase()): - params.BaseAmount = cloneProtoMoney(amount) - case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()): - params.QuoteAmount = cloneProtoMoney(amount) - default: - params.BaseAmount = cloneProtoMoney(amount) - } - } else { - params.BaseAmount = cloneProtoMoney(amount) - } - } - - quote, err := s.deps.oracle.client.GetQuote(ctx, params) - if err != nil { - s.logger.Warn("fx oracle quote failed", zap.Error(err)) - return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error())) - } - if quote == nil { - if intent.GetRequiresFx() { - return nil, merrors.Internal("orchestrator: fx quote missing") - } - return nil, nil - } - return quoteToProto(quote), nil -} - -func feesRequiredForRails(sourceRail, destRail model.Rail) bool { - if sourceRail == model.RailLedger && destRail == model.RailLedger { - return false - } - return true -} - -func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string { - if intent == nil || len(s.deps.feeLedgerAccounts) == 0 { - return "" - } - - key := s.gatewayKeyFromIntent(intent) - if key == "" { - return "" - } - return strings.TrimSpace(s.deps.feeLedgerAccounts[key]) -} - -func (s *Service) assignFeeLedgerAccounts(intent *orchestratorv1.PaymentIntent, lines []*feesv1.DerivedPostingLine) { - account := s.feeLedgerAccountForIntent(intent) - key := s.gatewayKeyFromIntent(intent) - - missing := 0 - for _, line := range lines { - if line == nil { - continue - } - if strings.TrimSpace(line.GetLedgerAccountRef()) == "" { - missing++ - } - } - if missing == 0 { - return - } - - if account == "" { - s.logger.Debug("No fee ledger account mapping found", zap.String("gateway", key), zap.Int("missing_lines", missing)) - return - } - assignLedgerAccounts(lines, account) - s.logger.Debug("Applied fee ledger account mapping", zap.String("gateway", key), zap.String("ledger_account", account), zap.Int("lines", missing)) -} - -func (s *Service) gatewayKeyFromIntent(intent *orchestratorv1.PaymentIntent) string { - if intent == nil { - return "" - } - key := strings.TrimSpace(intent.GetAttributes()["gateway"]) - if key == "" { - if dest := intent.GetDestination(); dest != nil && dest.GetCard() != nil { - key = defaultCardGateway - } - } - return strings.ToLower(key) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go deleted file mode 100644 index 068307fc..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - "time" - - oracleclient "github.com/tech/sendico/fx/oracle/client" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" - "google.golang.org/grpc" -) - -type feeEngineFake struct { - precomputeCalls int -} - -func (f *feeEngineFake) QuoteFees(ctx context.Context, in *feesv1.QuoteFeesRequest, opts ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) { - return &feesv1.QuoteFeesResponse{}, nil -} - -func (f *feeEngineFake) PrecomputeFees(ctx context.Context, in *feesv1.PrecomputeFeesRequest, opts ...grpc.CallOption) (*feesv1.PrecomputeFeesResponse, error) { - f.precomputeCalls++ - return &feesv1.PrecomputeFeesResponse{}, nil -} - -func (f *feeEngineFake) ValidateFeeToken(ctx context.Context, in *feesv1.ValidateFeeTokenRequest, opts ...grpc.CallOption) (*feesv1.ValidateFeeTokenResponse, error) { - return &feesv1.ValidateFeeTokenResponse{}, nil -} - -func TestBuildPaymentQuote_RequestsFXWhenSettlementDiffers(t *testing.T) { - ctx := context.Background() - calls := 0 - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - deps: serviceDependencies{ - oracle: oracleDependency{ - client: &oracleclient.Fake{ - GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { - calls++ - return &oracleclient.Quote{ - QuoteRef: "q1", - Pair: params.Pair, - Side: params.Side, - Price: "1.1", - BaseAmount: params.BaseAmount, - QuoteAmount: params.QuoteAmount, - ExpiresAt: time.Now().Add(time.Minute), - }, nil - }, - }, - }, - }, - } - - req := &orchestratorv1.QuotePaymentRequest{ - Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, - Intent: &orchestratorv1.PaymentIntent{ - Ref: "ref-1", - Source: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, - }, - Destination: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, - }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, - SettlementCurrency: "EUR", - }, - } - - if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { - t.Fatalf("buildPaymentQuote returned error: %v", err) - } - if calls != 1 { - t.Fatalf("expected 1 fx quote call, got %d", calls) - } -} - -func TestBuildPaymentQuote_FeesRequestedForExternalRails(t *testing.T) { - ctx := context.Background() - feeFake := &feeEngineFake{} - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - deps: serviceDependencies{ - fees: feesDependency{client: feeFake}, - }, - } - - req := &orchestratorv1.QuotePaymentRequest{ - Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, - Intent: &orchestratorv1.PaymentIntent{ - Ref: "ref-1", - Source: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, - }, - Destination: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Card{Card: &orchestratorv1.CardEndpoint{}}, - }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, - SettlementCurrency: "USD", - }, - } - - if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { - t.Fatalf("buildPaymentQuote returned error: %v", err) - } - if feeFake.precomputeCalls != 1 { - t.Fatalf("expected 1 fee precompute call, got %d", feeFake.precomputeCalls) - } -} - -func TestBuildPaymentQuote_FeesSkippedForLedgerTransfer(t *testing.T) { - ctx := context.Background() - feeFake := &feeEngineFake{} - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - deps: serviceDependencies{ - fees: feesDependency{client: feeFake}, - }, - } - - req := &orchestratorv1.QuotePaymentRequest{ - Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, - Intent: &orchestratorv1.PaymentIntent{ - Ref: "ref-1", - Source: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, - }, - Destination: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, - }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, - SettlementCurrency: "USD", - }, - } - - if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { - t.Fatalf("buildPaymentQuote returned error: %v", err) - } - if feeFake.precomputeCalls != 0 { - t.Fatalf("expected fee precompute to be skipped, got %d", feeFake.precomputeCalls) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_payment_idempotency_integration_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_payment_idempotency_integration_test.go deleted file mode 100644 index 534e4bfc..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_payment_idempotency_integration_test.go +++ /dev/null @@ -1,137 +0,0 @@ -//go:build integration -// +build integration - -package orchestrator - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/tech/sendico/payments/storage/model" - storagemongo "github.com/tech/sendico/payments/storage/mongo" - "github.com/tech/sendico/pkg/db/repository" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/mongodb" - "github.com/testcontainers/testcontainers-go/wait" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" - "go.mongodb.org/mongo-driver/v2/mongo/readpref" - "go.uber.org/zap" -) - -func terminateMongo(ctx context.Context, t *testing.T, container *mongodb.MongoDBContainer) { - t.Helper() - if err := container.Terminate(ctx); err != nil { - t.Fatalf("failed to terminate MongoDB container: %v", err) - } -} - -func disconnectMongo(ctx context.Context, t *testing.T, client *mongo.Client) { - t.Helper() - if err := client.Disconnect(ctx); err != nil { - t.Fatalf("failed to disconnect from MongoDB: %v", err) - } -} - -func TestQuotePayment_IdempotencyReuseAfterExpiry(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - mongoContainer, err := mongodb.Run(ctx, - "mongo:latest", - mongodb.WithUsername("root"), - mongodb.WithPassword("password"), - testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), - ) - if err != nil { - t.Fatalf("failed to start MongoDB container: %v", err) - } - defer terminateMongo(ctx, t, mongoContainer) - - mongoURI, err := mongoContainer.ConnectionString(ctx) - if err != nil { - t.Fatalf("failed to get MongoDB connection string: %v", err) - } - - client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI)) - if err != nil { - t.Fatalf("failed to connect to MongoDB: %v", err) - } - defer disconnectMongo(ctx, t, client) - - db := client.Database("test_" + strings.ReplaceAll(t.Name(), "/", "_")) - paymentsRepo := repository.CreateMongoRepository(db, (&model.Payment{}).Collection()) - quotesRepo := repository.CreateMongoRepository(db, (&model.PaymentQuoteRecord{}).Collection()) - routesRepo := repository.CreateMongoRepository(db, (&model.PaymentRoute{}).Collection()) - plansRepo := repository.CreateMongoRepository(db, (&model.PaymentPlanTemplate{}).Collection()) - - ping := func(ctx context.Context) error { return client.Ping(ctx, readpref.Primary()) } - store, err := storagemongo.NewWithRepository(zap.NewNop(), ping, paymentsRepo, quotesRepo, routesRepo, plansRepo) - if err != nil { - t.Fatalf("failed to create payments repository: %v", err) - } - - now := time.Now() - svc := NewService(zap.NewNop(), store, WithClock(testClock{now: now})) - - orgID := bson.NewObjectID() - req := &orchestratorv1.QuotePaymentRequest{ - Meta: &orchestratorv1.RequestMeta{OrganizationRef: orgID.Hex()}, - IdempotencyKey: "idem-expired-quote", - Ref: "ref-expired", - Intent: &orchestratorv1.PaymentIntent{ - Ref: "intent-1", - Source: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{ - Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}, - }, - }, - Destination: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{ - Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, - }, - }, - Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"}, - SettlementCurrency: "USDT", - }, - } - - resp1, err := svc.QuotePayment(ctx, req) - if err != nil { - t.Fatalf("first quote returned error: %v", err) - } - firstRef := resp1.GetQuote().GetQuoteRef() - if firstRef == "" { - t.Fatal("expected first quote ref to be populated") - } - - quotesColl := db.Collection((&model.PaymentQuoteRecord{}).Collection()) - update := bson.M{"$set": bson.M{"expiresAt": time.Now().Add(-time.Minute)}} - result, err := quotesColl.UpdateOne(ctx, bson.M{ - "organizationRef": orgID, - "idempotencyKey": req.GetIdempotencyKey(), - }, update) - if err != nil { - t.Fatalf("failed to expire quote: %v", err) - } - if result.MatchedCount == 0 { - t.Fatal("expected expired quote to be updated") - } - - resp2, err := svc.QuotePayment(ctx, req) - if err != nil { - t.Fatalf("second quote returned error: %v", err) - } - secondRef := resp2.GetQuote().GetQuoteRef() - if secondRef == "" { - t.Fatal("expected second quote ref to be populated") - } - if secondRef == firstRef { - t.Fatal("expected a new quote to be generated after expiry") - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go deleted file mode 100644 index 0fe4261f..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - oracleclient "github.com/tech/sendico/fx/oracle/client" - "github.com/tech/sendico/pkg/merrors" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" -) - -func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) { - ctx := context.Background() - var captured oracleclient.GetQuoteParams - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - deps: serviceDependencies{ - oracle: oracleDependency{ - client: &oracleclient.Fake{ - GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { - captured = params - return &oracleclient.Quote{ - QuoteRef: "q", - Pair: params.Pair, - Side: params.Side, - Price: "1.1", - BaseAmount: params.BaseAmount, - QuoteAmount: params.QuoteAmount, - ExpiresAt: time.Now(), - }, nil - }, - }, - }, - }, - } - - req := &orchestratorv1.QuotePaymentRequest{ - Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, - Intent: &orchestratorv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, - SettlementCurrency: "USD", - Fx: &orchestratorv1.FXIntent{ - Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"}, - Side: fxv1.Side_BUY_BASE_SELL_QUOTE, - }, - }, - } - - if _, err := svc.requestFXQuote(ctx, "org", req); err != nil { - t.Fatalf("requestFXQuote returned error: %v", err) - } - - if captured.QuoteAmount == nil { - t.Fatal("expected quote amount to be populated") - } - if captured.BaseAmount != nil { - t.Fatal("expected base amount to be nil when using quote amount input") - } - if captured.QuoteAmount.GetCurrency() != "USD" { - t.Fatalf("expected quote amount currency USD, got %s", captured.QuoteAmount.GetCurrency()) - } -} - -func TestRequestFXQuoteFailsWhenRequiredAndOracleUnavailable(t *testing.T) { - ctx := context.Background() - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - deps: serviceDependencies{ - oracle: oracleDependency{}, - }, - } - - req := &orchestratorv1.QuotePaymentRequest{ - Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, - Intent: &orchestratorv1.PaymentIntent{ - Ref: "ref-1", - RequiresFx: true, - Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"}, - SettlementCurrency: "RUB", - Fx: &orchestratorv1.FXIntent{ - Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - }, - }, - } - - if _, err := svc.requestFXQuote(ctx, "org", req); err == nil { - t.Fatal("expected error when FX is required and oracle is unavailable") - } else { - if !errors.Is(err, merrors.ErrInternal) { - t.Fatalf("expected internal error, got %v", err) - } - if !strings.Contains(err.Error(), "fx_oracle_unavailable") { - t.Fatalf("expected fx_oracle_unavailable error, got %v", err) - } - } -} - -func TestRequestFXQuoteFailsWhenRequiredAndQuoteMissing(t *testing.T) { - ctx := context.Background() - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - deps: serviceDependencies{ - oracle: oracleDependency{ - client: &oracleclient.Fake{ - GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { - return nil, nil - }, - }, - }, - }, - } - - req := &orchestratorv1.QuotePaymentRequest{ - Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, - Intent: &orchestratorv1.PaymentIntent{ - Ref: "ref-1", - RequiresFx: true, - Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"}, - SettlementCurrency: "RUB", - Fx: &orchestratorv1.FXIntent{ - Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - }, - }, - } - - if _, err := svc.requestFXQuote(ctx, "org", req); err == nil { - t.Fatal("expected error when FX quote is missing") - } else { - if !errors.Is(err, merrors.ErrInternal) { - t.Fatalf("expected internal error, got %v", err) - } - if !strings.Contains(err.Error(), "orchestrator: fx quote missing") { - t.Fatalf("expected 'orchestrator: fx quote missing' error, got %v", err) - } - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go b/api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go new file mode 100644 index 00000000..dfe7e290 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go @@ -0,0 +1,99 @@ +package orchestrator + +import ( + "strings" + "time" + + "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" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { + override := railOverrideFromAttributes(attrs, isSource) + if override != model.RailUnspecified { + return override, networkFromEndpoint(endpoint), nil + } + switch endpoint.Type { + case model.EndpointTypeLedger: + return model.RailLedger, "", nil + case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain: + return model.RailCrypto, networkFromEndpoint(endpoint), nil + case model.EndpointTypeCard: + return model.RailCardPayout, "", nil + default: + return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint") + } +} + +func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail { + if len(attrs) == 0 { + return model.RailUnspecified + } + keys := []string{"source_rail", "sourceRail"} + if !isSource { + keys = []string{"destination_rail", "destinationRail"} + } + lookup := map[string]struct{}{} + for _, key := range keys { + lookup[strings.ToLower(key)] = struct{}{} + } + for key, value := range attrs { + if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok { + continue + } + rail := parseRailValue(value) + if rail != model.RailUnspecified { + return rail + } + } + return model.RailUnspecified +} + +func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole { + if role == nil { + return nil + } + cloned := *role + return &cloned +} + +func resolveFeeAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *paymenttypes.Money { + if quote != nil && quote.GetExpectedFeeTotal() != nil { + return moneyFromProto(quote.GetExpectedFeeTotal()) + } + if payment != nil && payment.LastQuote != nil { + return cloneMoney(payment.LastQuote.ExpectedFeeTotal) + } + return nil +} + +func planTimestamp(payment *model.Payment) time.Time { + if payment != nil && !payment.CreatedAt.IsZero() { + return payment.CreatedAt.UTC() + } + return time.Now().UTC() +} + +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 +} + +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/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 69e6c11f..ec938347 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" + "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/api/routers" @@ -10,6 +11,7 @@ import ( msg "github.com/tech/sendico/pkg/messaging" mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" + orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "google.golang.org/grpc" ) @@ -20,13 +22,9 @@ func (e serviceError) Error() string { return string(e) } -const ( - defaultFeeQuoteTTLMillis int64 = 120000 - defaultOracleTTLMillis int64 = 60000 -) - var ( - errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised") + errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised") + errQuotationUnavailable = serviceError("payments.orchestrator: quotation service not configured") ) // Service orchestrates payments across ledger, billing, FX, and chain domains. @@ -42,22 +40,22 @@ type Service struct { gatewayBroker mb.Broker gatewayConsumers []msg.Consumer - orchestratorv1.UnimplementedPaymentOrchestratorServer + orchestrationv1.UnimplementedPaymentExecutionServiceServer } type serviceDependencies struct { - fees feesDependency - ledger ledgerDependency - gateway gatewayDependency - railGateways railGatewayDependency - providerGateway providerGatewayDependency - oracle oracleDependency + quotation quotationDependency + fees feesDependency + ledger ledgerDependency + gateway gatewayDependency + railGateways railGatewayDependency + providerGateway providerGatewayDependency + mntx mntxDependency - gatewayRegistry GatewayRegistry + gatewayRegistry plan_builder.GatewayRegistry gatewayInvokeResolver GatewayInvokeResolver cardRoutes map[string]CardGatewayRoute feeLedgerAccounts map[string]string - planBuilder PlanBuilder } type handlerSet struct { @@ -118,22 +116,10 @@ func (s *Service) ensureHandlers() { // Register attaches the service to the supplied gRPC router. func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { - orchestratorv1.RegisterPaymentOrchestratorServer(reg, s) + orchestrationv1.RegisterPaymentExecutionServiceServer(reg, s) }) } -// QuotePayment aggregates downstream quotes. -func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req) -} - -// QuotePayments aggregates downstream quotes for multiple intents. -func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req) -} - // InitiatePayment captures a payment intent and reserves funds orchestration. func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { s.ensureHandlers() diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go index 6272594e..3ce07583 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "go.mongodb.org/mongo-driver/v2/bson" "google.golang.org/protobuf/proto" ) @@ -106,51 +107,74 @@ type quoteResolutionError struct { func (e quoteResolutionError) Error() string { return e.err.Error() } -func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) { +func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) { if ref := strings.TrimSpace(in.QuoteRef); ref != "" { quotesStore, err := ensureQuotesStore(s.storage) if err != nil { - return nil, nil, err + return nil, nil, nil, err } record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) if err != nil { if errors.Is(err, storage.ErrQuoteNotFound) { - return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} + return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} } - return nil, nil, err + return nil, nil, nil, err } if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { - return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} + return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} } intent, err := recordIntentFromQuote(record) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if in.Intent != nil && !proto.Equal(intent, in.Intent) { - return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} + return nil, nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} } quote, err := recordQuoteFromQuote(record) if err != nil { - return nil, nil, err + return nil, nil, nil, err } quote.QuoteRef = ref - return quote, intent, nil + plan, err := recordPlanFromQuote(record) + if err != nil { + return nil, nil, nil, err + } + return quote, intent, plan, nil } if in.Intent == nil { - return nil, nil, merrors.InvalidArgument("intent is required") + return nil, nil, nil, merrors.InvalidArgument("intent is required") } - req := &orchestratorv1.QuotePaymentRequest{ + req := "ationv1.QuotePaymentRequest{ Meta: in.Meta, IdempotencyKey: in.IdempotencyKey, Intent: in.Intent, PreviewOnly: false, } - quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req) - if err != nil { - return nil, nil, err + if !s.deps.quotation.available() { + return nil, nil, nil, errQuotationUnavailable } - return quote, in.Intent, nil + quoteResp, err := s.deps.quotation.client.QuotePayment(ctx, req) + if err != nil { + return nil, nil, nil, err + } + quote := quoteResp.GetQuote() + if quote == nil { + return nil, nil, nil, merrors.InvalidArgument("stored quote is empty") + } + ref := strings.TrimSpace(quote.GetQuoteRef()) + if ref == "" { + return nil, nil, nil, merrors.InvalidArgument("quotation response does not include quote_ref") + } + + return s.resolvePaymentQuote(ctx, quoteResolutionInput{ + OrgRef: in.OrgRef, + OrgID: in.OrgID, + Meta: in.Meta, + Intent: in.Intent, + QuoteRef: ref, + IdempotencyKey: in.IdempotencyKey, + }) } func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) { @@ -185,6 +209,22 @@ func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.Pay return nil, merrors.InvalidArgument("stored quote is empty") } +func recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) { + if record == nil { + return nil, merrors.InvalidArgument("stored quote payload is incomplete") + } + if len(record.Plans) > 0 { + if len(record.Plans) != 1 { + return nil, merrors.InvalidArgument("stored quote payload is incomplete") + } + return cloneStoredPaymentPlan(record.Plans[0]), nil + } + if record.Plan != nil { + return cloneStoredPaymentPlan(record.Plan), nil + } + return nil, nil +} + func newPayment(orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment { entity := &model.Payment{} entity.SetID(bson.NewObjectID()) diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go index fc0847e0..1714f3ed 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -5,18 +5,20 @@ import ( "testing" "time" - oracleclient "github.com/tech/sendico/fx/oracle/client" ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/payments/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" 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" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "go.mongodb.org/mongo-driver/v2/bson" + "google.golang.org/grpc" "google.golang.org/protobuf/types/known/structpb" ) @@ -81,7 +83,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) { storage: stubRepo{quotes: &helperQuotesStore{}}, clock: clockpkg.NewSystem(), } - _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ OrgRef: org.Hex(), OrgID: org, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, @@ -110,7 +112,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) { storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, clock: clockpkg.NewSystem(), } - _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ OrgRef: org.Hex(), OrgID: org, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, @@ -136,7 +138,7 @@ func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, clock: clockpkg.NewSystem(), } - quote, resolvedIntent, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + quote, resolvedIntent, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ OrgRef: org.Hex(), OrgID: org, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, @@ -166,23 +168,12 @@ func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) { Quote: &model.PaymentQuoteSnapshot{}, } - feeFake := &feeEngineFake{} - oracleCalls := 0 svc := &Service{ storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, clock: clockpkg.NewSystem(), - deps: serviceDependencies{ - fees: feesDependency{client: feeFake}, - oracle: oracleDependency{client: &oracleclient.Fake{ - GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { - oracleCalls++ - return &oracleclient.Quote{QuoteRef: "q1", ExpiresAt: time.Now()}, nil - }, - }}, - }, } - _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ OrgRef: org.Hex(), OrgID: org, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, @@ -191,18 +182,44 @@ func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if feeFake.precomputeCalls != 0 { - t.Fatalf("expected no fee recompute, got %d", feeFake.precomputeCalls) - } - if oracleCalls != 0 { - t.Fatalf("expected no fx recompute, got %d", oracleCalls) - } } func TestInitiatePaymentIdempotency(t *testing.T) { logger := mloggerfactory.NewLogger(false) org := bson.NewObjectID() store := newHelperPaymentStore() + intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", + Source: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, + }, + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, + }, + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + SettlementCurrency: "USD", + } + record := &model.PaymentQuoteRecord{ + QuoteRef: "q1", + Intent: intentFromProto(intent), + Quote: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, + ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, + QuoteRef: "q1", + }, + Plan: &model.PaymentPlan{ + Steps: []*model.PaymentStep{ + { + StepID: "ledger_move", + Rail: model.RailLedger, + Action: model.RailOperationMove, + Amount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, + FromRole: rolePtr(account_role.AccountRoleOperating), + ToRole: rolePtr(account_role.AccountRoleTransit), + }, + }, + }, + } ledgerFake := &ledgerclient.Fake{ ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { operatingDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_OPERATING"}) @@ -218,41 +235,23 @@ func TestInitiatePaymentIdempotency(t *testing.T) { return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil }, } - routes := &stubRoutesStore{ - routes: []*model.PaymentRoute{ - {FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true}, - }, - } - plans := &stubPlanTemplatesStore{ - templates: []*model.PaymentPlanTemplate{ - { - FromRail: model.RailLedger, - ToRail: model.RailLedger, - IsEnabled: true, - Steps: []model.OrchestrationStep{ - {StepID: "ledger_move", Rail: model.RailLedger, Operation: "ledger.move", FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, - }, - }, - }, - } svc := NewService(logger, stubRepo{ payments: store, - routes: routes, - plans: plans, - }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) + quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, + }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake), WithQuotationService(&helperQuotationClient{ + quotePaymentFn: func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { + amount := req.GetIntent().GetAmount() + return "ationv1.QuotePaymentResponse{ + Quote: &orchestratorv1.PaymentQuote{ + QuoteRef: "q1", + DebitAmount: amount, + ExpectedSettlementAmount: amount, + }, + IdempotencyKey: req.GetIdempotencyKey(), + }, nil + }, + })) svc.ensureHandlers() - - intent := &orchestratorv1.PaymentIntent{ - Ref: "ref-1", - Source: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, - }, - Destination: &orchestratorv1.PaymentEndpoint{ - Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, - }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } req := &orchestratorv1.InitiatePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, Intent: intent, @@ -289,7 +288,19 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { record := &model.PaymentQuoteRecord{ QuoteRef: "q1", Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + Plan: &model.PaymentPlan{ + Steps: []*model.PaymentStep{ + { + StepID: "ledger_move", + Rail: model.RailLedger, + Action: model.RailOperationMove, + Amount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, + FromRole: rolePtr(account_role.AccountRoleOperating), + ToRole: rolePtr(account_role.AccountRoleTransit), + }, + }, + }, } ledgerFake := &ledgerclient.Fake{ ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { @@ -306,28 +317,9 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil }, } - routes := &stubRoutesStore{ - routes: []*model.PaymentRoute{ - {FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true}, - }, - } - plans := &stubPlanTemplatesStore{ - templates: []*model.PaymentPlanTemplate{ - { - FromRail: model.RailLedger, - ToRail: model.RailLedger, - IsEnabled: true, - Steps: []model.OrchestrationStep{ - {StepID: "ledger_move", Rail: model.RailLedger, Operation: "ledger.move", FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, - }, - }, - }, - } svc := NewService(logger, stubRepo{ payments: store, quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, - routes: routes, - plans: plans, }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) svc.ensureHandlers() @@ -470,6 +462,33 @@ func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.O return nil, storage.ErrQuoteNotFound } +type helperQuotationClient struct { + quotePaymentFn func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) + quotePaymentsFn func(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) +} + +func (c *helperQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { + if c.quotePaymentFn != nil { + return c.quotePaymentFn(ctx, req, opts...) + } + return "ationv1.QuotePaymentResponse{}, nil +} + +func (c *helperQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) { + if c.quotePaymentsFn != nil { + return c.quotePaymentsFn(ctx, req, opts...) + } + return "ationv1.QuotePaymentsResponse{}, nil +} + func rolePtr(role account_role.AccountRole) *account_role.AccountRole { return &role } + +type stubGatewayRegistry struct { + items []*model.GatewayInstanceDescriptor +} + +func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + return s.items, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index cfcc61d1..569e7bb7 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -64,7 +64,8 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) { Type: model.EndpointTypeLedger, Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, }, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}, + Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}, + SettlementCurrency: "USD", }, } store.payments[payment.PaymentRef] = payment @@ -77,6 +78,15 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) { Price: &moneyv1.Decimal{Value: "0.9"}, }, } + attachStoredPlan(payment, &model.PaymentPlan{ + Steps: []*model.PaymentStep{ + { + StepID: "fx_convert", + Rail: model.RailLedger, + Action: model.RailOperationFXConvert, + }, + }, + }, payment.IdempotencyKey) if err := svc.executePayment(ctx, store, payment, quote); err != nil { t.Fatalf("executePayment returned error: %v", err) } @@ -174,11 +184,22 @@ func TestExecutePayment_ChainFailure(t *testing.T) { Type: model.EndpointTypeLedger, Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"}, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"}, + SettlementCurrency: "USDT", }, } store.payments[payment.PaymentRef] = payment + attachStoredPlan(payment, &model.PaymentPlan{ + Steps: []*model.PaymentStep{ + { + StepID: "crypto_send", + Rail: model.RailCrypto, + Action: model.RailOperationSend, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"}, + }, + }, + }, payment.IdempotencyKey) err := svc.executePayment(ctx, store, payment, &orchestratorv1.PaymentQuote{}) if err == nil || err.Error() != "chain failure" { t.Fatalf("expected chain failure error, got %v", err) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go b/api/payments/orchestrator/internal/service/plan_builder/default.go similarity index 93% rename from api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go rename to api/payments/orchestrator/internal/service/plan_builder/default.go index ba322676..82626e9b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go +++ b/api/payments/orchestrator/internal/service/plan_builder/default.go @@ -1,4 +1,4 @@ -package orchestrator +package plan_builder import ( "context" @@ -15,7 +15,8 @@ type defaultPlanBuilder struct { logger mlogger.Logger } -func newDefaultPlanBuilder(logger mlogger.Logger) *defaultPlanBuilder { +// New constructs the default plan builder. +func New(logger mlogger.Logger) *defaultPlanBuilder { return &defaultPlanBuilder{ logger: logger.Named("plan_builder"), } @@ -77,7 +78,7 @@ func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment") } - network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) + network, err := ResolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) if err != nil { logger.Warn("Failed to resolve route network", zap.Error(err)) return nil, err @@ -91,7 +92,7 @@ func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, } logger.Debug("Route selected", mzap.StorableRef(route)) - template, err := selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network) + 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 diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go b/api/payments/orchestrator/internal/service/plan_builder/default_test.go similarity index 98% rename from api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go rename to api/payments/orchestrator/internal/service/plan_builder/default_test.go index 8f93cebd..4f79641e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go +++ b/api/payments/orchestrator/internal/service/plan_builder/default_test.go @@ -1,4 +1,4 @@ -package orchestrator +package plan_builder import ( "context" @@ -15,7 +15,7 @@ import ( func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { ctx := context.Background() - builder := newDefaultPlanBuilder(mloggerfactory.NewLogger(false)) + builder := New(mloggerfactory.NewLogger(false)) payment := &model.Payment{ PaymentRef: "pay-1", @@ -138,7 +138,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { ctx := context.Background() - builder := newDefaultPlanBuilder(mloggerfactory.NewLogger(false)) + builder := New(mloggerfactory.NewLogger(false)) payment := &model.Payment{ PaymentRef: "pay-1", @@ -173,7 +173,7 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t *testing.T) { ctx := context.Background() - builder := newDefaultPlanBuilder(mloggerfactory.NewLogger(false)) + builder := New(mloggerfactory.NewLogger(false)) payment := &model.Payment{ PaymentRef: "pay-settle-1", @@ -237,7 +237,7 @@ func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) { ctx := context.Background() - builder := newDefaultPlanBuilder(mloggerfactory.NewLogger(false)) + builder := New(mloggerfactory.NewLogger(false)) payment := &model.Payment{ PaymentRef: "pay-2", @@ -423,6 +423,10 @@ func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceD 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 { diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go b/api/payments/orchestrator/internal/service/plan_builder/endpoints.go similarity index 99% rename from api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go rename to api/payments/orchestrator/internal/service/plan_builder/endpoints.go index d825937d..4785fa75 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go +++ b/api/payments/orchestrator/internal/service/plan_builder/endpoints.go @@ -1,4 +1,4 @@ -package orchestrator +package plan_builder import ( "strings" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go b/api/payments/orchestrator/internal/service/plan_builder/gateways.go similarity index 99% rename from api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go rename to api/payments/orchestrator/internal/service/plan_builder/gateways.go index 8d3f573d..133e1148 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go +++ b/api/payments/orchestrator/internal/service/plan_builder/gateways.go @@ -1,4 +1,4 @@ -package orchestrator +package plan_builder import ( "context" diff --git a/api/payments/orchestrator/internal/service/plan_builder/helpers.go b/api/payments/orchestrator/internal/service/plan_builder/helpers.go new file mode 100644 index 00000000..e4ef8e2d --- /dev/null +++ b/api/payments/orchestrator/internal/service/plan_builder/helpers.go @@ -0,0 +1,451 @@ +package plan_builder + +import ( + "strings" + + "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" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +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 *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote { + if quote != nil { + return quote + } + if payment != nil && payment.LastQuote != nil { + return modelQuoteToProto(payment.LastQuote) + } + return &orchestratorv1.PaymentQuote{} +} + +func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote { + if src == nil { + return nil + } + return &orchestratorv1.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 + } + return &paymenttypes.FXQuote{ + QuoteRef: strings.TrimSpace(quote.GetQuoteRef()), + Pair: pairFromProto(quote.GetPair()), + Side: fxSideFromProto(quote.GetSide()), + Price: decimalFromProto(quote.GetPrice()), + BaseAmount: moneyFromProto(quote.GetBaseAmount()), + QuoteAmount: moneyFromProto(quote.GetQuoteAmount()), + ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(), + Provider: strings.TrimSpace(quote.GetProvider()), + RateRef: strings.TrimSpace(quote.GetRateRef()), + Firm: quote.GetFirm(), + } +} + +func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote { + if quote == nil { + return nil + } + return &oraclev1.Quote{ + QuoteRef: strings.TrimSpace(quote.QuoteRef), + Pair: pairToProto(quote.Pair), + Side: fxSideToProto(quote.Side), + Price: decimalToProto(quote.Price), + BaseAmount: protoMoney(quote.BaseAmount), + QuoteAmount: protoMoney(quote.QuoteAmount), + ExpiresAtUnixMs: quote.ExpiresAtUnixMs, + Provider: strings.TrimSpace(quote.Provider), + RateRef: strings.TrimSpace(quote.RateRef), + Firm: quote.Firm, + } +} + +func 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 new file mode 100644 index 00000000..dfec9fed --- /dev/null +++ b/api/payments/orchestrator/internal/service/plan_builder/plan_builder.go @@ -0,0 +1,22 @@ +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/quotation/internal/service/orchestrator/plan_builder_plans.go b/api/payments/orchestrator/internal/service/plan_builder/plans.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/plan_builder_plans.go rename to api/payments/orchestrator/internal/service/plan_builder/plans.go index b6e3b073..1747db95 100644 --- a/api/payments/quotation/internal/service/orchestrator/plan_builder_plans.go +++ b/api/payments/orchestrator/internal/service/plan_builder/plans.go @@ -1,4 +1,4 @@ -package orchestrator +package plan_builder import ( "time" diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_routes.go b/api/payments/orchestrator/internal/service/plan_builder/routes.go similarity index 93% rename from api/payments/quotation/internal/service/orchestrator/plan_builder_routes.go rename to api/payments/orchestrator/internal/service/plan_builder/routes.go index de979ece..8b0bf2d5 100644 --- a/api/payments/quotation/internal/service/orchestrator/plan_builder_routes.go +++ b/api/payments/orchestrator/internal/service/plan_builder/routes.go @@ -1,4 +1,4 @@ -package orchestrator +package plan_builder import ( "context" @@ -9,7 +9,9 @@ import ( "github.com/tech/sendico/pkg/merrors" ) -func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { +// 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) { diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_steps.go b/api/payments/orchestrator/internal/service/plan_builder/steps.go similarity index 98% rename from api/payments/quotation/internal/service/orchestrator/plan_builder_steps.go rename to api/payments/orchestrator/internal/service/plan_builder/steps.go index c9460f41..980af682 100644 --- a/api/payments/quotation/internal/service/orchestrator/plan_builder_steps.go +++ b/api/payments/orchestrator/internal/service/plan_builder/steps.go @@ -1,4 +1,4 @@ -package orchestrator +package plan_builder import ( "context" @@ -6,7 +6,6 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" paymenttypes "github.com/tech/sendico/pkg/payments/types" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -353,14 +352,6 @@ func observeAmountForRail(rail model.Rail, source, settlement, payout *paymentty return source } -func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole { - if role == nil { - return nil - } - cloned := *role - return &cloned -} - func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote) (*paymenttypes.Money, error) { if sourceAmount == nil { return nil, merrors.InvalidArgument("plan builder: source amount is required") diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_templates.go b/api/payments/orchestrator/internal/service/plan_builder/templates.go similarity index 97% rename from api/payments/quotation/internal/service/orchestrator/plan_builder_templates.go rename to api/payments/orchestrator/internal/service/plan_builder/templates.go index bbaed284..cf1e7d48 100644 --- a/api/payments/quotation/internal/service/orchestrator/plan_builder_templates.go +++ b/api/payments/orchestrator/internal/service/plan_builder/templates.go @@ -1,4 +1,4 @@ -package orchestrator +package plan_builder import ( "context" @@ -12,7 +12,8 @@ import ( "go.uber.org/zap" ) -func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { +// 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") } diff --git a/api/payments/orchestrator/internal/service/shared/idempotency.go b/api/payments/orchestrator/internal/service/shared/idempotency.go new file mode 100644 index 00000000..c5d0afbb --- /dev/null +++ b/api/payments/orchestrator/internal/service/shared/idempotency.go @@ -0,0 +1,17 @@ +package shared + +import ( + "fmt" + "strings" +) + +func PerIntentIdempotencyKey(base string, index int, total int) string { + base = strings.TrimSpace(base) + if base == "" { + return "" + } + if total <= 1 { + return base + } + return fmt.Sprintf("%s:%d", base, index+1) +} diff --git a/api/payments/quotation/go.mod b/api/payments/quotation/go.mod index e3ea6409..80044e13 100644 --- a/api/payments/quotation/go.mod +++ b/api/payments/quotation/go.mod @@ -17,12 +17,10 @@ replace github.com/tech/sendico/ledger => ../../ledger replace github.com/tech/sendico/payments/storage => ../storage require ( - github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 - github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/payments/storage v0.0.0-00010101000000-000000000000 github.com/tech/sendico/pkg v0.1.0 @@ -41,6 +39,7 @@ require ( github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -52,6 +51,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/payments/quotation/internal/server/internal/discovery.go b/api/payments/quotation/internal/server/internal/discovery.go index e68f1096..7275a0ab 100644 --- a/api/payments/quotation/internal/server/internal/discovery.go +++ b/api/payments/quotation/internal/server/internal/discovery.go @@ -11,6 +11,34 @@ import ( const quotationDiscoverySender = "payment_quotation" +func (i *Imp) initDiscovery(cfg *config) { + if i == nil || cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" { + return + } + + logger := i.logger.Named("discovery") + broker, err := msg.CreateMessagingBroker(logger.Named("bus"), cfg.Messaging) + if err != nil { + i.logger.Warn("Failed to initialise discovery broker", zap.Error(err)) + return + } + + registry := discovery.NewRegistry() + watcher, err := discovery.NewRegistryWatcher(logger, broker, registry) + if err != nil { + i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(err)) + return + } + if err := watcher.Start(); err != nil { + i.logger.Warn("Failed to start discovery registry watcher", zap.Error(err)) + return + } + + i.discoveryWatcher = watcher + i.discoveryReg = registry + i.logger.Info("Discovery registry watcher started") +} + func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) { if i == nil || cfg == nil || producer == nil || cfg.GRPC == nil { return @@ -43,3 +71,15 @@ func (i *Imp) stopDiscoveryAnnouncer() { i.discoveryAnnouncer.Stop() i.discoveryAnnouncer = nil } + +func (i *Imp) stopDiscovery() { + if i == nil { + return + } + i.stopDiscoveryAnnouncer() + if i.discoveryWatcher != nil { + i.discoveryWatcher.Stop() + i.discoveryWatcher = nil + } + i.discoveryReg = nil +} diff --git a/api/payments/quotation/internal/server/internal/serverimp.go b/api/payments/quotation/internal/server/internal/serverimp.go index 6038738b..f14f6d63 100644 --- a/api/payments/quotation/internal/server/internal/serverimp.go +++ b/api/payments/quotation/internal/server/internal/serverimp.go @@ -1,7 +1,7 @@ package serverimp import ( - quotesvc "github.com/tech/sendico/payments/quotation/internal/service/orchestrator" + quotesvc "github.com/tech/sendico/payments/quotation/internal/service/quotation" "github.com/tech/sendico/payments/storage" mongostorage "github.com/tech/sendico/payments/storage/mongo" "github.com/tech/sendico/pkg/db" @@ -19,7 +19,7 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { } func (i *Imp) Shutdown() { - i.stopDiscoveryAnnouncer() + i.stopDiscovery() if i.service != nil { i.service.Shutdown() } @@ -34,6 +34,7 @@ func (i *Imp) Start() error { } i.config = cfg + i.initDiscovery(cfg) i.deps = i.initDependencies(cfg) quoteRetention := cfg.quoteRetention() @@ -54,6 +55,9 @@ func (i *Imp) Start() error { opts = append(opts, quotesvc.WithChainGatewayClient(i.deps.gatewayClient)) } } + if registry := quotesvc.NewDiscoveryGatewayRegistry(logger, i.discoveryReg); registry != nil { + opts = append(opts, quotesvc.WithGatewayRegistry(registry)) + } i.startDiscoveryAnnouncer(cfg, producer) svc := quotesvc.NewQuotationService(logger, repo, opts...) i.service = svc diff --git a/api/payments/quotation/internal/server/internal/types.go b/api/payments/quotation/internal/server/internal/types.go index 9722c592..f6c19199 100644 --- a/api/payments/quotation/internal/server/internal/types.go +++ b/api/payments/quotation/internal/server/internal/types.go @@ -33,5 +33,7 @@ type Imp struct { service quoteService deps *clientDependencies + discoveryWatcher *discovery.RegistryWatcher + discoveryReg *discovery.Registry discoveryAnnouncer *discovery.Announcer } diff --git a/api/payments/quotation/internal/service/orchestrator/card_payout_constants.go b/api/payments/quotation/internal/service/orchestrator/card_payout_constants.go deleted file mode 100644 index eff95946..00000000 --- a/api/payments/quotation/internal/service/orchestrator/card_payout_constants.go +++ /dev/null @@ -1,10 +0,0 @@ -package orchestrator - -const ( - defaultCardGateway = "monetix" - - stepCodeGasTopUp = "gas_top_up" - stepCodeFundingTransfer = "funding_transfer" - stepCodeCardPayout = "card_payout" - stepCodeFeeTransfer = "fee_transfer" -) diff --git a/api/payments/quotation/internal/service/orchestrator/card_payout_funding.go b/api/payments/quotation/internal/service/orchestrator/card_payout_funding.go deleted file mode 100644 index e558d08e..00000000 --- a/api/payments/quotation/internal/service/orchestrator/card_payout_funding.go +++ /dev/null @@ -1,367 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/shopspring/decimal" - chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" -) - -func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - intent := payment.Intent - source := intent.Source.ManagedWallet - if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { - return merrors.InvalidArgument("card funding: source managed wallet is required") - } - route, err := s.cardRoute(defaultCardGateway) - if err != nil { - return err - } - sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef) - fundingAddress := strings.TrimSpace(route.FundingAddress) - feeWalletRef := strings.TrimSpace(route.FeeWalletRef) - - intentAmount := cloneMoney(intent.Amount) - if intentAmount == nil || strings.TrimSpace(intentAmount.GetAmount()) == "" || strings.TrimSpace(intentAmount.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: amount is required") - } - intentAmountProto := protoMoney(intentAmount) - - payoutAmount, err := cardPayoutAmount(payment) - if err != nil { - return err - } - - var feeAmount *paymenttypes.Money - if quote != nil { - feeAmount = moneyFromProto(quote.GetExpectedFeeTotal()) - } - if feeAmount == nil && payment.LastQuote != nil { - feeAmount = cloneMoney(payment.LastQuote.ExpectedFeeTotal) - } - feeDecimal := decimal.Zero - if feeAmount != nil && strings.TrimSpace(feeAmount.GetAmount()) != "" { - if strings.TrimSpace(feeAmount.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: fee currency is required") - } - feeDecimal, err = decimalFromMoney(feeAmount) - if err != nil { - return err - } - } - feeRequired := feeDecimal.IsPositive() - feeAmountProto := protoMoney(feeAmount) - - network := networkFromEndpoint(intent.Source) - instanceID := strings.TrimSpace(intent.Source.InstanceID) - actions := []model.RailOperation{model.RailOperationSend} - if feeRequired { - actions = append(actions, model.RailOperationFee) - } - chainClient, _, err := s.resolveChainGatewayClient(ctx, network, intentAmount, actions, instanceID, payment.PaymentRef) - if err != nil { - s.logger.Warn("card funding gateway resolution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - - fundingDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress}, - } - fundingFee, err := s.estimateTransferNetworkFee(ctx, chainClient, sourceWalletRef, fundingDest, intentAmountProto) - if err != nil { - return err - } - - var feeTransferFee *moneyv1.Money - if feeRequired { - if feeWalletRef == "" { - return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists") - } - feeDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, - } - feeTransferFee, err = s.estimateTransferNetworkFee(ctx, chainClient, sourceWalletRef, feeDest, feeAmountProto) - if err != nil { - return err - } - } - - totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee) - if err != nil { - return err - } - - var estimatedTotalFee *moneyv1.Money - if gasCurrency != "" && !totalFee.IsNegative() { - estimatedTotalFee = makeMoney(gasCurrency, totalFee) - } - - var topUpMoney *moneyv1.Money - var topUpFee *moneyv1.Money - topUpPositive := false - if estimatedTotalFee != nil { - computeResp, err := chainClient.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{ - WalletRef: sourceWalletRef, - EstimatedTotalFee: estimatedTotalFee, - }) - if err != nil { - s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - if computeResp != nil { - topUpMoney = computeResp.GetTopupAmount() - } - if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" { - amountDec, err := decimalFromMoney(topUpMoney) - if err != nil { - return err - } - topUpPositive = amountDec.IsPositive() - } - if topUpMoney != nil && topUpPositive { - if strings.TrimSpace(topUpMoney.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: gas top-up currency is required") - } - if feeWalletRef == "" { - return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up") - } - topUpDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, - } - topUpFee, err = s.estimateTransferNetworkFee(ctx, chainClient, feeWalletRef, topUpDest, topUpMoney) - if err != nil { - return err - } - } - } - - plan := ensureExecutionPlan(payment) - var gasStep *model.ExecutionStep - var feeStep *model.ExecutionStep - if topUpMoney != nil && topUpPositive { - gasStep = ensureExecutionStep(plan, stepCodeGasTopUp) - setExecutionStepRole(gasStep, executionStepRoleSource) - setExecutionStepStatus(gasStep, model.OperationStatePlanned) - gasStep.Description = "Top up native gas from fee wallet" - gasStep.Amount = moneyFromProto(topUpMoney) - gasStep.NetworkFee = moneyFromProto(topUpFee) - gasStep.SourceWalletRef = feeWalletRef - gasStep.DestinationRef = sourceWalletRef - } - - fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) - setExecutionStepRole(fundStep, executionStepRoleSource) - setExecutionStepStatus(fundStep, model.OperationStatePlanned) - fundStep.Description = "Transfer payout amount to card funding wallet" - fundStep.Amount = cloneMoney(intentAmount) - fundStep.NetworkFee = moneyFromProto(fundingFee) - fundStep.SourceWalletRef = sourceWalletRef - fundStep.DestinationRef = fundingAddress - - if feeRequired { - feeStep = ensureExecutionStep(plan, stepCodeFeeTransfer) - setExecutionStepRole(feeStep, executionStepRoleSource) - setExecutionStepStatus(feeStep, model.OperationStatePlanned) - feeStep.Description = "Transfer fee to fee wallet" - feeStep.Amount = cloneMoney(feeAmount) - feeStep.NetworkFee = moneyFromProto(feeTransferFee) - feeStep.SourceWalletRef = sourceWalletRef - feeStep.DestinationRef = feeWalletRef - } - - cardStep := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(cardStep, executionStepRoleConsumer) - setExecutionStepStatus(cardStep, model.OperationStatePlanned) - cardStep.Description = "Submit card payout" - cardStep.Amount = cloneMoney(payoutAmount) - if card := intent.Destination.Card; card != nil { - if masked := strings.TrimSpace(card.MaskedPan); masked != "" { - cardStep.DestinationRef = masked - } - } - - updateExecutionPlanTotalNetworkFee(plan) - - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - - if topUpMoney != nil && topUpPositive { - ensureResp, gasErr := chainClient.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:gas", - OrganizationRef: payment.OrganizationRef.Hex(), - IntentRef: strings.TrimSpace(payment.Intent.Ref), - OperationRef: strings.TrimSpace(cardStep.OperationRef), - SourceWalletRef: feeWalletRef, - TargetWalletRef: sourceWalletRef, - EstimatedTotalFee: estimatedTotalFee, - Metadata: cloneMetadata(payment.Metadata), - PaymentRef: payment.PaymentRef, - }) - if gasErr != nil { - s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) - return gasErr - } - if gasStep != nil { - actual := (*moneyv1.Money)(nil) - if ensureResp != nil { - actual = ensureResp.GetTopupAmount() - if transfer := ensureResp.GetTransfer(); transfer != nil { - gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef()) - } - } - actualPositive := false - if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" { - actualDec, err := decimalFromMoney(actual) - if err != nil { - return err - } - actualPositive = actualDec.IsPositive() - } - if actual != nil && actualPositive { - gasStep.Amount = moneyFromProto(actual) - if strings.TrimSpace(actual.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: gas top-up currency is required") - } - topUpDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, - } - topUpFee, err = s.estimateTransferNetworkFee(ctx, chainClient, feeWalletRef, topUpDest, actual) - if err != nil { - return err - } - gasStep.NetworkFee = moneyFromProto(topUpFee) - setExecutionStepStatus(gasStep, model.OperationStateWaiting) - } else { - gasStep.Amount = nil - gasStep.NetworkFee = nil - gasStep.TransferRef = "" - setExecutionStepStatus(gasStep, model.OperationStateSkipped) - } - } - if gasStep != nil { - s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) - } - updateExecutionPlanTotalNetworkFee(plan) - } - - fundResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:fund", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: sourceWalletRef, - Destination: fundingDest, - Amount: cloneProtoMoney(intentAmountProto), - Metadata: cloneMetadata(payment.Metadata), - PaymentRef: payment.PaymentRef, - IntentRef: strings.TrimSpace(intent.Ref), - OperationRef: strings.TrimSpace(cardStep.OperationRef), - }) - if err != nil { - return err - } - if fundResp != nil && fundResp.GetTransfer() != nil { - exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef()) - fundStep.TransferRef = exec.ChainTransferRef - } - setExecutionStepStatus(fundStep, model.OperationStateWaiting) - updateExecutionPlanTotalNetworkFee(plan) - - if feeRequired { - feeResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ - IntentRef: intent.Ref, - OperationRef: feeStep.OperationRef, - IdempotencyKey: payment.IdempotencyKey + ":card:fee", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: sourceWalletRef, - Destination: &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, - }, - Amount: cloneProtoMoney(feeAmountProto), - Metadata: cloneMetadata(payment.Metadata), - PaymentRef: payment.PaymentRef, - }) - if err != nil { - return err - } - if feeResp != nil && feeResp.GetTransfer() != nil { - exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef()) - feeStep.TransferRef = exec.FeeTransferRef - } - setExecutionStepStatus(feeStep, model.OperationStateWaiting) - s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) - } - - payment.Execution = exec - return nil -} - -func (s *Service) estimateTransferNetworkFee(ctx context.Context, client chainclient.Client, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) { - if client == nil { - return nil, merrors.InvalidArgument("chain gateway unavailable") - } - sourceWalletRef = strings.TrimSpace(sourceWalletRef) - if sourceWalletRef == "" { - return nil, merrors.InvalidArgument("source wallet ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("amount is required") - } - - resp, err := client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{ - SourceWalletRef: sourceWalletRef, - Destination: destination, - Amount: cloneProtoMoney(amount), - }) - if err != nil { - s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - if resp == nil { - s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - fee := resp.GetNetworkFee() - if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { - s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - return cloneProtoMoney(fee), nil -} - -func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) { - total := decimal.Zero - currency := "" - for _, fee := range fees { - if fee == nil { - continue - } - amount := strings.TrimSpace(fee.GetAmount()) - feeCurrency := strings.TrimSpace(fee.GetCurrency()) - if amount == "" || feeCurrency == "" { - return decimal.Zero, "", merrors.InvalidArgument("network fee is required") - } - value, err := decimalFromMoney(fee) - if err != nil { - return decimal.Zero, "", err - } - if currency == "" { - currency = feeCurrency - } else if !strings.EqualFold(currency, feeCurrency) { - return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch") - } - total = total.Add(value) - } - return total, currency, nil -} diff --git a/api/payments/quotation/internal/service/orchestrator/card_payout_helpers.go b/api/payments/quotation/internal/service/orchestrator/card_payout_helpers.go deleted file mode 100644 index 86554e87..00000000 --- a/api/payments/quotation/internal/service/orchestrator/card_payout_helpers.go +++ /dev/null @@ -1,80 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" -) - -func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan { - if payment == nil { - return nil - } - if payment.ExecutionPlan == nil { - payment.ExecutionPlan = &model.ExecutionPlan{} - } - return payment.ExecutionPlan -} - -func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep { - if plan == nil { - return nil - } - code = strings.TrimSpace(code) - if code == "" { - return nil - } - for _, step := range plan.Steps { - if step == nil { - continue - } - if strings.EqualFold(step.Code, code) { - if step.Code == "" { - step.Code = code - } - return step - } - } - step := &model.ExecutionStep{Code: code} - plan.Steps = append(plan.Steps, step) - return step -} - -func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) { - if plan == nil { - return - } - total := decimal.Zero - currency := "" - hasFee := false - for _, step := range plan.Steps { - if step == nil || step.NetworkFee == nil { - continue - } - fee := step.NetworkFee - if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { - continue - } - if currency == "" { - currency = strings.TrimSpace(fee.GetCurrency()) - } else if !strings.EqualFold(currency, fee.GetCurrency()) { - continue - } - value, err := decimalFromMoney(fee) - if err != nil { - continue - } - total = total.Add(value) - hasFee = true - } - if !hasFee || currency == "" { - plan.TotalNetworkFee = nil - return - } - plan.TotalNetworkFee = &paymenttypes.Money{ - Currency: currency, - Amount: total.String(), - } -} diff --git a/api/payments/quotation/internal/service/orchestrator/card_payout_routes.go b/api/payments/quotation/internal/service/orchestrator/card_payout_routes.go deleted file mode 100644 index 621e693f..00000000 --- a/api/payments/quotation/internal/service/orchestrator/card_payout_routes.go +++ /dev/null @@ -1,29 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/pkg/merrors" - "go.uber.org/zap" -) - -func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { - if len(s.deps.cardRoutes) == 0 { - s.logger.Warn("card routing not configured", zap.String("gateway", gateway)) - return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured") - } - key := strings.ToLower(strings.TrimSpace(gateway)) - if key == "" { - key = defaultCardGateway - } - route, ok := s.deps.cardRoutes[key] - if !ok { - s.logger.Warn("card routing missing for gateway", zap.String("gateway", key)) - return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) - } - if strings.TrimSpace(route.FundingAddress) == "" { - s.logger.Warn("card routing missing funding address", zap.String("gateway", key)) - return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) - } - return route, nil -} diff --git a/api/payments/quotation/internal/service/orchestrator/card_payout_submit.go b/api/payments/quotation/internal/service/orchestrator/card_payout_submit.go deleted file mode 100644 index 4f5d8b9b..00000000 --- a/api/payments/quotation/internal/service/orchestrator/card_payout_submit.go +++ /dev/null @@ -1,351 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/google/uuid" - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - "go.uber.org/zap" -) - -func (s *Service) submitCardPayout(ctx context.Context, operationRef string, payment *model.Payment) error { - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - if payment.Execution != nil && strings.TrimSpace(payment.Execution.CardPayoutRef) != "" { - return nil - } - intent := payment.Intent - card := intent.Destination.Card - if card == nil { - return merrors.InvalidArgument("card payout: card endpoint is required") - } - amount, err := cardPayoutAmount(payment) - if err != nil { - return err - } - amtDec, err := decimalFromMoney(amount) - if err != nil { - return err - } - minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() - - payoutID := payment.PaymentRef - currency := strings.TrimSpace(amount.GetCurrency()) - holder := strings.TrimSpace(card.Cardholder) - meta := cloneMetadata(payment.Metadata) - customer := intent.Customer - customerID := "" - customerFirstName := "" - customerMiddleName := "" - customerLastName := "" - customerIP := "" - customerZip := "" - customerCountry := "" - customerState := "" - customerCity := "" - customerAddress := "" - if customer != nil { - customerID = strings.TrimSpace(customer.ID) - customerFirstName = strings.TrimSpace(customer.FirstName) - customerMiddleName = strings.TrimSpace(customer.MiddleName) - customerLastName = strings.TrimSpace(customer.LastName) - customerIP = strings.TrimSpace(customer.IP) - customerZip = strings.TrimSpace(customer.Zip) - customerCountry = strings.TrimSpace(customer.Country) - customerState = strings.TrimSpace(customer.State) - customerCity = strings.TrimSpace(customer.City) - customerAddress = strings.TrimSpace(customer.Address) - } - if customerFirstName == "" { - customerFirstName = strings.TrimSpace(card.Cardholder) - } - if customerLastName == "" { - customerLastName = strings.TrimSpace(card.CardholderSurname) - } - if customerID == "" { - return merrors.InvalidArgument("card payout: customer id is required") - } - if customerFirstName == "" { - return merrors.InvalidArgument("card payout: customer first name is required") - } - if customerLastName == "" { - return merrors.InvalidArgument("card payout: customer last name is required") - } - if customerIP == "" { - return merrors.InvalidArgument("card payout: customer ip is required") - } - - var ( - state *mntxv1.CardPayoutState - ) - - if token := strings.TrimSpace(card.Token); token != "" { - req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - IdempotencyKey: payment.IdempotencyKey, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardToken: token, - CardHolder: holder, - MaskedPan: strings.TrimSpace(card.MaskedPan), - Metadata: meta, - } - resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) - if err != nil { - s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - state = resp.GetPayout() - } else if pan := strings.TrimSpace(card.Pan); pan != "" { - req := &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - IdempotencyKey: payment.IdempotencyKey, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardPan: pan, - CardExpYear: card.ExpYear, - CardExpMonth: card.ExpMonth, - CardHolder: holder, - Metadata: meta, - IntentRef: payment.Intent.Ref, - OperationRef: operationRef, - } - resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) - if err != nil { - s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - state = resp.GetPayout() - } else { - return merrors.InvalidArgument("card payout: either token or pan must be provided") - } - - if state == nil { - return merrors.Internal("card payout: missing payout state") - } - recordCardPayoutState(payment, state) - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - if exec.CardPayoutRef == "" { - exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) - } - payment.Execution = exec - - plan := ensureExecutionPlan(payment) - if plan != nil { - step := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(step, executionStepRoleConsumer) - step.Description = "Submit card payout" - step.Amount = cloneMoney(amount) - if masked := strings.TrimSpace(card.MaskedPan); masked != "" { - step.DestinationRef = masked - } - if exec.CardPayoutRef != "" { - step.TransferRef = exec.CardPayoutRef - } - setExecutionStepStatus(step, model.OperationStateWaiting) - updateExecutionPlanTotalNetworkFee(plan) - } - - s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_id", exec.CardPayoutRef), zap.String("operation_ref", state.OperationRef)) - - return nil -} - -func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) { - if payment == nil || state == nil { - return - } - if payment.CardPayout == nil { - payment.CardPayout = &model.CardPayout{} - } - payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId()) - payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId()) - payment.CardPayout.Status = state.GetStatus().String() - payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage()) - payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode()) - if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil { - payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country) - } - if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil { - payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan) - } - payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId()) -} - -func updateCardPayoutPlanSteps(payment *model.Payment, payout *mntxv1.CardPayoutState) bool { - if payment == nil || payout == nil || payment.PaymentPlan == nil { - return false - } - plan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if plan == nil { - return false - } - payoutID := strings.TrimSpace(payout.GetPayoutId()) - if payoutID == "" { - return false - } - - updated := false - for idx, planStep := range payment.PaymentPlan.Steps { - if planStep == nil { - continue - } - if planStep.Rail != model.RailCardPayout { - continue - } - if planStep.Action != model.RailOperationSend && planStep.Action != model.RailOperationObserveConfirm { - continue - } - if idx >= len(plan.Steps) { - continue - } - execStep := plan.Steps[idx] - if execStep == nil { - execStep = &model.ExecutionStep{ - Code: planStepID(planStep, idx), - Description: describePlanStep(planStep), - OperationRef: uuid.New().String(), - State: model.OperationStateCreated, - } - plan.Steps[idx] = execStep - } - if execStep.TransferRef == "" { - execStep.TransferRef = payoutID - } - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: - setExecutionStepStatus(execStep, model.OperationStateCreated) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: - setExecutionStepStatus(execStep, model.OperationStateWaiting) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - setExecutionStepStatus(execStep, model.OperationStateSuccess) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - setExecutionStepStatus(execStep, model.OperationStateFailed) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - setExecutionStepStatus(execStep, model.OperationStateCancelled) - - default: - setExecutionStepStatus(execStep, model.OperationStatePlanned) - } - updated = true - } - return updated -} - -func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) { - if payment == nil || payout == nil { - return - } - recordCardPayoutState(payment, payout) - - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.CardPayoutRef == "" { - payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId()) - } - - updated := updateCardPayoutPlanSteps(payment, payout) - plan := ensureExecutionPlan(payment) - if plan != nil && !updated { - step := findExecutionStepByTransferRef(plan, strings.TrimSpace(payout.GetPayoutId())) - if step == nil { - step = ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(step, executionStepRoleConsumer) - if step.TransferRef == "" { - step.TransferRef = payment.Execution.CardPayoutRef - } - } - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: - setExecutionStepStatus(step, model.OperationStatePlanned) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: - setExecutionStepStatus(step, model.OperationStateWaiting) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - setExecutionStepStatus(step, model.OperationStateSuccess) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - setExecutionStepStatus(step, model.OperationStateFailed) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - setExecutionStepStatus(step, model.OperationStateCancelled) - - default: - setExecutionStepStatus(step, model.OperationStatePlanned) - } - - } - - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = "payout cancelled" - - default: - // CREATED / WAITING — keep as is - } -} - -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 -} diff --git a/api/payments/quotation/internal/service/orchestrator/execution_plan.go b/api/payments/quotation/internal/service/orchestrator/execution_plan.go deleted file mode 100644 index fb093504..00000000 --- a/api/payments/quotation/internal/service/orchestrator/execution_plan.go +++ /dev/null @@ -1,163 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" -) - -const ( - executionStepMetadataRole = "role" - executionStepMetadataStatus = "status" - - executionStepRoleSource = "source" - executionStepRoleConsumer = "consumer" -) - -func setExecutionStepRole(step *model.ExecutionStep, role string) { - role = strings.ToLower(strings.TrimSpace(role)) - setExecutionStepMetadata(step, executionStepMetadataRole, role) -} - -func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) { - step.State = state - setExecutionStepMetadata(step, executionStepMetadataStatus, string(state)) -} - -func executionStepRole(step *model.ExecutionStep) string { - if step == nil { - return "" - } - if role := strings.TrimSpace(step.Metadata[executionStepMetadataRole]); role != "" { - return strings.ToLower(role) - } - if strings.EqualFold(step.Code, stepCodeCardPayout) { - return executionStepRoleConsumer - } - return executionStepRoleSource -} - -func isSourceExecutionStep(step *model.ExecutionStep) bool { - return executionStepRole(step) == executionStepRoleSource -} - -func sourceStepsConfirmed(plan *model.ExecutionPlan) bool { - if plan == nil || len(plan.Steps) == 0 { - return false - } - hasSource := false - for _, step := range plan.Steps { - if step == nil || !isSourceExecutionStep(step) { - continue - } - if step.State == model.OperationStateSkipped { - continue - } - hasSource = true - if step.State != model.OperationStateSuccess { - return false - } - } - return hasSource -} - -func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep { - if plan == nil { - return nil - } - transferRef = strings.TrimSpace(transferRef) - if transferRef == "" { - return nil - } - for _, step := range plan.Steps { - if step == nil { - continue - } - if strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) { - return step - } - } - return nil -} - -func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep { - if plan == nil || event == nil || event.GetTransfer() == nil { - return nil - } - transfer := event.GetTransfer() - transferRef := strings.TrimSpace(transfer.GetTransferRef()) - if transferRef == "" { - return nil - } - if status := executionStepStatusFromTransferStatus(transfer.GetStatus()); status != "" { - var updated *model.ExecutionStep - for _, step := range plan.Steps { - if step == nil { - continue - } - if !strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) { - continue - } - if step.TransferRef == "" { - step.TransferRef = transferRef - } - setExecutionStepStatus(step, status) - if updated == nil { - updated = step - } - } - return updated - } - return nil -} - -func executionStepStatusFromTransferStatus(status chainv1.TransferStatus) model.OperationState { - switch status { - - case chainv1.TransferStatus_TRANSFER_CREATED: - return model.OperationStatePlanned - - case chainv1.TransferStatus_TRANSFER_PROCESSING: - return model.OperationStateProcessing - - case chainv1.TransferStatus_TRANSFER_WAITING: - return model.OperationStateWaiting - - case chainv1.TransferStatus_TRANSFER_SUCCESS: - return model.OperationStateSuccess - - case chainv1.TransferStatus_TRANSFER_FAILED: - return model.OperationStateFailed - - case chainv1.TransferStatus_TRANSFER_CANCELLED: - return model.OperationStateCancelled - - default: - return model.OperationStatePlanned - } -} - -func setExecutionStepMetadata(step *model.ExecutionStep, key, value string) { - if step == nil { - return - } - key = strings.TrimSpace(key) - if key == "" { - return - } - value = strings.TrimSpace(value) - if value == "" { - if step.Metadata != nil { - delete(step.Metadata, key) - if len(step.Metadata) == 0 { - step.Metadata = nil - } - } - return - } - if step.Metadata == nil { - step.Metadata = map[string]string{} - } - step.Metadata[key] = value -} diff --git a/api/payments/quotation/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/quotation/internal/service/orchestrator/gateway_execution_consumer.go deleted file mode 100644 index a6065af9..00000000 --- a/api/payments/quotation/internal/service/orchestrator/gateway_execution_consumer.go +++ /dev/null @@ -1,295 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "strings" - - paymodel "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - cons "github.com/tech/sendico/pkg/messaging/consumer" - paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" - np "github.com/tech/sendico/pkg/messaging/notifications/processor" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/payments/rail" - "go.uber.org/zap" -) - -func (s *Service) startGatewayConsumers() { - if s == nil || s.gatewayBroker == nil { - s.logger.Warn("Missing broker. Gateway feedback consumer has NOT started") - return - } - s.logger.Info("Gateway feedback consumer started") - processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution) - s.consumeGatewayProcessor(processor) -} - -func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) { - consumer, err := cons.NewConsumer(s.logger, s.gatewayBroker, processor.GetSubject()) - if err != nil { - s.logger.Warn("Failed to create payment gateway consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) - return - } - s.gatewayConsumers = append(s.gatewayConsumers, consumer) - go func() { - if err := consumer.ConsumeMessages(processor.Process); err != nil { - s.logger.Warn("Payment gateway consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) - } - }() -} - -func executionPlanSucceeded(plan *paymodel.ExecutionPlan) bool { - for _, s := range plan.Steps { - if !s.IsTerminal() { - return false - } - if s.State != paymodel.OperationStateSuccess { - return false - } - } - return true -} - -func executionPlanFailed(plan *paymodel.ExecutionPlan) bool { - hasFailed := false - - for _, s := range plan.Steps { - if !s.IsTerminal() { - return false - } - if s.State == paymodel.OperationStateFailed { - hasFailed = true - } - } - - return hasFailed -} - -func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error { - if exec == nil { - return merrors.InvalidArgument("payment gateway execution is nil", "execution") - } - - paymentRef := strings.TrimSpace(exec.PaymentRef) - if paymentRef == "" { - return merrors.InvalidArgument("payment_ref is required", "payment_ref") - } - - store := s.storage.Payments() - - payment, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - s.logger.Warn("Failed to fetch payment from database", zap.Error(err)) - return err - } - - // --- metadata - if payment.Metadata == nil { - payment.Metadata = map[string]string{} - } - payment.Metadata["gateway_operation_result"] = string(exec.Status) - payment.Metadata["gateway_operation_ref"] = exec.OperationRef - payment.Metadata["gateway_request_idempotency"] = exec.IdempotencyKey - - // --- update exactly ONE step - - if payment.State, err = updateExecutionStepsFromGatewayExecution(s.logger, payment, exec); err != nil { - s.logger.Warn("No execution step matched gateway result", - zap.String("payment_ref", paymentRef), - zap.String("operation_ref", exec.OperationRef), - zap.String("idempotency", exec.IdempotencyKey), - ) - } - - if err := store.Update(ctx, payment); err != nil { - return err - } - - // reload unified state - payment, err = store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return err - } - - // --- if plan can continue — continue - if payment.ExecutionPlan != nil && !executionPlanComplete(payment.ExecutionPlan) { - return s.resumePaymentPlan(ctx, store, payment) - } - - // --- plan is terminal: decide payment fate by aggregation - if payment.ExecutionPlan != nil && executionPlanComplete(payment.ExecutionPlan) { - switch { - case executionPlanSucceeded(payment.ExecutionPlan): - payment.State = paymodel.PaymentStateSettled - - case executionPlanFailed(payment.ExecutionPlan): - payment.State = paymodel.PaymentStateFailed - payment.FailureReason = "execution_plan_failed" - } - - return store.Update(ctx, payment) - } - - return nil -} - -func updateExecutionStepsFromGatewayExecution( - logger mlogger.Logger, - payment *paymodel.Payment, - exec *model.PaymentGatewayExecution, -) (paymodel.PaymentState, error) { - - log := logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("operation_ref", strings.TrimSpace(exec.OperationRef)), - zap.String("gateway_status", string(exec.Status)), - ) - - log.Debug("gateway execution received") - - if payment == nil || payment.PaymentPlan == nil || exec == nil { - log.Warn("invalid input: payment/plan/exec is nil") - return paymodel.PaymentStateSubmitted, - merrors.DataConflict("payment is missing plan or execution step") - } - - operationRef := strings.TrimSpace(exec.OperationRef) - if operationRef == "" { - log.Warn("empty operation_ref from gateway") - return paymodel.PaymentStateSubmitted, - merrors.InvalidArgument("no operation reference provided") - } - - execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if execPlan == nil { - log.Warn("Execution plan missing") - return paymodel.PaymentStateSubmitted, merrors.InvalidArgument("execution plan missing") - } - - status := executionStepStatusFromGatewayStatus(exec.Status) - if status == "" { - log.Warn("Unknown gateway status") - return paymodel.PaymentStateSubmitted, - merrors.DataConflict(fmt.Sprintf("unknown gateway status: %s", exec.Status)) - } - - var matched bool - - for idx, execStep := range execPlan.Steps { - if execStep == nil { - continue - } - - if strings.EqualFold(strings.TrimSpace(execStep.OperationRef), operationRef) { - - log.Debug("Execution step matched", - zap.Int("step_index", idx), - zap.String("step_code", execStep.Code), - zap.String("prev_state", string(execStep.State)), - ) - - if execStep.TransferRef == "" && exec.TransferRef != "" { - execStep.TransferRef = strings.TrimSpace(exec.TransferRef) - log.Debug("Transfer_ref attached to step", zap.String("transfer_ref", execStep.TransferRef)) - } - - setExecutionStepStatus(execStep, status) - if exec.Error != "" && execStep.Error == "" { - execStep.Error = strings.TrimSpace(exec.Error) - } - - log.Debug("Execution step state updated", - zap.Int("step_index", idx), - zap.String("step_code", execStep.Code), - zap.String("new_state", string(execStep.State)), - ) - - matched = true - break - } - } - - if !matched { - log.Warn("No execution step found for operation_ref") - return paymodel.PaymentStateSubmitted, - merrors.InvalidArgument( - fmt.Sprintf("execution step not found for operation reference: %s", operationRef), - ) - } - - // -------- GLOBAL REDUCTION -------- - - var ( - hasSuccess bool - allDone = true - ) - - for idx, step := range execPlan.Steps { - if step == nil { - continue - } - - log.Debug("Evaluating step for payment state", - zap.Int("step_index", idx), - zap.String("step_code", step.Code), - zap.String("step_state", string(step.State)), - ) - - switch step.State { - case paymodel.OperationStateFailed: - payment.FailureReason = step.Error - log.Info("Payment marked as FAILED due to step failure", - zap.String("failed_step_code", step.Code), - zap.String("error", step.Error), - ) - return paymodel.PaymentStateFailed, nil - - case paymodel.OperationStateSuccess: - hasSuccess = true - - case paymodel.OperationStateSkipped: - // ok - - default: - allDone = false - } - } - - if hasSuccess && allDone { - log.Info("Payment marked as SUCCESS (all steps completed)") - return paymodel.PaymentStateSuccess, nil - } - - log.Info("Payment still PROCESSING (steps not finished)") - return paymodel.PaymentStateSubmitted, nil -} - -func executionStepStatusFromGatewayStatus(status rail.OperationResult) paymodel.OperationState { - switch status { - - case rail.OperationResultSuccess: - return paymodel.OperationStateSuccess - - case rail.OperationResultFailed: - return paymodel.OperationStateFailed - - case rail.OperationResultCancelled: - return paymodel.OperationStateCancelled - - default: - return paymodel.OperationStateFailed - } -} - -func (s *Service) Shutdown() { - if s == nil { - return - } - for _, consumer := range s.gatewayConsumers { - if consumer != nil { - consumer.Close() - } - } -} diff --git a/api/payments/quotation/internal/service/orchestrator/handlers_commands.go b/api/payments/quotation/internal/service/orchestrator/handlers_commands.go deleted file mode 100644 index 57e9cb40..00000000 --- a/api/payments/quotation/internal/service/orchestrator/handlers_commands.go +++ /dev/null @@ -1,922 +0,0 @@ -package orchestrator - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "sort" - "strings" - "time" - - "github.com/google/uuid" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - "github.com/tech/sendico/pkg/mutil/mzap" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" - "google.golang.org/protobuf/proto" -) - -type quotePaymentCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -var ( - errIdempotencyRequired = errors.New("idempotency key is required") - errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") - errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") -) - -type quoteCtx struct { - orgID string - orgRef bson.ObjectID - intent *orchestratorv1.PaymentIntent - previewOnly bool - idempotencyKey string - hash string -} - -func (h *quotePaymentCommand) Execute( - ctx context.Context, - req *orchestratorv1.QuotePaymentRequest, -) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { - - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - - qc, err := h.prepareQuoteCtx(req) - if err != nil { - return h.mapQuoteErr(err) - } - - quotesStore, err := ensureQuotesStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req) - if err != nil { - return h.mapQuoteErr(err) - } - - return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{ - IdempotencyKey: req.GetIdempotencyKey(), - Quote: quoteProto, - }) -} - -func (h *quotePaymentCommand) prepareQuoteCtx(req *orchestratorv1.QuotePaymentRequest) (*quoteCtx, error) { - orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return nil, err - } - if err := requireNonNilIntent(req.GetIntent()); err != nil { - return nil, err - } - - intent := req.GetIntent() - preview := req.GetPreviewOnly() - idem := strings.TrimSpace(req.GetIdempotencyKey()) - - if preview && idem != "" { - return nil, errPreviewWithIdempotency - } - if !preview && idem == "" { - return nil, errIdempotencyRequired - } - - return "eCtx{ - orgID: orgRef, - orgRef: orgID, - intent: intent, - previewOnly: preview, - idempotencyKey: idem, - hash: hashQuoteRequest(req), - }, nil -} - -func (h *quotePaymentCommand) quotePayment( - ctx context.Context, - quotesStore storage.QuotesStore, - qc *quoteCtx, - req *orchestratorv1.QuotePaymentRequest, -) (*orchestratorv1.PaymentQuote, error) { - - if qc.previewOnly { - quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) - if err != nil { - h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID)) - return nil, err - } - quote.QuoteRef = bson.NewObjectID().Hex() - return quote, nil - } - - existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) { - h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err), - mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey), - ) - return nil, err - } - if existing != nil { - if existing.Hash != qc.hash { - return nil, errIdempotencyParamMismatch - } - h.logger.Debug( - "Idempotent quote reused", - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("quote_ref", existing.QuoteRef), - ) - return modelQuoteToProto(existing.Quote), nil - } - - quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) - if err != nil { - h.logger.Warn( - "Failed to build payment quote", - zap.Error(err), - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - ) - return nil, err - } - - quoteRef := bson.NewObjectID().Hex() - quote.QuoteRef = quoteRef - - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - IdempotencyKey: qc.idempotencyKey, - Hash: qc.hash, - Intent: intentFromProto(qc.intent), - Quote: quoteSnapshotToModel(quote), - ExpiresAt: expiresAt, - } - record.SetID(bson.NewObjectID()) - record.SetOrganizationRef(qc.orgRef) - - if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, storage.ErrDuplicateQuote) { - existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if getErr == nil && existing != nil { - if existing.Hash != qc.hash { - return nil, errIdempotencyParamMismatch - } - return modelQuoteToProto(existing.Quote), nil - } - } - return nil, err - } - - h.logger.Info( - "Stored payment quote", - zap.String("quote_ref", quoteRef), - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("kind", qc.intent.GetKind().String()), - ) - - return quote, nil -} - -func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { - if errors.Is(err, errIdempotencyRequired) || - errors.Is(err, errPreviewWithIdempotency) || - errors.Is(err, errIdempotencyParamMismatch) { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) -} - -// TODO: temprorarary hashing function, replace with a proper solution later -func hashQuoteRequest(req *orchestratorv1.QuotePaymentRequest) string { - cloned := proto.Clone(req).(*orchestratorv1.QuotePaymentRequest) - cloned.Meta = nil - cloned.IdempotencyKey = "" - cloned.PreviewOnly = false - - b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned) - if err != nil { - sum := sha256.Sum256([]byte("marshal_error")) - return hex.EncodeToString(sum[:]) - } - - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} - -type quotePaymentsCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -var ( - errBatchIdempotencyRequired = errors.New("idempotency key is required") - errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") - errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") - errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape") -) - -type quotePaymentsCtx struct { - orgID string - orgRef bson.ObjectID - previewOnly bool - idempotencyKey string - hash string - intentCount int -} - -func (h *quotePaymentsCommand) Execute( - ctx context.Context, - req *orchestratorv1.QuotePaymentsRequest, -) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] { - - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - - qc, intents, err := h.prepare(req) - if err != nil { - return h.mapErr(err) - } - - quotesStore, err := ensureQuotesStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if qc.previewOnly { - quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, true) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - aggregate, expiresAt, err := h.aggregate(quotes, expires) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - _ = expiresAt - return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{ - QuoteRef: "", - Aggregate: aggregate, - Quotes: quotes, - }) - } - - if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } else if ok { - return gsresponse.Success(h.responseFromRecord(rec)) - } - - quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, false) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - aggregate, expiresAt, err := h.aggregate(quotes, expires) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - quoteRef := bson.NewObjectID().Hex() - for _, q := range quotes { - if q != nil { - q.QuoteRef = quoteRef - } - } - - rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, expiresAt) - if err != nil { - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if rec != nil { - return gsresponse.Success(h.responseFromRecord(rec)) - } - - h.logger.Info( - "Stored payment quotes", - h.logFields(qc, quoteRef, expiresAt, len(quotes))..., - ) - - return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{ - IdempotencyKey: req.GetIdempotencyKey(), - QuoteRef: quoteRef, - Aggregate: aggregate, - Quotes: quotes, - }) -} - -func (h *quotePaymentsCommand) prepare(req *orchestratorv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*orchestratorv1.PaymentIntent, error) { - orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return nil, nil, err - } - - intents := req.GetIntents() - if len(intents) == 0 { - return nil, nil, merrors.InvalidArgument("intents are required") - } - for _, intent := range intents { - if err := requireNonNilIntent(intent); err != nil { - return nil, nil, err - } - } - - preview := req.GetPreviewOnly() - idem := strings.TrimSpace(req.GetIdempotencyKey()) - - if preview && idem != "" { - return nil, nil, errBatchPreviewWithIdempotency - } - if !preview && idem == "" { - return nil, nil, errBatchIdempotencyRequired - } - - hash, err := hashQuotePaymentsIntents(intents) - if err != nil { - return nil, nil, err - } - - return "ePaymentsCtx{ - orgID: orgRefStr, - orgRef: orgID, - previewOnly: preview, - idempotencyKey: idem, - hash: hash, - intentCount: len(intents), - }, intents, nil -} - -func (h *quotePaymentsCommand) tryReuse( - ctx context.Context, - quotesStore storage.QuotesStore, - qc *quotePaymentsCtx, -) (*model.PaymentQuoteRecord, bool, error) { - - rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil { - if errors.Is(err, storage.ErrQuoteNotFound) { - return nil, false, nil - } - h.logger.Warn( - "Failed to lookup payment quotes by idempotency key", - h.logFields(qc, "", time.Time{}, 0)..., - ) - return nil, false, err - } - - if len(rec.Quotes) == 0 { - return nil, false, errBatchIdempotencyShapeMismatch - } - if rec.Hash != qc.hash { - return nil, false, errBatchIdempotencyParamMismatch - } - - h.logger.Debug( - "Idempotent payment quotes reused", - h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))..., - ) - - return rec, true, nil -} - -func (h *quotePaymentsCommand) buildQuotes( - ctx context.Context, - meta *orchestratorv1.RequestMeta, - baseKey string, - intents []*orchestratorv1.PaymentIntent, - preview bool, -) ([]*orchestratorv1.PaymentQuote, []time.Time, error) { - - quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents)) - expires := make([]time.Time, 0, len(intents)) - - for i, intent := range intents { - req := &orchestratorv1.QuotePaymentRequest{ - Meta: meta, - IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)), - Intent: intent, - PreviewOnly: preview, - } - q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req) - if err != nil { - h.logger.Warn( - "Failed to build payment quote (batch item)", - zap.Int("idx", i), - zap.Error(err), - ) - return nil, nil, err - } - quotes = append(quotes, q) - expires = append(expires, exp) - } - - return quotes, expires, nil -} - -func (h *quotePaymentsCommand) aggregate( - quotes []*orchestratorv1.PaymentQuote, - expires []time.Time, -) (*orchestratorv1.PaymentQuoteAggregate, time.Time, error) { - - agg, err := aggregatePaymentQuotes(quotes) - if err != nil { - return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed") - } - - expiresAt, ok := minQuoteExpiry(expires) - if !ok { - return nil, time.Time{}, merrors.Internal("quote expiry missing") - } - - return agg, expiresAt, nil -} - -func (h *quotePaymentsCommand) storeBatch( - ctx context.Context, - quotesStore storage.QuotesStore, - qc *quotePaymentsCtx, - quoteRef string, - intents []*orchestratorv1.PaymentIntent, - quotes []*orchestratorv1.PaymentQuote, - expiresAt time.Time, -) (*model.PaymentQuoteRecord, error) { - - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - IdempotencyKey: qc.idempotencyKey, - Hash: qc.hash, - Intents: intentsFromProto(intents), - Quotes: quoteSnapshotsFromProto(quotes), - ExpiresAt: expiresAt, - } - record.SetID(bson.NewObjectID()) - record.SetOrganizationRef(qc.orgRef) - - if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, storage.ErrDuplicateQuote) { - rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc) - if reuseErr != nil { - return nil, reuseErr - } - if ok { - return rec, nil - } - return nil, err - } - return nil, err - } - - return nil, nil -} - -func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *orchestratorv1.QuotePaymentsResponse { - quotes := modelQuotesToProto(rec.Quotes) - for _, q := range quotes { - if q != nil { - q.QuoteRef = rec.QuoteRef - } - } - aggregate, _ := aggregatePaymentQuotes(quotes) - - return &orchestratorv1.QuotePaymentsResponse{ - QuoteRef: rec.QuoteRef, - Aggregate: aggregate, - Quotes: quotes, - } -} - -func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field { - fields := []zap.Field{ - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("org_ref_str", qc.orgID), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("hash", qc.hash), - zap.Bool("preview_only", qc.previewOnly), - zap.Int("intent_count", qc.intentCount), - } - if quoteRef != "" { - fields = append(fields, zap.String("quote_ref", quoteRef)) - } - if !expiresAt.IsZero() { - fields = append(fields, zap.Time("expires_at", expiresAt)) - } - if quoteCount > 0 { - fields = append(fields, zap.Int("quote_count", quoteCount)) - } - return fields -} - -func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] { - if errors.Is(err, errBatchIdempotencyRequired) || - errors.Is(err, errBatchPreviewWithIdempotency) || - errors.Is(err, errBatchIdempotencyParamMismatch) || - errors.Is(err, errBatchIdempotencyShapeMismatch) { - return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) -} - -func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*orchestratorv1.PaymentQuote { - if len(snaps) == 0 { - return nil - } - out := make([]*orchestratorv1.PaymentQuote, 0, len(snaps)) - for _, s := range snaps { - out = append(out, modelQuoteToProto(s)) - } - return out -} - -func hashQuotePaymentsIntents(intents []*orchestratorv1.PaymentIntent) (string, error) { - type item struct { - Idx int - H [32]byte - } - items := make([]item, 0, len(intents)) - - for i, intent := range intents { - b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent) - if err != nil { - return "", err - } - items = append(items, item{Idx: i, H: sha256.Sum256(b)}) - } - - sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx }) - - h := sha256.New() - h.Write([]byte("quote-payments-fp/v1")) - h.Write([]byte{0}) - for _, it := range items { - h.Write(it.H[:]) - h.Write([]byte{0}) - } - - return hex.EncodeToString(h.Sum(nil)), nil -} - -type initiatePaymentsCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] { - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - _, orgRef, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - quoteRef := strings.TrimSpace(req.GetQuoteRef()) - if quoteRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required")) - } - - quotesStore, err := ensureQuotesStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef) - if err != nil { - if errors.Is(err, storage.ErrQuoteNotFound) { - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired")) - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - intents := record.Intents - quotes := record.Quotes - if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified { - intents = []model.PaymentIntent{record.Intent} - } - if len(quotes) == 0 && record.Quote != nil { - quotes = []*model.PaymentQuoteSnapshot{record.Quote} - } - if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete")) - } - - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - payments := make([]*orchestratorv1.Payment, 0, len(intents)) - for i := range intents { - intentProto := protoIntentFromModel(intents[i]) - if err := requireNonNilIntent(intentProto); err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - quoteProto := modelQuoteToProto(quotes[i]) - if quoteProto == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty")) - } - quoteProto.QuoteRef = quoteRef - - perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents)) - if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil { - payments = append(payments, toProtoPayment(existing)) - continue - } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto) - if err = store.Create(ctx, entity); err != nil { - if errors.Is(err, storage.ErrDuplicatePayment) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil { - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - payments = append(payments, toProtoPayment(entity)) - } - - h.logger.Info( - "Payments initiated", - mzap.ObjRef("org_ref", orgRef), - zap.String("quote_ref", quoteRef), - zap.String("idempotency_key", idempotencyKey), - zap.Int("payment_count", len(payments)), - ) - return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments}) -} - -type initiatePaymentCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] { - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - intent := req.GetIntent() - quoteRef := strings.TrimSpace(req.GetQuoteRef()) - hasIntent := intent != nil - hasQuote := quoteRef != "" - switch { - case !hasIntent && !hasQuote: - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required")) - case hasIntent && hasQuote: - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive")) - } - if hasIntent { - if err := requireNonNilIntent(intent); err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - } - idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Debug( - "Initiate payment request accepted", - mzap.ObjRef("org_ref", orgID), - zap.String("idempotency_key", idempotencyKey), - zap.String("quote_ref", quoteRef), - zap.Bool("has_intent", hasIntent), - ) - - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { - h.logger.Debug( - "idempotent payment request reused", - zap.String("payment_ref", existing.PaymentRef), - mzap.ObjRef("org_ref", orgID), - zap.String("idempotency_key", idempotencyKey), - zap.String("quote_ref", quoteRef), - ) - return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)}) - } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ - OrgRef: orgRef, - OrgID: orgID, - Meta: req.GetMeta(), - Intent: intent, - QuoteRef: quoteRef, - IdempotencyKey: req.GetIdempotencyKey(), - }) - if err != nil { - if qerr, ok := err.(quoteResolutionError); ok { - switch qerr.code { - case "quote_not_found": - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) - case "quote_expired": - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) - case "quote_intent_mismatch": - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err) - default: - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err) - } - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if quoteSnapshot == nil { - quoteSnapshot = &orchestratorv1.PaymentQuote{} - } - if err := requireNonNilIntent(resolvedIntent); err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Debug( - "Payment quote resolved", - mzap.ObjRef("org_ref", orgID), - zap.String("quote_ref", quoteRef), - zap.Bool("quote_ref_used", quoteRef != ""), - ) - - entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot) - - if err = store.Create(ctx, entity); err != nil { - if errors.Is(err, storage.ErrDuplicatePayment) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - h.logger.Info( - "Payment initiated", - zap.String("payment_ref", entity.PaymentRef), - mzap.ObjRef("org_ref", orgID), - zap.String("kind", resolvedIntent.GetKind().String()), - zap.String("quote_ref", quoteSnapshot.GetQuoteRef()), - zap.String("idempotency_key", idempotencyKey), - ) - return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{ - Payment: toProtoPayment(entity), - }) -} - -type cancelPaymentCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] { - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - paymentRef, err := requirePaymentRef(req.GetPaymentRef()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - payment, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err) - } - if payment.State != model.PaymentStateAccepted { - reason := merrors.InvalidArgument("payment cannot be cancelled in current state") - return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason) - } - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(req.GetReason()) - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex())) - return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)}) -} - -type initiateConversionCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] { - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req.GetSource() == nil || req.GetSource().GetLedger() == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required")) - } - if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required")) - } - fxIntent := req.GetFx() - if fxIntent == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required")) - } - - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { - h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), mzap.ObjRef("org_ref", orgID)) - return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)}) - } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - intentProto := &orchestratorv1.PaymentIntent{ - Ref: uuid.New().String(), - Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, - Source: req.GetSource(), - Destination: req.GetDestination(), - Amount: amount, - RequiresFx: true, - Fx: fxIntent, - FeePolicy: req.GetFeePolicy(), - SettlementCurrency: strings.TrimSpace(amount.GetCurrency()), - } - - quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ - Meta: req.GetMeta(), - IdempotencyKey: req.GetIdempotencyKey(), - Intent: intentProto, - }) - if err != nil { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote) - - if err = store.Create(ctx, entity); err != nil { - if errors.Is(err, storage.ErrDuplicatePayment) { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), mzap.ObjRef("org_ref", orgID)) - return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{ - Conversion: toProtoPayment(entity), - }) -} diff --git a/api/payments/quotation/internal/service/orchestrator/handlers_events.go b/api/payments/quotation/internal/service/orchestrator/handlers_events.go deleted file mode 100644 index c4d75792..00000000 --- a/api/payments/quotation/internal/service/orchestrator/handlers_events.go +++ /dev/null @@ -1,318 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" -) - -type paymentEventHandler struct { - repo storage.Repository - ensureRepo func(ctx context.Context) error - logger mlogger.Logger - submitCardPayout func(ctx context.Context, operationRef string, payment *model.Payment) error - resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error - releaseHold func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error -} - -func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger, submitCardPayout func(ctx context.Context, operationRef string, payment *model.Payment) error, resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error, releaseHold func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error) *paymentEventHandler { - return &paymentEventHandler{ - repo: repo, - ensureRepo: ensure, - logger: logger, - submitCardPayout: submitCardPayout, - resumePlan: resumePlan, - releaseHold: releaseHold, - } -} - -func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil { - return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required")) - } - transfer := req.GetEvent().GetTransfer() - transferRef := strings.TrimSpace(transfer.GetTransferRef()) - if transferRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required")) - } - store := h.repo.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - payment, err := store.GetByChainTransferRef(ctx, transferRef) - if err != nil { - return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err) - } - if payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { - if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != len(payment.PaymentPlan.Steps) { - ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - } - updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent()) - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.ChainTransferRef == "" { - payment.Execution.ChainTransferRef = transferRef - } - reason := transferFailureReason(req.GetEvent()) - switch transfer.GetStatus() { - case chainv1.TransferStatus_TRANSFER_FAILED: - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeChain - payment.FailureReason = reason - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - case chainv1.TransferStatus_TRANSFER_CANCELLED: - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = reason - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - case chainv1.TransferStatus_TRANSFER_SUCCESS: - if h.resumePlan != nil { - if err := h.resumePlan(ctx, store, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - case chainv1.TransferStatus_TRANSFER_WAITING: - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - default: - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - } - } - - updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent()) - if payment.Intent.Destination.Type == model.EndpointTypeCard { - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.ChainTransferRef == "" { - payment.Execution.ChainTransferRef = transferRef - } - reason := transferFailureReason(req.GetEvent()) - switch transfer.GetStatus() { - case chainv1.TransferStatus_TRANSFER_FAILED: - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeChain - payment.FailureReason = reason - case chainv1.TransferStatus_TRANSFER_CANCELLED: - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = reason - case chainv1.TransferStatus_TRANSFER_SUCCESS: - if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { - if cardPayoutDependenciesConfirmed(payment.PaymentPlan, payment.ExecutionPlan) { - if payment.Execution.CardPayoutRef == "" { - payment.State = model.PaymentStateFundsReserved - if h.submitCardPayout == nil { - h.logger.Warn("card payout execution skipped", zap.String("payment_ref", payment.PaymentRef)) - } else if err := h.submitCardPayout(ctx, transfer.GetOperationRef(), payment); err != nil { - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(err.Error()) - h.logger.Warn("card payout execution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - } - } - } - } - case chainv1.TransferStatus_TRANSFER_WAITING: - default: - // keep current state - } - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - } - - applyTransferStatus(req.GetEvent(), payment) - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) -} - -func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil || req.GetEvent() == nil { - return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required")) - } - event := req.GetEvent() - walletRef := strings.TrimSpace(event.GetWalletRef()) - if walletRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required")) - } - store := h.repo.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - filter := &model.PaymentFilter{ - States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved}, - DestinationRef: walletRef, - } - result, err := store.List(ctx, filter) - if err != nil { - return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) - } - for _, payment := range result.Items { - if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet { - continue - } - if !moneyEquals(payment.Intent.Amount, event.GetAmount()) { - continue - } - payment.State = model.PaymentStateSettled - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.ChainTransferRef == "" { - payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash()) - } - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef)) - return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)}) - } - return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{}) -} - -func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil { - return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required")) - } - payout := req.GetEvent().GetPayout() - paymentRef := strings.TrimSpace(payout.GetPayoutId()) - if paymentRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required")) - } - - store := h.repo.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - payment, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err) - } - - applyCardPayoutUpdate(payment, payout) - - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - h.logger.Info("card payout success received", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.String("payment_state_before", string(payment.State)), - zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), - zap.Bool("resume_plan_present", h.resumePlan != nil), - ) - - if h.resumePlan != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { - if err := h.resumePlan(ctx, store, payment); err != nil { - h.logger.Error("resumePlan failed after payout success", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.Error(err), - ) - return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("resumePlan executed after payout success", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - ) - } else { - h.logger.Warn("payout success but plan cannot be resumed", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.Bool("resume_plan_present", h.resumePlan != nil), - zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), - ) - } - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - h.logger.Warn("card payout failed", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.String("provider_message", payout.GetProviderMessage()), - ) - - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) - - if h.releaseHold != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { - h.logger.Info("releasing hold after payout failure", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - ) - - if err := h.releaseHold(ctx, store, payment); err != nil { - h.logger.Error("releaseHold failed after payout failure", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.Error(err), - ) - return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - } else { - h.logger.Warn("payout failed but hold cannot be released", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.Bool("release_hold_present", h.releaseHold != nil), - zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), - ) - } - } - - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State)) - return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{ - Payment: toProtoPayment(payment), - }) -} - -func transferFailureReason(event *chainv1.TransferStatusChangedEvent) string { - if event == nil || event.GetTransfer() == nil { - return "" - } - reason := strings.TrimSpace(event.GetReason()) - if reason != "" { - return reason - } - return strings.TrimSpace(event.GetTransfer().GetFailureReason()) -} diff --git a/api/payments/quotation/internal/service/orchestrator/handlers_queries.go b/api/payments/quotation/internal/service/orchestrator/handlers_queries.go deleted file mode 100644 index 9ae60074..00000000 --- a/api/payments/quotation/internal/service/orchestrator/handlers_queries.go +++ /dev/null @@ -1,80 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" -) - -type paymentQueryHandler struct { - repo storage.Repository - ensureRepo func(ctx context.Context) error - logger mlogger.Logger -} - -func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler { - return &paymentQueryHandler{ - repo: repo, - ensureRepo: ensure, - logger: logger, - } -} - -func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - paymentRef, err := requirePaymentRef(req.GetPaymentRef()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - store, err := ensurePaymentsStore(h.repo) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - entity, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err) - } - h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef)) - return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)}) -} - -func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - store, err := ensurePaymentsStore(h.repo) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - filter := filterFromProto(req) - result, err := store.List(ctx, filter) - if err != nil { - return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - resp := &orchestratorv1.ListPaymentsResponse{ - Page: &paginationv1.CursorPageResponse{ - NextCursor: result.NextCursor, - }, - } - resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items)) - for _, item := range result.Items { - resp.Payments = append(resp.Payments, toProtoPayment(item)) - } - h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor())) - return gsresponse.Success(resp) -} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_executor.go b/api/payments/quotation/internal/service/orchestrator/payment_executor.go deleted file mode 100644 index c6e09490..00000000 --- a/api/payments/quotation/internal/service/orchestrator/payment_executor.go +++ /dev/null @@ -1,237 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" -) - -type paymentExecutor struct { - deps *serviceDependencies - logger mlogger.Logger - svc *Service -} - -func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor { - return &paymentExecutor{deps: deps, logger: logger, svc: svc} -} - -func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { - if store == nil { - return errStorageUnavailable - } - if p.svc == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "service_unavailable", errStorageUnavailable) - } - if p.svc.storage == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable) - } - routeStore := p.svc.storage.Routes() - if routeStore == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable) - } - planTemplates := p.svc.storage.PlanTemplates() - if planTemplates == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "plan_templates_store_unavailable", errStorageUnavailable) - } - builder := p.svc.deps.planBuilder - if builder == nil { - builder = newDefaultPlanBuilder(p.logger) - } - plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry) - if err != nil { - p.logPlanBuilderFailure(payment, err) - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) - } - if plan == nil || len(plan.Steps) == 0 { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_empty", merrors.InvalidArgument("payment plan is required")) - } - payment.PaymentPlan = plan - - return p.executePaymentPlan(ctx, store, payment, quote) -} - -func (p *paymentExecutor) logPlanBuilderFailure(payment *model.Payment, err error) { - if p == nil || payment == nil { - return - } - intent := payment.Intent - sourceRail, sourceNetwork, sourceErr := railFromEndpoint(intent.Source, intent.Attributes, true) - destRail, destNetwork, destErr := railFromEndpoint(intent.Destination, intent.Attributes, false) - - fields := []zap.Field{ - zap.Error(err), - zap.String("payment_ref", payment.PaymentRef), - zap.String("org_ref", payment.OrganizationRef.Hex()), - zap.String("idempotency_key", payment.IdempotencyKey), - zap.String("source_rail", string(sourceRail)), - zap.String("destination_rail", string(destRail)), - zap.String("source_network", sourceNetwork), - zap.String("destination_network", destNetwork), - zap.String("source_endpoint_type", string(intent.Source.Type)), - zap.String("destination_endpoint_type", string(intent.Destination.Type)), - } - - missing := make([]string, 0, 2) - if sourceErr != nil || sourceRail == model.RailUnspecified { - missing = append(missing, "source") - if sourceErr != nil { - fields = append(fields, zap.String("source_rail_error", sourceErr.Error())) - } - } - if destErr != nil || destRail == model.RailUnspecified { - missing = append(missing, "destination") - if destErr != nil { - fields = append(fields, zap.String("destination_rail_error", destErr.Error())) - } - } - if len(missing) > 0 { - fields = append(fields, zap.String("missing_rails", strings.Join(missing, ","))) - p.logger.Warn("Payment rail resolution failed", fields...) - return - } - - routeNetwork, routeErr := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) - if routeErr != nil { - fields = append(fields, zap.String("route_network_error", routeErr.Error())) - } else if routeNetwork != "" { - fields = append(fields, zap.String("route_network", routeNetwork)) - } - p.logger.Warn("Payment route missing for rails", fields...) -} - -func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { - intent := payment.Intent - source := intent.Source.Ledger - destination := intent.Destination.Ledger - if source == nil || destination == nil { - return merrors.InvalidArgument("ledger: fx conversion requires ledger source and destination") - } - fq := quote.GetFxQuote() - if fq == nil { - return merrors.InvalidArgument("ledger: fx quote missing") - } - fxSide := fxv1.Side_SIDE_UNSPECIFIED - if intent.FX != nil { - fxSide = fxSideToProto(intent.FX.Side) - } - fromMoney, toMoney := resolveTradeAmounts(protoMoney(intent.Amount), fq, fxSide) - if fromMoney == nil { - fromMoney = protoMoney(intent.Amount) - } - if toMoney == nil { - toMoney = cloneProtoMoney(quote.GetExpectedSettlementAmount()) - } - rate := "" - if fq.GetPrice() != nil { - rate = fq.GetPrice().GetValue() - } - req := &ledgerv1.FXRequest{ - IdempotencyKey: payment.IdempotencyKey, - OrganizationRef: payment.OrganizationRef.Hex(), - FromLedgerAccountRef: strings.TrimSpace(source.LedgerAccountRef), - ToLedgerAccountRef: strings.TrimSpace(destination.LedgerAccountRef), - FromMoney: fromMoney, - ToMoney: toMoney, - Rate: rate, - Description: description, - Charges: charges, - Metadata: metadata, - } - resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req) - if err != nil { - return err - } - exec.FXEntryRef = strings.TrimSpace(resp.GetJournalEntryRef()) - payment.Execution = exec - return nil -} - -func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if store == nil { - return errStorageUnavailable - } - return store.Update(ctx, payment) -} - -func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error { - payment.State = model.PaymentStateFailed - payment.FailureCode = code - payment.FailureReason = strings.TrimSpace(reason) - if store != nil { - if updateErr := store.Update(ctx, payment); updateErr != nil { - p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) - } - } - if err != nil { - return err - } - return merrors.Internal(reason) -} - -func paymentDescription(payment *model.Payment) string { - if payment == nil { - return "" - } - if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" { - return val - } - if payment.Metadata != nil { - if val := strings.TrimSpace(payment.Metadata["description"]); val != "" { - return val - } - } - return payment.PaymentRef -} - -func applyTransferStatus(event *chainv1.TransferStatusChangedEvent, payment *model.Payment) { - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if event == nil || event.GetTransfer() == nil { - return - } - transfer := event.GetTransfer() - payment.Execution.ChainTransferRef = strings.TrimSpace(transfer.GetTransferRef()) - reason := strings.TrimSpace(event.GetReason()) - if reason == "" { - reason = strings.TrimSpace(transfer.GetFailureReason()) - } - switch transfer.GetStatus() { - - case chainv1.TransferStatus_TRANSFER_SUCCESS: - payment.State = model.PaymentStateSettled - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - - case chainv1.TransferStatus_TRANSFER_FAILED: - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeChain - payment.FailureReason = reason - - case chainv1.TransferStatus_TRANSFER_CANCELLED: - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = reason - - case chainv1.TransferStatus_TRANSFER_WAITING: - payment.State = model.PaymentStateSubmitted - - case chainv1.TransferStatus_TRANSFER_CREATED, - chainv1.TransferStatus_TRANSFER_PROCESSING: - // do nothing, retain previous state - - default: - // retain previous state - } - -} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_analyzer.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_analyzer.go deleted file mode 100644 index 0e19e24b..00000000 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_analyzer.go +++ /dev/null @@ -1,123 +0,0 @@ -package orchestrator - -import ( - "errors" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - "go.uber.org/zap" -) - -type Liveness string - -const ( - StepFinal Liveness = "final" - StepRunnable Liveness = "runnable" - StepBlocked Liveness = "blocked" - StepDead Liveness = "dead" -) - -func buildPaymentStepIndex(plan *model.PaymentPlan) map[string]*model.PaymentStep { - idx := make(map[string]*model.PaymentStep, len(plan.Steps)) - for _, s := range plan.Steps { - idx[s.StepID] = s - } - return idx -} - -func buildExecutionStepIndex(plan *model.ExecutionPlan) map[string]*model.ExecutionStep { - index := make(map[string]*model.ExecutionStep, len(plan.Steps)) - for _, s := range plan.Steps { - if s == nil { - continue - } - index[s.Code] = s - } - return index -} - -func stepLiveness( - logger mlogger.Logger, - step *model.ExecutionStep, - pStepIdx map[string]*model.PaymentStep, - eStepIdx map[string]*model.ExecutionStep, -) Liveness { - - if step.IsTerminal() { - return StepFinal - } - - pStep, ok := pStepIdx[step.Code] - if !ok { - logger.Error("step missing in payment plan", - zap.String("step_id", step.Code), - ) - return StepDead - } - - for _, depID := range pStep.DependsOn { - dep := eStepIdx[depID] - if dep == nil { - logger.Warn("dependency missing in execution plan", - zap.String("step_id", step.Code), - zap.String("dep_id", depID), - ) - continue - } - - switch dep.State { - case model.OperationStateFailed: - return StepDead - } - } - - allSuccess := true - for _, depID := range pStep.DependsOn { - dep := eStepIdx[depID] - if dep == nil || dep.State != model.OperationStateSuccess { - allSuccess = false - break - } - } - - if allSuccess { - return StepRunnable - } - - return StepBlocked -} - -func analyzeExecutionPlan( - logger mlogger.Logger, - payment *model.Payment, -) (bool, bool, error) { - - if payment == nil || payment.ExecutionPlan == nil { - return true, false, nil - } - - eIdx := buildExecutionStepIndex(payment.ExecutionPlan) - pIdx := buildPaymentStepIndex(payment.PaymentPlan) - - hasRunnable := false - hasFailed := false - var rootErr error - - for _, s := range payment.ExecutionPlan.Steps { - live := stepLiveness(logger, s, pIdx, eIdx) - - if live == StepRunnable { - hasRunnable = true - } - - if s.State == model.OperationStateFailed { - hasFailed = true - if rootErr == nil && s.Error != "" { - rootErr = errors.New(s.Error) - } - } - } - - done := !hasRunnable - return done, hasFailed, rootErr -} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_card.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_card.go deleted file mode 100644 index 2a1fb32a..00000000 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_card.go +++ /dev/null @@ -1,196 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "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" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" -) - -func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *model.Payment, operationRef string, amount *moneyv1.Money, fromRole, toRole *account_role.AccountRole) (string, error) { - if payment == nil { - return "", merrors.InvalidArgument("payment is required") - } - if !p.deps.mntx.available() { - return "", merrors.Internal("card_gateway_unavailable") - } - intent := payment.Intent - card := intent.Destination.Card - if card == nil { - return "", merrors.InvalidArgument("card payout: card endpoint is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("card payout: amount is required") - } - - amtDec, err := decimalFromMoney(amount) - if err != nil { - return "", err - } - minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() - - payoutID := payment.PaymentRef - currency := strings.TrimSpace(amount.GetCurrency()) - holder := strings.TrimSpace(card.Cardholder) - meta := cloneMetadata(payment.Metadata) - if strings.TrimSpace(string(mergeAccountRole(fromRole))) != "" { - if meta == nil { - meta = map[string]string{} - } - meta[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(mergeAccountRole(fromRole))) - } - if strings.TrimSpace(string(mergeAccountRole(toRole))) != "" { - if meta == nil { - meta = map[string]string{} - } - meta[account_role.MetadataKeyToRole] = strings.TrimSpace(string(mergeAccountRole(toRole))) - } - customer := intent.Customer - customerID := "" - customerFirstName := "" - customerMiddleName := "" - customerLastName := "" - customerIP := "" - customerZip := "" - customerCountry := "" - customerState := "" - customerCity := "" - customerAddress := "" - if customer != nil { - customerID = strings.TrimSpace(customer.ID) - customerFirstName = strings.TrimSpace(customer.FirstName) - customerMiddleName = strings.TrimSpace(customer.MiddleName) - customerLastName = strings.TrimSpace(customer.LastName) - customerIP = strings.TrimSpace(customer.IP) - customerZip = strings.TrimSpace(customer.Zip) - customerCountry = strings.TrimSpace(customer.Country) - customerState = strings.TrimSpace(customer.State) - customerCity = strings.TrimSpace(customer.City) - customerAddress = strings.TrimSpace(customer.Address) - } - if customerFirstName == "" { - customerFirstName = strings.TrimSpace(card.Cardholder) - } - if customerLastName == "" { - customerLastName = strings.TrimSpace(card.CardholderSurname) - } - if customerID == "" { - return "", merrors.InvalidArgument("card payout: customer id is required") - } - if customerFirstName == "" { - return "", merrors.InvalidArgument("card payout: customer first name is required") - } - if customerLastName == "" { - return "", merrors.InvalidArgument("card payout: customer last name is required") - } - if customerIP == "" { - return "", merrors.InvalidArgument("card payout: customer ip is required") - } - - var state *mntxv1.CardPayoutState - if token := strings.TrimSpace(card.Token); token != "" { - req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardToken: token, - CardHolder: holder, - MaskedPan: strings.TrimSpace(card.MaskedPan), - Metadata: meta, - OperationRef: operationRef, - IntentRef: payment.Intent.Ref, - IdempotencyKey: payment.IdempotencyKey, - } - resp, err := p.deps.mntx.client.CreateCardTokenPayout(ctx, req) - if err != nil { - return "", err - } - state = resp.GetPayout() - } else if pan := strings.TrimSpace(card.Pan); pan != "" { - req := &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardPan: pan, - CardExpYear: card.ExpYear, - CardExpMonth: card.ExpMonth, - CardHolder: holder, - Metadata: meta, - OperationRef: operationRef, - IntentRef: payment.Intent.Ref, - IdempotencyKey: payment.IdempotencyKey, - } - resp, err := p.deps.mntx.client.CreateCardPayout(ctx, req) - if err != nil { - return "", err - } - state = resp.GetPayout() - } else { - return "", merrors.InvalidArgument("card payout: either token or pan must be provided") - } - - if state == nil { - return "", merrors.Internal("card payout: missing payout state") - } - recordCardPayoutState(payment, state) - exec := ensureExecutionRefs(payment) - if exec.CardPayoutRef == "" { - exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) - } - return exec.CardPayoutRef, nil -} - -func mergeAccountRole(role *account_role.AccountRole) account_role.AccountRole { - if role == nil { - return "" - } - return account_role.AccountRole(strings.TrimSpace(string(*role))) -} - -func (p *paymentExecutor) resolveCardRoute(intent model.PaymentIntent) (CardGatewayRoute, error) { - if p.svc != nil { - return p.svc.cardRoute(p.gatewayKeyFromIntent(intent)) - } - key := p.gatewayKeyFromIntent(intent) - route, ok := p.deps.cardRoutes[key] - if !ok { - return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) - } - if strings.TrimSpace(route.FundingAddress) == "" { - return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) - } - return route, nil -} - -func (p *paymentExecutor) gatewayKeyFromIntent(intent model.PaymentIntent) string { - key := strings.TrimSpace(intent.Attributes["gateway"]) - if key == "" && intent.Destination.Card != nil { - key = defaultCardGateway - } - return strings.ToLower(key) -} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_chain.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_chain.go deleted file mode 100644 index b495a7c9..00000000 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_chain.go +++ /dev/null @@ -1,116 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" -) - -func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amount *paymenttypes.Money, action model.RailOperation, idempotencyKey, operationRef string, quote *orchestratorv1.PaymentQuote, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) { - if payment == nil { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment is required") - } - if amount == nil { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required") - } - source := payment.Intent.Source.ManagedWallet - if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: source managed wallet is required") - } - destRef, memo, err := p.resolveCryptoDestination(payment, action) - if err != nil { - return rail.TransferRequest{}, err - } - paymentRef := strings.TrimSpace(payment.PaymentRef) - if paymentRef == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment reference is required") - } - req := rail.TransferRequest{ - IntentRef: strings.TrimSpace(payment.Intent.Ref), - OperationRef: strings.TrimSpace(operationRef), - OrganizationRef: payment.OrganizationRef.Hex(), - PaymentRef: strings.TrimSpace(payment.PaymentRef), - FromAccountID: strings.TrimSpace(source.ManagedWalletRef), - ToAccountID: strings.TrimSpace(destRef), - Currency: strings.TrimSpace(amount.GetCurrency()), - Network: strings.TrimSpace(cryptoNetworkForPayment(payment)), - Amount: strings.TrimSpace(amount.GetAmount()), - IdempotencyKey: strings.TrimSpace(idempotencyKey), - Metadata: cloneMetadata(payment.Metadata), - DestinationMemo: memo, - } - if fromRole != nil { - req.FromRole = *fromRole - } - if toRole != nil { - req.ToRole = *toRole - } - if req.Currency == "" || req.Amount == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required") - } - if req.IdempotencyKey == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: idempotency_key is required") - } - return req, nil -} - -func (p *paymentExecutor) resolveCryptoDestination(payment *model.Payment, action model.RailOperation) (string, string, error) { - if payment == nil { - return "", "", merrors.InvalidArgument("chain: payment is required") - } - intent := payment.Intent - switch intent.Destination.Type { - case model.EndpointTypeManagedWallet: - if action == model.RailOperationSend { - if intent.Destination.ManagedWallet == nil || strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef) == "" { - return "", "", merrors.InvalidArgument("chain: destination managed wallet is required") - } - return strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef), "", nil - } - case model.EndpointTypeExternalChain: - if action == model.RailOperationSend { - if intent.Destination.ExternalChain == nil || strings.TrimSpace(intent.Destination.ExternalChain.Address) == "" { - return "", "", merrors.InvalidArgument("chain: external address is required") - } - return strings.TrimSpace(intent.Destination.ExternalChain.Address), strings.TrimSpace(intent.Destination.ExternalChain.Memo), nil - } - } - route, err := p.resolveCardRoute(intent) - if err != nil { - return "", "", err - } - switch action { - case model.RailOperationSend: - address := strings.TrimSpace(route.FundingAddress) - if address == "" { - return "", "", merrors.InvalidArgument("chain: funding address is required") - } - return address, "", nil - case model.RailOperationFee: - if walletRef := strings.TrimSpace(route.FeeWalletRef); walletRef != "" { - return walletRef, "", nil - } - if address := strings.TrimSpace(route.FeeAddress); address != "" { - return address, "", nil - } - return "", "", merrors.InvalidArgument("chain: fee destination is required") - default: - return "", "", merrors.InvalidArgument("chain: unsupported action") - } -} - -func cryptoNetworkForPayment(payment *model.Payment) string { - if payment == nil { - return "" - } - network := networkFromEndpoint(payment.Intent.Source) - if network != "" { - return network - } - return networkFromEndpoint(payment.Intent.Destination) -} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_executor.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_executor.go deleted file mode 100644 index f90780ad..00000000 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_executor.go +++ /dev/null @@ -1,208 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" -) - -func buildStepIndex(plan *model.PaymentPlan) map[string]int { - m := make(map[string]int, len(plan.Steps)) - for i, s := range plan.Steps { - if s == nil { - continue - } - m[s.StepID] = i - } - return m -} - -func isPlanComplete(payment *model.Payment) bool { - if (payment.State == model.PaymentStateCancelled) || - (payment.State == model.PaymentStateSettled) || - (payment.State == model.PaymentStateFailed) { - return true - } - return false -} - -func isStepFinal(step *model.ExecutionStep) bool { - if (step.State == model.OperationStateFailed) || (step.State == model.OperationStateSuccess) || (step.State == model.OperationStateCancelled) { - return true - } - return false -} - -func (p *paymentExecutor) pickIndependentSteps( - ctx context.Context, - l *zap.Logger, - store storage.PaymentsStore, - waiting []*model.ExecutionStep, - payment *model.Payment, - quote *orchestratorv1.PaymentQuote, -) error { - - logger := l.With(zap.Int("waiting_steps", len(waiting))) - logger.Debug("Selecting independent steps for execution") - - execSteps := executionStepsByCode(payment.ExecutionPlan) - planSteps := planStepsByID(payment.PaymentPlan) - execQuote := executionQuote(payment, quote) - charges := ledgerChargesFromFeeLines(execQuote.GetFeeLines()) - stepIdx := buildStepIndex(payment.PaymentPlan) - - for _, execStep := range waiting { - if execStep == nil { - continue - } - - lg := logger.With( - zap.String("step_code", execStep.Code), - zap.String("step_state", string(execStep.State)), - ) - - planStep := planSteps[execStep.Code] - if planStep == nil { - lg.Warn("Plan step not found") - continue - } - - ready, waitingDep, blocked, err := - stepDependenciesReady(planStep, execSteps, planSteps, true) - - if err != nil { - lg.Warn("Dependency evaluation failed", zap.Error(err)) - continue - } - - if blocked { - lg.Debug("Step permanently blocked by dependency failure") - setExecutionStepStatus(execStep, model.OperationStateCancelled) - continue - } - - if waitingDep { - lg.Debug("Step waiting for dependencies") - continue - } - - if !ready { - continue - } - - lg.Debug("Executing independent step") - idx := stepIdx[execStep.Code] - - async, err := p.executePlanStep( - ctx, - payment, - planStep, - execStep, - quote, - charges, - idx, - ) - if err != nil { - lg.Warn("Step execution failed", zap.Error(err), zap.Bool("async", async)) - return err - } - } - - return nil -} - -func (p *paymentExecutor) pickWaitingSteps( - ctx context.Context, - l *zap.Logger, - store storage.PaymentsStore, - payment *model.Payment, - quote *orchestratorv1.PaymentQuote, -) error { - if payment == nil || payment.ExecutionPlan == nil { - l.Debug("No execution plan") - return nil - } - - logger := l.With(zap.Int("total_steps", len(payment.ExecutionPlan.Steps))) - logger.Debug("Collecting waiting steps") - - waitingSteps := make([]*model.ExecutionStep, 0, len(payment.ExecutionPlan.Steps)) - for _, step := range payment.ExecutionPlan.Steps { - if step == nil { - continue - } - if step.State != model.OperationStatePlanned { - continue - } - waitingSteps = append(waitingSteps, step) - } - - if len(waitingSteps) == 0 { - logger.Debug("No waiting steps to process") - return nil - } - - return p.pickIndependentSteps(ctx, logger, store, waitingSteps, payment, quote) -} - -func (p *paymentExecutor) executePaymentPlan( - ctx context.Context, - store storage.PaymentsStore, - payment *model.Payment, - quote *orchestratorv1.PaymentQuote, -) error { - - if payment == nil { - return merrors.InvalidArgument("plan must be provided") - } - - logger := p.logger.With(zap.String("payment_ref", payment.PaymentRef)) - logger.Debug("Starting plan execution") - - if isPlanComplete(payment) { - logger.Debug("Plan already completed") - return nil - } - - if payment.ExecutionPlan == nil { - logger.Debug("Initializing execution plan from payment plan") - payment.ExecutionPlan = ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if err := store.Update(ctx, payment); err != nil { - return err - } - } - - // Execute steps - if err := p.pickWaitingSteps(ctx, logger, store, payment, quote); err != nil { - logger.Warn("Step execution returned infrastructure error", zap.Error(err)) - } - - if err := store.Update(ctx, payment); err != nil { - return err - } - - done, failed, rootErr := analyzeExecutionPlan(logger, payment) - if !done { - return nil - } - - if failed { - payment.State = model.PaymentStateFailed - } else { - payment.State = model.PaymentStateSettled - } - - if err := store.Update(ctx, payment); err != nil { - logger.Warn("Failed to update final payment state", zap.Error(err)) - return err - } - - if failed && rootErr != nil { - return rootErr - } - return nil -} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_ledger.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_ledger.go deleted file mode 100644 index 594cafa5..00000000 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_ledger.go +++ /dev/null @@ -1,596 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/ledgerconv" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func (p *paymentExecutor) postLedgerDebit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (string, error) { - paymentRef := "" - if payment != nil { - paymentRef = strings.TrimSpace(payment.PaymentRef) - } - if p.deps.ledger.internal == nil { - p.logger.Error("Ledger client unavailable", zap.String("action", "debit"), zap.String("payment_ref", paymentRef)) - return "", merrors.Internal("ledger_client_unavailable") - } - tx, err := p.ledgerTxForAction(ctx, payment, amount, charges, idempotencyKey, idx, action, quote) - if err != nil { - p.logger.Warn("Ledger debit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err)) - return "", err - } - ref, err := p.deps.ledger.internal.CreateTransaction(ctx, tx) - if err != nil { - p.logger.Warn("Ledger debit failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err)) - return "", err - } - p.logger.Info("Ledger debit posted", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("action", string(action)), - zap.String("entry_ref", strings.TrimSpace(ref))) - return ref, nil -} - -func (p *paymentExecutor) postLedgerCredit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (string, error) { - paymentRef := "" - if payment != nil { - paymentRef = strings.TrimSpace(payment.PaymentRef) - } - if p.deps.ledger.internal == nil { - p.logger.Error("Ledger client unavailable", zap.String("action", "credit"), zap.String("payment_ref", paymentRef)) - return "", merrors.Internal("ledger_client_unavailable") - } - tx, err := p.ledgerTxForAction(ctx, payment, amount, nil, idempotencyKey, idx, action, quote) - if err != nil { - p.logger.Warn("Ledger credit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err)) - return "", err - } - ref, err := p.deps.ledger.internal.CreateTransaction(ctx, tx) - if err != nil { - p.logger.Warn("Ledger credit failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err)) - return "", err - } - p.logger.Info("Ledger credit posted", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("action", string(action)), - zap.String("entry_ref", strings.TrimSpace(ref))) - return ref, nil -} - -func (p *paymentExecutor) postLedgerMove(ctx context.Context, payment *model.Payment, step *model.PaymentStep, amount *moneyv1.Money, idempotencyKey string, idx int) (string, error) { - paymentRef := "" - if payment != nil { - paymentRef = strings.TrimSpace(payment.PaymentRef) - } - if p.deps.ledger.internal == nil { - p.logger.Error("Ledger client unavailable", zap.String("action", "move"), zap.String("payment_ref", paymentRef)) - return "", merrors.Internal("ledger_client_unavailable") - } - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - if payment.OrganizationRef == bson.NilObjectID { - return "", merrors.InvalidArgument("ledger: organization_ref is required") - } - if step == nil { - return "", merrors.InvalidArgument("ledger: step is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("ledger: amount is required") - } - fromRole, toRole, err := ledgerMoveRoles(step) - if err != nil { - return "", err - } - currency := strings.TrimSpace(amount.GetCurrency()) - fromAccount, err := p.resolveAccount(ctx, payment.OrganizationRef, currency, model.RailLedger, fromRole) - if err != nil { - return "", err - } - toAccount, err := p.resolveAccount(ctx, payment.OrganizationRef, currency, model.RailLedger, toRole) - if err != nil { - return "", err - } - resp, err := p.deps.ledger.internal.TransferInternal(ctx, &ledgerv1.TransferRequest{ - IdempotencyKey: strings.TrimSpace(idempotencyKey), - OrganizationRef: payment.OrganizationRef.Hex(), - FromLedgerAccountRef: strings.TrimSpace(fromAccount), - ToLedgerAccountRef: strings.TrimSpace(toAccount), - Money: cloneProtoMoney(amount), - Description: paymentDescription(payment), - Metadata: cloneMetadata(payment.Metadata), - FromRole: ledgerRoleFromAccountRole(fromRole), - ToRole: ledgerRoleFromAccountRole(toRole), - }) - if err != nil { - p.logger.Warn("Ledger move failed", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("from_role", string(fromRole)), - zap.String("to_role", string(toRole)), - zap.String("from_account", strings.TrimSpace(fromAccount)), - zap.String("to_account", strings.TrimSpace(toAccount)), - zap.String("amount", strings.TrimSpace(amount.GetAmount())), - zap.String("currency", currency), - zap.Error(err)) - return "", err - } - entryRef := strings.TrimSpace(resp.GetJournalEntryRef()) - p.logger.Info("Ledger move posted", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("entry_ref", entryRef), - zap.String("from_role", string(fromRole)), - zap.String("to_role", string(toRole)), - zap.String("from_account", strings.TrimSpace(fromAccount)), - zap.String("to_account", strings.TrimSpace(toAccount)), - zap.String("amount", strings.TrimSpace(amount.GetAmount())), - zap.String("currency", currency)) - return entryRef, nil -} - -func (p *paymentExecutor) ledgerTxForAction(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (rail.LedgerTx, error) { - if payment == nil { - return rail.LedgerTx{}, merrors.InvalidArgument("ledger: payment is required") - } - if payment.OrganizationRef == bson.NilObjectID { - return rail.LedgerTx{}, merrors.InvalidArgument("ledger: organization_ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return rail.LedgerTx{}, merrors.InvalidArgument("ledger: amount is required") - } - - sourceRail, _, err := railFromEndpoint(payment.Intent.Source, payment.Intent.Attributes, true) - if err != nil { - sourceRail = model.RailUnspecified - } - destRail, _, err := railFromEndpoint(payment.Intent.Destination, payment.Intent.Attributes, false) - if err != nil { - destRail = model.RailUnspecified - } - - fromRail := model.RailUnspecified - toRail := model.RailUnspecified - accountRef := "" - contraRef := "" - externalRef := "" - operation := "" - - switch action { - case model.RailOperationDebit, model.RailOperationExternalDebit: - fromRail = model.RailLedger - toRail = ledgerStepToRail(payment.PaymentPlan, idx, destRail) - accountRef, contraRef, err = ledgerDebitAccount(payment) - if err != nil { - accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action) - } - if err == nil { - if blockRef := ledgerBlockAccountIfConfirmed(payment); blockRef != "" { - accountRef = blockRef - contraRef = "" - } - } - if action == model.RailOperationExternalDebit { - operation = "external.debit" - } - case model.RailOperationCredit, model.RailOperationExternalCredit: - fromRail = ledgerStepFromRail(payment.PaymentPlan, idx, sourceRail) - toRail = model.RailLedger - accountRef, contraRef, err = ledgerCreditAccount(payment) - if err != nil { - accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action) - } - externalRef = ledgerExternalReference(payment.ExecutionPlan, idx) - if action == model.RailOperationExternalCredit { - operation = "external.credit" - } - default: - return rail.LedgerTx{}, merrors.InvalidArgument("ledger: unsupported action") - } - if err != nil { - return rail.LedgerTx{}, err - } - isDebit := action == model.RailOperationDebit || action == model.RailOperationExternalDebit - isCredit := action == model.RailOperationCredit || action == model.RailOperationExternalCredit - if isCredit && strings.TrimSpace(accountRef) != "" { - setLedgerAccountAttributes(payment, accountRef) - } - if isDebit && toRail == model.RailLedger { - toRail = model.RailUnspecified - } - if isCredit && fromRail == model.RailLedger { - fromRail = model.RailUnspecified - } - - planID := payment.PaymentRef - if payment.PaymentPlan != nil && strings.TrimSpace(payment.PaymentPlan.ID) != "" { - planID = strings.TrimSpace(payment.PaymentPlan.ID) - } - - feeAmount := "" - if isDebit { - if feeMoney := resolveFeeAmount(payment, quote); feeMoney != nil { - feeAmount = strings.TrimSpace(feeMoney.GetAmount()) - } - } - - fxRate := "" - if quote != nil && quote.GetFxQuote() != nil && quote.GetFxQuote().GetPrice() != nil { - fxRate = strings.TrimSpace(quote.GetFxQuote().GetPrice().GetValue()) - } - - return rail.LedgerTx{ - PaymentPlanID: planID, - Currency: strings.TrimSpace(amount.GetCurrency()), - Amount: strings.TrimSpace(amount.GetAmount()), - FeeAmount: feeAmount, - FromRail: ledgerRailValue(fromRail), - ToRail: ledgerRailValue(toRail), - ExternalReferenceID: externalRef, - Operation: operation, - FXRateUsed: fxRate, - IdempotencyKey: strings.TrimSpace(idempotencyKey), - CreatedAt: planTimestamp(payment), - OrganizationRef: payment.OrganizationRef.Hex(), - LedgerAccountRef: strings.TrimSpace(accountRef), - ContraLedgerAccountRef: strings.TrimSpace(contraRef), - Description: paymentDescription(payment), - Charges: charges, - Metadata: cloneMetadata(payment.Metadata), - }, nil -} - -func ledgerRailValue(railValue model.Rail) string { - if railValue == model.RailUnspecified || strings.TrimSpace(string(railValue)) == "" { - return "" - } - return string(railValue) -} - -func ledgerStepFromRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail { - if plan == nil || idx <= 0 { - return fallback - } - for i := idx - 1; i >= 0; i-- { - step := plan.Steps[i] - if step == nil { - continue - } - if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified { - return step.Rail - } - } - return fallback -} - -func ledgerStepToRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail { - if plan == nil || idx < 0 { - return fallback - } - for i := idx + 1; i < len(plan.Steps); i++ { - step := plan.Steps[i] - if step == nil { - continue - } - if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified { - return step.Rail - } - } - return fallback -} - -func ledgerExternalReference(plan *model.ExecutionPlan, idx int) string { - if plan == nil || idx <= 0 { - return "" - } - for i := idx - 1; i >= 0; i-- { - step := plan.Steps[i] - if step == nil { - continue - } - if ref := strings.TrimSpace(step.TransferRef); ref != "" { - return ref - } - } - return "" -} - -func ledgerMoveRoles(step *model.PaymentStep) (account_role.AccountRole, account_role.AccountRole, error) { - if step == nil { - return "", "", merrors.InvalidArgument("ledger: step is required") - } - if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" { - return "", "", merrors.InvalidArgument("ledger: from_role is required") - } - if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" { - return "", "", merrors.InvalidArgument("ledger: to_role 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) { - return "", "", merrors.InvalidArgument("ledger: from_role and to_role must differ") - } - return account_role.AccountRole(from), account_role.AccountRole(to), nil -} - -func ledgerRoleFromAccountRole(role account_role.AccountRole) ledgerv1.AccountRole { - if strings.TrimSpace(string(role)) == "" { - return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED - } - if parsed, ok := ledgerconv.ParseAccountRole(string(role)); ok { - return parsed - } - return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED -} - -func (p *paymentExecutor) resolveAccount(ctx context.Context, orgRef bson.ObjectID, asset string, rail model.Rail, role account_role.AccountRole) (string, error) { - switch rail { - case model.RailLedger: - return p.resolveLedgerAccountByRole(ctx, orgRef, asset, role) - default: - return "", nil - } -} - -func (p *paymentExecutor) resolveLedgerAccountByRole(ctx context.Context, orgRef bson.ObjectID, asset string, role account_role.AccountRole) (string, error) { - if p == nil || p.deps == nil || p.deps.ledger.client == nil { - return "", merrors.Internal("ledger_client_unavailable") - } - if orgRef == bson.NilObjectID { - return "", merrors.InvalidArgument("ledger: organization_ref is required") - } - currency := strings.TrimSpace(asset) - if currency == "" { - return "", merrors.InvalidArgument("ledger: asset is required") - } - if strings.TrimSpace(string(role)) == "" { - return "", merrors.InvalidArgument("ledger: role is required") - } - - resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{ - OrganizationRef: orgRef.Hex(), - Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, - Asset: currency, - }) - if err != nil { - return "", err - } - expectedRole := strings.ToLower(strings.TrimSpace(string(role))) - for _, account := range resp.GetAccounts() { - if account == nil { - continue - } - if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT { - continue - } - if asset := strings.TrimSpace(account.GetAsset()); asset == "" || !strings.EqualFold(asset, currency) { - continue - } - if strings.TrimSpace(account.GetOwnerRef()) != "" { - continue - } - accRole := strings.ToLower(strings.TrimSpace(string(connectorAccountRole(account)))) - if accRole == "" || !strings.EqualFold(accRole, expectedRole) { - continue - } - if ref := account.GetRef(); ref != nil { - if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" { - return accountID, nil - } - } - } - return "", merrors.InvalidArgument("ledger: account role not found") -} - -func (p *paymentExecutor) resolveLedgerAccountRef(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, action model.RailOperation) (string, string, error) { - if payment == nil { - return "", "", merrors.InvalidArgument("ledger: payment is required") - } - if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", "", merrors.InvalidArgument("ledger: amount is required") - } - switch action { - case model.RailOperationCredit, model.RailOperationExternalCredit: - if account, _, err := ledgerDebitAccount(payment); err == nil && strings.TrimSpace(account) != "" { - setLedgerAccountAttributes(payment, account) - return account, "", nil - } - case model.RailOperationDebit, model.RailOperationExternalDebit: - if account, _, err := ledgerCreditAccount(payment); err == nil && strings.TrimSpace(account) != "" { - setLedgerAccountAttributes(payment, account) - return account, "", nil - } - } - account, err := p.resolveOrgOwnedLedgerAccount(ctx, payment, amount) - if err != nil { - return "", "", err - } - setLedgerAccountAttributes(payment, account) - return account, "", nil -} - -func (p *paymentExecutor) resolveOrgOwnedLedgerAccount(ctx context.Context, payment *model.Payment, amount *moneyv1.Money) (string, error) { - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - if payment.OrganizationRef == bson.NilObjectID { - return "", merrors.InvalidArgument("ledger: organization_ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("ledger: amount is required") - } - if p == nil || p.deps == nil || p.deps.ledger.client == nil { - return "", merrors.Internal("ledger_client_unavailable") - } - - currency := strings.TrimSpace(amount.GetCurrency()) - resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{ - OrganizationRef: payment.OrganizationRef.Hex(), - Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, - Asset: currency, - }) - if err != nil { - return "", err - } - for _, account := range resp.GetAccounts() { - if account == nil { - continue - } - if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT { - continue - } - asset := strings.TrimSpace(account.GetAsset()) - if asset == "" || !strings.EqualFold(asset, currency) { - continue - } - if strings.TrimSpace(account.GetOwnerRef()) != "" { - continue - } - if connectorAccountIsSettlement(account) { - continue - } - if ref := account.GetRef(); ref != nil { - if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" { - return accountID, nil - } - } - } - return "", merrors.InvalidArgument("ledger: org-owned account not found") -} - -func connectorAccountIsSettlement(account *connectorv1.Account) bool { - return connectorAccountRole(account) == account_role.AccountRoleSettlement -} - -func connectorAccountRole(account *connectorv1.Account) account_role.AccountRole { - if account == nil || account.GetProviderDetails() == nil { - return "" - } - details := account.GetProviderDetails().AsMap() - if value := strings.TrimSpace(fmt.Sprint(details["role"])); value != "" { - if role, ok := account_role.Parse(value); ok { - return role - } - } - switch v := details["is_settlement"].(type) { - case bool: - if v { - return account_role.AccountRoleSettlement - } - case string: - if strings.EqualFold(strings.TrimSpace(v), "true") { - return account_role.AccountRoleSettlement - } - } - return "" -} - -func setLedgerAccountAttributes(payment *model.Payment, accountRef string) { - if payment == nil || strings.TrimSpace(accountRef) == "" { - return - } - if payment.Intent.Attributes == nil { - payment.Intent.Attributes = map[string]string{} - } - if attributeLookup(payment.Intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef") == "" { - payment.Intent.Attributes["ledger_debit_account_ref"] = accountRef - } - if attributeLookup(payment.Intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef") == "" { - payment.Intent.Attributes["ledger_credit_account_ref"] = accountRef - } -} - -func ledgerDebitAccount(payment *model.Payment) (string, string, error) { - if payment == nil { - return "", "", merrors.InvalidArgument("ledger: payment is required") - } - intent := payment.Intent - if intent.Source.Ledger != nil && strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef) != "" { - return strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef), nil - } - if ref := attributeLookup(intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef"); ref != "" { - return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_debit_contra_account_ref", "ledgerDebitContraAccountRef")), nil - } - return "", "", merrors.InvalidArgument("ledger: source account is required") -} - -func ledgerBlockAccount(payment *model.Payment) (string, error) { - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - intent := payment.Intent - if intent.Source.Ledger != nil { - if ref := strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef); ref != "" { - return ref, nil - } - } - if ref := attributeLookup(intent.Attributes, - "ledger_block_account_ref", - "ledgerBlockAccountRef", - "ledger_hold_account_ref", - "ledgerHoldAccountRef", - "ledger_debit_contra_account_ref", - "ledgerDebitContraAccountRef", - ); ref != "" { - return ref, nil - } - return "", merrors.InvalidArgument("ledger: block account is required") -} - -func ledgerBlockAccountIfConfirmed(payment *model.Payment) string { - if payment == nil { - return "" - } - if !blockStepConfirmed(payment.PaymentPlan, payment.ExecutionPlan) { - return "" - } - ref, err := ledgerBlockAccount(payment) - if err != nil { - return "" - } - return ref -} - -func ledgerCreditAccount(payment *model.Payment) (string, string, error) { - if payment == nil { - return "", "", merrors.InvalidArgument("ledger: payment is required") - } - intent := payment.Intent - if intent.Destination.Ledger != nil && strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef) != "" { - return strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Destination.Ledger.ContraLedgerAccountRef), nil - } - if ref := attributeLookup(intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef"); ref != "" { - return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_credit_contra_account_ref", "ledgerCreditContraAccountRef")), nil - } - return "", "", merrors.InvalidArgument("ledger: destination account is required") -} - -func attributeLookup(attrs map[string]string, keys ...string) string { - if len(keys) == 0 { - return "" - } - for _, key := range keys { - if key == "" || attrs == nil { - continue - } - if val := strings.TrimSpace(attrs[key]); val != "" { - return val - } - } - return "" -} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_release.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_release.go deleted file mode 100644 index d0362fb8..00000000 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_release.go +++ /dev/null @@ -1,50 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "go.uber.org/zap" -) - -func (p *paymentExecutor) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if store == nil { - return errStorageUnavailable - } - if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return nil - } - execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if execPlan == nil || !blockStepConfirmed(payment.PaymentPlan, execPlan) { - return nil - } - execSteps := executionStepsByCode(execPlan) - execQuote := executionQuote(payment, nil) - - for idx, step := range payment.PaymentPlan.Steps { - if step == nil || step.Action != model.RailOperationRelease { - continue - } - stepID := planStepID(step, idx) - execStep := execSteps[stepID] - if execStep == nil { - execStep = &model.ExecutionStep{Code: stepID} - execSteps[stepID] = execStep - if idx < len(execPlan.Steps) { - execPlan.Steps[idx] = execStep - } - } - if execStep.State == model.OperationStateSuccess { - p.logger.Debug("Payment step already confirmed, skipping", zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef)) - continue - } - if _, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, nil, idx); err != nil { - p.logger.Warn("Failed to execute payment step", zap.Error(err), - zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef)) - return err - } - } - - return p.persistPayment(ctx, store, payment) -} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_plan_steps.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_steps.go deleted file mode 100644 index 37272a3e..00000000 --- a/api/payments/quotation/internal/service/orchestrator/payment_plan_steps.go +++ /dev/null @@ -1,446 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "math/big" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" -) - -func (p *paymentExecutor) executePlanStep( - ctx context.Context, - payment *model.Payment, - step *model.PaymentStep, - execStep *model.ExecutionStep, - quote *orchestratorv1.PaymentQuote, - charges []*ledgerv1.PostingLine, - idx int, -) (bool, error) { - - if payment == nil || step == nil || execStep == nil { - return false, merrors.InvalidArgument("payment plan: step is required") - } - - stepID := execStep.Code - logger := p.logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("step_id", stepID), - zap.String("rail", string(step.Rail)), - zap.String("action", string(step.Action)), - zap.Int("idx", idx), - ) - - logger.Debug("Executing payment plan step") - - if execStep.IsTerminal() { - logger.Debug("Step already in terminal state, skipping execution", - zap.String("state", string(execStep.State)), - ) - return false, nil - } - - switch step.Action { - - case model.RailOperationMove: - logger.Debug("Posting ledger move") - amount, err := requireMoney(cloneMoney(step.Amount), "ledger move amount") - if err != nil { - logger.Warn("Ledger move amount invalid", zap.Error(err)) - return false, err - } - ref, err := p.postLedgerMove(ctx, payment, step, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx) - if err != nil { - logger.Warn("Ledger move failed", zap.Error(err)) - return false, err - } - execStep.TransferRef = strings.TrimSpace(ref) - setExecutionStepStatus(execStep, model.OperationStateSuccess) - logger.Info("Ledger move completed", zap.String("journal_ref", ref)) - return false, nil - - case model.RailOperationDebit, model.RailOperationExternalDebit: - logger.Debug("Posting ledger debit") - amount, err := requireMoney(cloneMoney(step.Amount), "ledger debit amount") - if err != nil { - logger.Warn("Ledger debit amount invalid", zap.Error(err)) - return false, err - } - ref, err := p.postLedgerDebit(ctx, payment, protoMoney(amount), charges, planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote) - if err != nil { - logger.Warn("Ledger debit failed", zap.Error(err)) - return false, err - } - ensureExecutionRefs(payment).DebitEntryRef = ref - setExecutionStepStatus(execStep, model.OperationStateSuccess) - logger.Info("Ledger debit completed", zap.String("journal_ref", ref)) - return false, nil - - case model.RailOperationCredit, model.RailOperationExternalCredit: - logger.Debug("Posting ledger credit") - amount, err := requireMoney(cloneMoney(step.Amount), "ledger credit amount") - if err != nil { - logger.Warn("Ledger credit amount invalid", zap.Error(err)) - return false, err - } - ref, err := p.postLedgerCredit(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote) - if err != nil { - logger.Warn("Ledger credit failed", zap.Error(err)) - return false, err - } - ensureExecutionRefs(payment).CreditEntryRef = ref - setExecutionStepStatus(execStep, model.OperationStateSuccess) - logger.Info("Ledger credit completed", zap.String("journal_ref", ref)) - return false, nil - - case model.RailOperationFXConvert: - logger.Debug("Applying FX conversion") - if err := p.applyFX(ctx, payment, quote, charges, paymentDescription(payment), cloneMetadata(payment.Metadata), ensureExecutionRefs(payment)); err != nil { - logger.Warn("FX conversion failed", zap.Error(err)) - return false, err - } - setExecutionStepStatus(execStep, model.OperationStateSuccess) - logger.Info("FX conversion completed") - return false, nil - - case model.RailOperationObserveConfirm: - setExecutionStepStatus(execStep, model.OperationStateWaiting) - logger.Info("ObserveConfirm step set to waiting for external confirmation") - return true, nil - - case model.RailOperationSend: - logger.Debug("Executing send step") - async, err := p.executeSendStep(ctx, payment, step, execStep, quote, idx) - if err != nil { - setExecutionStepStatus(execStep, model.OperationStateFailed) - execStep.Error = err.Error() - logger.Warn("Send step failed", zap.Error(err)) - return false, err - } - - return async, nil - - case model.RailOperationFee: - logger.Debug("Executing fee step") - async, err := p.executeFeeStep(ctx, payment, step, execStep, idx) - if err != nil { - logger.Warn("Fee step failed", zap.Error(err)) - return false, err - } - logger.Info("Fee step submitted") - return async, nil - - default: - logger.Warn("Unsupported payment plan action") - return false, merrors.InvalidArgument("payment plan: unsupported action") - } -} - -func sub(a, b string) (string, error) { - ra, ok := new(big.Rat).SetString(a) - if !ok { - return "", fmt.Errorf("invalid number: %s", a) - } - - rb, ok := new(big.Rat).SetString(b) - if !ok { - return "", fmt.Errorf("invalid number: %s", b) - } - - ra.Sub(ra, rb) - - // 2 знака после запятой (как у тебя) - return ra.FloatString(2), nil -} - -func (p *paymentExecutor) executeSendStep( - ctx context.Context, - payment *model.Payment, - step *model.PaymentStep, - execStep *model.ExecutionStep, - quote *orchestratorv1.PaymentQuote, - idx int, -) (bool, error) { - - stepID := execStep.Code - logger := p.logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("step_id", stepID), - zap.String("rail", string(step.Rail)), - zap.String("action", string(step.Action)), - zap.Int("idx", idx), - ) - - logger.Debug("Executing send step") - - switch step.Rail { - - case model.RailCrypto: - logger.Debug("Preparing crypto transfer") - - amount, err := requireMoney(cloneMoney(step.Amount), "crypto send amount") - if err != nil { - logger.Warn("Invalid crypto amount", zap.Error(err)) - return false, err - } - - if !p.deps.railGateways.available() { - logger.Warn("Rail gateway unavailable") - return false, merrors.Internal("rail gateway unavailable") - } - - fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - req, err := p.buildCryptoTransferRequest( - payment, - amount, - model.RailOperationSend, - planStepIdempotencyKey(payment, idx, step), - execStep.OperationRef, - quote, - fromRole, toRole, - ) - if err != nil { - logger.Warn("Failed to build crypto transfer request", zap.Error(err)) - return false, err - } - - gw, err := p.deps.railGateways.resolve(ctx, step) - if err != nil { - logger.Warn("Failed to resolve rail gateway", zap.Error(err)) - return false, err - } - - logger.Debug("Sending crypto transfer", - zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef), - zap.String("operation_ref", req.OperationRef), - ) - - result, err := gw.Send(ctx, req) - if err != nil { - execStep.Error = strings.TrimSpace(err.Error()) - setExecutionStepStatus(execStep, model.OperationStateFailed) - - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeChain - - logger.Warn("Send failed; step marked as failed", zap.Error(err)) - return false, nil - } - - execStep.TransferRef = strings.TrimSpace(result.ReferenceID) - logger.Info("Crypto transfer submitted", - zap.String("transfer_ref", execStep.TransferRef), - ) - - exec := ensureExecutionRefs(payment) - if exec.ChainTransferRef == "" && execStep.TransferRef != "" { - exec.ChainTransferRef = execStep.TransferRef - } - - if execStep.TransferRef != "" { - linkRailObservation(payment, step.Rail, execStep.TransferRef, stepID) - } - - setExecutionStepStatus(execStep, model.OperationStateWaiting) - return true, nil - - case model.RailCardPayout: - logger.Debug("Submitting card payout") - - amount, err := requireMoney(cloneMoney(step.Amount), "card payout amount") - if err != nil { - logger.Warn("Invalid card payout amount", zap.Error(err)) - return false, err - } - - fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - ref, err := p.submitCardPayoutPlan( - ctx, - payment, - execStep.OperationRef, - protoMoney(amount), - fromRole, toRole, - ) - if err != nil { - logger.Warn("Card payout submission failed", zap.Error(err)) - return false, err - } - - execStep.TransferRef = ref - ensureExecutionRefs(payment).CardPayoutRef = ref - - logger.Info("Card payout submitted", zap.String("payout_ref", ref)) - - setExecutionStepStatus(execStep, model.OperationStateWaiting) - return true, nil - - case model.RailProviderSettlement: - logger.Debug("Preparing provider settlement transfer") - - amount, err := requireMoney(cloneMoney(payment.LastQuote.DebitSettlementAmount), "provider settlement amount") - if err != nil { - logger.Warn("Invalid provider settlement amount", zap.Error(err), zap.Any("settlement", payment.LastQuote.DebitSettlementAmount)) - return false, err - } - logger.Debug("Expected settlement amount", zap.String("amount", amount.Amount), zap.String("currency", amount.Currency)) - fee, err := requireMoney(cloneMoney(payment.LastQuote.ExpectedFeeTotal), "provider settlement amount") - if err != nil { - logger.Warn("Invalid fee settlement amount", zap.Error(err)) - return false, err - } - if fee.Currency != amount.Currency { - logger.Warn("Fee and amount currencies do not match", - zap.String("amount_currency", amount.Currency), zap.String("fee_currency", fee.Currency), - ) - return false, merrors.DataConflict("settlement payment: currencies mismatch") - } - - if !p.deps.railGateways.available() { - logger.Warn("Rail gateway unavailable") - return false, merrors.Internal("rail gateway unavailable") - } - - fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - req, err := p.buildProviderSettlementTransferRequest( - payment, - step, - execStep.OperationRef, - amount, - quote, - idx, - fromRole, toRole) - if err != nil { - logger.Warn("Failed to build provider settlement request", zap.Error(err)) - return false, err - } - - gw, err := p.deps.railGateways.resolve(ctx, step) - if err != nil { - logger.Warn("Failed to resolve rail gateway", zap.Error(err)) - return false, err - } - - logger.Info("Sending provider settlement transfer", - zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef), - ) - - result, err := gw.Send(ctx, req) - if err != nil { - execStep.Error = strings.TrimSpace(err.Error()) - setExecutionStepStatus(execStep, model.OperationStateFailed) - - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeSettlement - - logger.Warn("Send failed; step marked as failed", zap.Error(err)) - return false, nil - } - - execStep.TransferRef = strings.TrimSpace(result.ReferenceID) - if execStep.TransferRef == "" { - execStep.TransferRef = strings.TrimSpace(req.IdempotencyKey) - } - - logger.Info("Provider settlement submitted", - zap.String("transfer_ref", execStep.TransferRef), - ) - - linkProviderSettlementObservation(payment, execStep.TransferRef) - setExecutionStepStatus(execStep, model.OperationStateWaiting) - return true, nil - - case model.RailFiatOnRamp: - logger.Warn("Fiat on-ramp not implemented") - return false, merrors.InvalidArgument("payment plan: fiat on-ramp execution not implemented") - - default: - logger.Warn("Unsupported send rail") - return false, merrors.InvalidArgument("payment plan: unsupported send rail") - } -} - -func (p *paymentExecutor) executeFeeStep( - ctx context.Context, - payment *model.Payment, - step *model.PaymentStep, - execStep *model.ExecutionStep, - idx int, -) (bool, error) { - - if payment == nil || step == nil || execStep == nil { - return false, merrors.InvalidArgument("payment plan: fee step is required") - } - - switch step.Rail { - - case model.RailCrypto: - amount, err := requireMoney(cloneMoney(step.Amount), "crypto fee amount") - if err != nil { - return false, err - } - - if !p.deps.railGateways.available() { - return false, merrors.Internal("rail gateway unavailable") - } - - fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - - req, err := p.buildCryptoTransferRequest( - payment, - amount, - model.RailOperationFee, - planStepIdempotencyKey(payment, idx, step), - execStep.OperationRef, - nil, - fromRole, - toRole, - ) - if err != nil { - return false, err - } - - gw, err := p.deps.railGateways.resolve(ctx, step) - if err != nil { - return false, err - } - - p.logger.Debug("Executing crypto fee transfer", - zap.String("payment_ref", payment.PaymentRef), - zap.String("step_id", planStepID(step, idx)), - zap.String("amount", amount.GetAmount()), - zap.String("currency", amount.GetCurrency()), - ) - - result, err := gw.Send(ctx, req) - if err != nil { - p.logger.Warn("Crypto fee transfer failed to submit", zap.Error(err), - zap.String("payment_ref", payment.PaymentRef), - ) - return false, nil - } - - execStep.TransferRef = strings.TrimSpace(result.ReferenceID) - - if execStep.TransferRef != "" { - ensureExecutionRefs(payment).FeeTransferRef = execStep.TransferRef - } - - // ВАЖНО: больше не Submitted - setExecutionStepStatus(execStep, model.OperationStateWaiting) - - p.logger.Info("Crypto fee transfer submitted, waiting confirmation", - zap.String("payment_ref", payment.PaymentRef), - zap.String("transfer_ref", execStep.TransferRef), - ) - - return true, nil - - default: - return false, merrors.InvalidArgument("payment plan: unsupported fee rail") - } -} diff --git a/api/payments/quotation/internal/service/orchestrator/provider_settlement.go b/api/payments/quotation/internal/service/orchestrator/provider_settlement.go deleted file mode 100644 index 96ba0f55..00000000 --- a/api/payments/quotation/internal/service/orchestrator/provider_settlement.go +++ /dev/null @@ -1,132 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" -) - -const ( - providerSettlementMetaPaymentIntentID = "payment_ref" - providerSettlementMetaQuoteRef = "quote_ref" - providerSettlementMetaTargetChatID = "target_chat_id" - providerSettlementMetaOutgoingLeg = "outgoing_leg" - providerSettlementMetaSourceAmount = "source_amount" - providerSettlementMetaSourceCurrency = "source_currency" -) - -func (p *paymentExecutor) buildProviderSettlementTransferRequest(payment *model.Payment, step *model.PaymentStep, operationRef string, amount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote, idx int, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) { - if payment == nil || step == nil { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment and step are required") - } - if amount == nil { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: amount is required") - } - requestID := planStepIdempotencyKey(payment, idx, step) - if requestID == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: idempotency key is required") - } - intentRef := strings.TrimSpace(payment.Intent.Ref) - if intentRef == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: intention ref is required") - } - paymentRef := strings.TrimSpace(payment.PaymentRef) - if paymentRef == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment_ref is required") - } - metadata := cloneMetadata(payment.Metadata) - if metadata == nil { - metadata = map[string]string{} - } - metadata[providerSettlementMetaPaymentIntentID] = paymentRef - if quoteRef := paymentGatewayQuoteRef(payment, quote); quoteRef != "" { - metadata[providerSettlementMetaQuoteRef] = quoteRef - } - if chatID := paymentGatewayTargetChatID(payment); chatID != "" { - metadata[providerSettlementMetaTargetChatID] = chatID - } - if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" { - metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(string(step.Rail))) - } - if strings.TrimSpace(metadata[providerSettlementMetaSourceAmount]) == "" { - metadata[providerSettlementMetaSourceAmount] = strings.TrimSpace(amount.Amount) - } - if strings.TrimSpace(metadata[providerSettlementMetaSourceCurrency]) == "" { - metadata[providerSettlementMetaSourceCurrency] = strings.TrimSpace(amount.Currency) - } - - sourceWalletRef := "" - if payment.Intent.Source.ManagedWallet != nil { - sourceWalletRef = strings.TrimSpace(payment.Intent.Source.ManagedWallet.ManagedWalletRef) - } - if sourceWalletRef == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: source managed wallet is required") - } - - destRef := "" - if payment.Intent.Destination.Type == model.EndpointTypeCard { - if route, err := p.resolveCardRoute(payment.Intent); err == nil { - destRef = strings.TrimSpace(route.FundingAddress) - } - } - if destRef == "" { - destRef = paymentRef - } - - req := rail.TransferRequest{ - OrganizationRef: payment.OrganizationRef.Hex(), - FromAccountID: sourceWalletRef, - ToAccountID: destRef, - Currency: strings.TrimSpace(amount.GetCurrency()), - Amount: strings.TrimSpace(amount.GetAmount()), - IdempotencyKey: requestID, - DestinationMemo: paymentRef, - Metadata: metadata, - PaymentRef: paymentRef, - OperationRef: operationRef, - IntentRef: intentRef, - } - if fromRole != nil { - req.FromRole = *fromRole - } - if toRole != nil { - req.ToRole = *toRole - } - return req, nil -} - -func paymentGatewayQuoteRef(payment *model.Payment, quote *orchestratorv1.PaymentQuote) string { - if quote != nil { - if ref := strings.TrimSpace(quote.GetQuoteRef()); ref != "" { - return ref - } - } - if payment != nil && payment.LastQuote != nil { - return strings.TrimSpace(payment.LastQuote.QuoteRef) - } - return "" -} - -func paymentGatewayTargetChatID(payment *model.Payment) string { - if payment == nil { - return "" - } - if payment.Intent.Attributes != nil { - if chatID := strings.TrimSpace(payment.Intent.Attributes["target_chat_id"]); chatID != "" { - return chatID - } - } - if payment.Metadata != nil { - return strings.TrimSpace(payment.Metadata["target_chat_id"]) - } - return "" -} - -func linkProviderSettlementObservation(payment *model.Payment, requestID string) { - linkRailObservation(payment, model.RailProviderSettlement, requestID, "") -} diff --git a/api/payments/quotation/internal/service/orchestrator/service.go b/api/payments/quotation/internal/service/orchestrator/service.go deleted file mode 100644 index 69e6c11f..00000000 --- a/api/payments/quotation/internal/service/orchestrator/service.go +++ /dev/null @@ -1,210 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/api/routers" - clockpkg "github.com/tech/sendico/pkg/clock" - msg "github.com/tech/sendico/pkg/messaging" - mb "github.com/tech/sendico/pkg/messaging/broker" - "github.com/tech/sendico/pkg/mlogger" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "google.golang.org/grpc" -) - -type serviceError string - -func (e serviceError) Error() string { - return string(e) -} - -const ( - defaultFeeQuoteTTLMillis int64 = 120000 - defaultOracleTTLMillis int64 = 60000 -) - -var ( - errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised") -) - -// Service orchestrates payments across ledger, billing, FX, and chain domains. -type Service struct { - logger mlogger.Logger - storage storage.Repository - clock clockpkg.Clock - - deps serviceDependencies - h handlerSet - comp componentSet - - gatewayBroker mb.Broker - gatewayConsumers []msg.Consumer - - orchestratorv1.UnimplementedPaymentOrchestratorServer -} - -type serviceDependencies struct { - fees feesDependency - ledger ledgerDependency - gateway gatewayDependency - railGateways railGatewayDependency - providerGateway providerGatewayDependency - oracle oracleDependency - mntx mntxDependency - gatewayRegistry GatewayRegistry - gatewayInvokeResolver GatewayInvokeResolver - cardRoutes map[string]CardGatewayRoute - feeLedgerAccounts map[string]string - planBuilder PlanBuilder -} - -type handlerSet struct { - commands *paymentCommandFactory - queries *paymentQueryHandler - events *paymentEventHandler -} - -type componentSet struct { - executor *paymentExecutor -} - -// NewService constructs a payment orchestrator service. -func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { - svc := &Service{ - logger: logger.Named("payment_orchestrator"), - storage: repo, - clock: clockpkg.NewSystem(), - } - - initMetrics() - - for _, opt := range opts { - if opt != nil { - opt(svc) - } - } - - if svc.clock == nil { - svc.clock = clockpkg.NewSystem() - } - - engine := defaultPaymentEngine{svc: svc} - svc.h.commands = newPaymentCommandFactory(engine, svc.logger) - svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries")) - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"), svc.submitCardPayout, svc.resumePaymentPlan, svc.releasePaymentHold) - svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc) - svc.startGatewayConsumers() - - return svc -} - -func (s *Service) ensureHandlers() { - if s.h.commands == nil { - s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger) - } - if s.h.queries == nil { - s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries")) - } - if s.h.events == nil { - s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"), s.submitCardPayout, s.resumePaymentPlan, s.releasePaymentHold) - } - if s.comp.executor == nil { - s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s) - } -} - -// Register attaches the service to the supplied gRPC router. -func (s *Service) Register(router routers.GRPC) error { - return router.Register(func(reg grpc.ServiceRegistrar) { - orchestratorv1.RegisterPaymentOrchestratorServer(reg, s) - }) -} - -// QuotePayment aggregates downstream quotes. -func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req) -} - -// QuotePayments aggregates downstream quotes for multiple intents. -func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req) -} - -// InitiatePayment captures a payment intent and reserves funds orchestration. -func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req) -} - -// InitiatePayments executes multiple payments using a stored quote reference. -func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().Execute, req) -} - -// CancelPayment attempts to cancel an in-flight payment. -func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req) -} - -// GetPayment returns a stored payment record. -func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req) -} - -// ListPayments lists stored payment records. -func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req) -} - -// InitiateConversion orchestrates standalone FX conversions. -func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req) -} - -// ProcessTransferUpdate reconciles chain events back into payment state. -func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req) -} - -// ProcessDepositObserved reconciles deposit events to ledger. -func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req) -} - -// ProcessCardPayoutUpdate reconciles card payout events back into payment state. -func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req) -} - -func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { - s.ensureHandlers() - return s.comp.executor.executePayment(ctx, store, payment, quote) -} - -func (s *Service) resumePaymentPlan(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return nil - } - s.ensureHandlers() - return s.comp.executor.executePaymentPlan(ctx, store, payment, nil) -} - -func (s *Service) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return nil - } - s.ensureHandlers() - return s.comp.executor.releasePaymentHold(ctx, store, payment) -} diff --git a/api/payments/quotation/internal/service/plan/builder.go b/api/payments/quotation/internal/service/plan/builder.go new file mode 100644 index 00000000..53cc429f --- /dev/null +++ b/api/payments/quotation/internal/service/plan/builder.go @@ -0,0 +1,70 @@ +package plan + +import ( + "context" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +// RouteStore exposes routing definitions for plan construction. +type RouteStore interface { + List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) +} + +// 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 *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) +} + +type SendDirection = sendDirection + +const ( + SendDirectionAny SendDirection = sendDirectionAny + SendDirectionOut SendDirection = sendDirectionOut + 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) +} + +func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { + 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) +} + +func IsGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir SendDirection, amount decimal.Decimal) error { + return isGatewayEligible(gw, rail, network, currency, action, sendDirection(dir), amount) +} + +func ParseRailValue(value string) model.Rail { + return parseRailValue(value) +} + +func NetworkFromEndpoint(endpoint model.PaymentEndpoint) string { + return networkFromEndpoint(endpoint) +} diff --git a/api/payments/quotation/internal/service/plan/helpers.go b/api/payments/quotation/internal/service/plan/helpers.go new file mode 100644 index 00000000..c512fecb --- /dev/null +++ b/api/payments/quotation/internal/service/plan/helpers.go @@ -0,0 +1,365 @@ +package plan + +import ( + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + 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" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +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] = strings.TrimSpace(v) + } + return clone +} + +func attributeLookup(attrs map[string]string, keys ...string) string { + if len(attrs) == 0 || len(keys) == 0 { + return "" + } + for _, key := range keys { + needle := strings.ToLower(strings.TrimSpace(key)) + if needle == "" { + continue + } + for attrKey, value := range attrs { + if strings.EqualFold(strings.TrimSpace(attrKey), needle) { + if val := strings.TrimSpace(value); val != "" { + return val + } + } + } + } + return "" +} + +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 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 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 fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote { + if quote == nil { + return nil + } + return &paymenttypes.FXQuote{ + QuoteRef: strings.TrimSpace(quote.GetQuoteRef()), + Pair: pairFromProto(quote.GetPair()), + Side: fxSideFromProto(quote.GetSide()), + Price: decimalFromProto(quote.GetPrice()), + BaseAmount: moneyFromProto(quote.GetBaseAmount()), + QuoteAmount: moneyFromProto(quote.GetQuoteAmount()), + ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(), + Provider: strings.TrimSpace(quote.GetProvider()), + RateRef: strings.TrimSpace(quote.GetRateRef()), + Firm: quote.GetFirm(), + } +} + +func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote { + if quote == nil { + return nil + } + return &oraclev1.Quote{ + QuoteRef: strings.TrimSpace(quote.QuoteRef), + Pair: pairToProto(quote.Pair), + Side: fxSideToProto(quote.Side), + Price: decimalToProto(quote.Price), + BaseAmount: protoMoney(quote.BaseAmount), + QuoteAmount: protoMoney(quote.QuoteAmount), + ExpiresAtUnixMs: quote.ExpiresAtUnixMs, + Provider: strings.TrimSpace(quote.Provider), + RateRef: strings.TrimSpace(quote.RateRef), + Firm: quote.Firm, + } +} + +func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide { + switch side { + case accountingv1.EntrySide_ENTRY_SIDE_DEBIT: + return paymenttypes.EntrySideDebit + case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: + return paymenttypes.EntrySideCredit + default: + return paymenttypes.EntrySideUnspecified + } +} + +func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide { + switch side { + case paymenttypes.EntrySideDebit: + return accountingv1.EntrySide_ENTRY_SIDE_DEBIT + case paymenttypes.EntrySideCredit: + return accountingv1.EntrySide_ENTRY_SIDE_CREDIT + default: + return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED + } +} + +func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType { + switch lineType { + case accountingv1.PostingLineType_POSTING_LINE_FEE: + return paymenttypes.PostingLineTypeFee + case accountingv1.PostingLineType_POSTING_LINE_TAX: + return paymenttypes.PostingLineTypeTax + case accountingv1.PostingLineType_POSTING_LINE_SPREAD: + return paymenttypes.PostingLineTypeSpread + case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: + return paymenttypes.PostingLineTypeReversal + default: + return paymenttypes.PostingLineTypeUnspecified + } +} + +func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType { + switch lineType { + case paymenttypes.PostingLineTypeFee: + return accountingv1.PostingLineType_POSTING_LINE_FEE + case paymenttypes.PostingLineTypeTax: + return accountingv1.PostingLineType_POSTING_LINE_TAX + case paymenttypes.PostingLineTypeSpread: + return accountingv1.PostingLineType_POSTING_LINE_SPREAD + case paymenttypes.PostingLineTypeReversal: + return accountingv1.PostingLineType_POSTING_LINE_REVERSAL + default: + return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED + } +} + +func 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 executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote { + if quote != nil { + return quote + } + if payment != nil && payment.LastQuote != nil { + return &orchestratorv1.PaymentQuote{ + DebitAmount: protoMoney(payment.LastQuote.DebitAmount), + DebitSettlementAmount: protoMoney(payment.LastQuote.DebitSettlementAmount), + ExpectedSettlementAmount: protoMoney(payment.LastQuote.ExpectedSettlementAmount), + ExpectedFeeTotal: protoMoney(payment.LastQuote.ExpectedFeeTotal), + FeeLines: feeLinesToProto(payment.LastQuote.FeeLines), + FxQuote: fxQuoteToProto(payment.LastQuote.FXQuote), + QuoteRef: strings.TrimSpace(payment.LastQuote.QuoteRef), + } + } + return &orchestratorv1.PaymentQuote{} +} + +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 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 +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_default.go b/api/payments/quotation/internal/service/plan/plan_builder_default.go similarity index 89% rename from api/payments/quotation/internal/service/orchestrator/plan_builder_default.go rename to api/payments/quotation/internal/service/plan/plan_builder_default.go index ba322676..d857bebb 100644 --- a/api/payments/quotation/internal/service/orchestrator/plan_builder_default.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_default.go @@ -1,4 +1,4 @@ -package orchestrator +package plan import ( "context" @@ -11,17 +11,17 @@ import ( "go.uber.org/zap" ) -type defaultPlanBuilder struct { +type defaultBuilder struct { logger mlogger.Logger } -func newDefaultPlanBuilder(logger mlogger.Logger) *defaultPlanBuilder { - return &defaultPlanBuilder{ +func newDefaultBuilder(logger mlogger.Logger) *defaultBuilder { + return &defaultBuilder{ logger: logger.Named("plan_builder"), } } -func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { +func (b *defaultBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { if payment == nil { return nil, merrors.InvalidArgument("plan builder: payment is required") } diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_endpoints.go b/api/payments/quotation/internal/service/plan/plan_builder_endpoints.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/plan_builder_endpoints.go rename to api/payments/quotation/internal/service/plan/plan_builder_endpoints.go index d825937d..1e136126 100644 --- a/api/payments/quotation/internal/service/orchestrator/plan_builder_endpoints.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_endpoints.go @@ -1,4 +1,4 @@ -package orchestrator +package plan import ( "strings" diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_gateways.go b/api/payments/quotation/internal/service/plan/plan_builder_gateways.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/plan_builder_gateways.go rename to api/payments/quotation/internal/service/plan/plan_builder_gateways.go index 8d3f573d..de3d083c 100644 --- a/api/payments/quotation/internal/service/orchestrator/plan_builder_gateways.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_gateways.go @@ -1,4 +1,4 @@ -package orchestrator +package plan import ( "context" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go b/api/payments/quotation/internal/service/plan/plan_builder_plans.go similarity index 99% rename from api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go rename to api/payments/quotation/internal/service/plan/plan_builder_plans.go index b6e3b073..6b27caac 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_plans.go @@ -1,4 +1,4 @@ -package orchestrator +package plan import ( "time" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go b/api/payments/quotation/internal/service/plan/plan_builder_routes.go similarity index 99% rename from api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go rename to api/payments/quotation/internal/service/plan/plan_builder_routes.go index de979ece..fdd673c9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_routes.go @@ -1,4 +1,4 @@ -package orchestrator +package plan import ( "context" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go b/api/payments/quotation/internal/service/plan/plan_builder_steps.go similarity index 96% rename from api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go rename to api/payments/quotation/internal/service/plan/plan_builder_steps.go index c9460f41..df7504ef 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_steps.go @@ -1,12 +1,12 @@ -package orchestrator +package plan import ( "context" "strings" + "github.com/tech/sendico/payments/quotation/internal/service/shared" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" paymenttypes "github.com/tech/sendico/pkg/payments/types" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -14,7 +14,7 @@ import ( "go.uber.org/zap" ) -func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) { +func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) { if template == nil { return nil, merrors.InvalidArgument("plan builder: plan template is required") } @@ -136,8 +136,8 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment CommitPolicy: policy, CommitAfter: cloneStringList(tpl.CommitAfter), Amount: cloneMoney(amount), - FromRole: cloneAccountRole(tpl.FromRole), - ToRole: cloneAccountRole(tpl.ToRole), + FromRole: shared.CloneAccountRole(tpl.FromRole), + ToRole: shared.CloneAccountRole(tpl.ToRole), } needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm @@ -353,14 +353,6 @@ func observeAmountForRail(rail model.Rail, source, settlement, payout *paymentty return source } -func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole { - if role == nil { - return nil - } - cloned := *role - return &cloned -} - func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote) (*paymenttypes.Money, error) { if sourceAmount == nil { return nil, merrors.InvalidArgument("plan builder: source amount is required") diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go b/api/payments/quotation/internal/service/plan/plan_builder_templates.go similarity index 99% rename from api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go rename to api/payments/quotation/internal/service/plan/plan_builder_templates.go index bbaed284..c0238de1 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_templates.go @@ -1,4 +1,4 @@ -package orchestrator +package plan import ( "context" diff --git a/api/payments/quotation/internal/service/quotation/card_payout_constants.go b/api/payments/quotation/internal/service/quotation/card_payout_constants.go new file mode 100644 index 00000000..50cc7fab --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/card_payout_constants.go @@ -0,0 +1,5 @@ +package quotation + +const ( + defaultCardGateway = "monetix" +) diff --git a/api/payments/quotation/internal/service/orchestrator/command_factory.go b/api/payments/quotation/internal/service/quotation/command_factory.go similarity index 59% rename from api/payments/quotation/internal/service/orchestrator/command_factory.go rename to api/payments/quotation/internal/service/quotation/command_factory.go index ed7b6c09..9bb96e29 100644 --- a/api/payments/quotation/internal/service/orchestrator/command_factory.go +++ b/api/payments/quotation/internal/service/quotation/command_factory.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" @@ -8,13 +8,14 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/v2/bson" ) type paymentEngine interface { EnsureRepository(ctx context.Context) error BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) - ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) - ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error + BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, quote *orchestratorv1.PaymentQuote) (*model.PaymentPlan, error) + ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) Repository() storage.Repository } @@ -30,12 +31,12 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri return e.svc.buildPaymentQuote(ctx, orgRef, req) } -func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) { - return e.svc.resolvePaymentQuote(ctx, in) +func (e defaultPaymentEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, quote *orchestratorv1.PaymentQuote) (*model.PaymentPlan, error) { + return e.svc.buildPaymentPlan(ctx, orgID, intent, idempotencyKey, quote) } -func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { - return e.svc.executePayment(ctx, store, payment, quote) +func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) { + return e.svc.resolvePaymentQuote(ctx, in) } func (e defaultPaymentEngine) Repository() storage.Repository { @@ -57,41 +58,13 @@ func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paym func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand { return "ePaymentCommand{ engine: f.engine, - logger: f.logger.Named("quote_payment"), + logger: f.logger.Named("quote.payment"), } } func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand { return "ePaymentsCommand{ engine: f.engine, - logger: f.logger.Named("quote_payments"), - } -} - -func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand { - return &initiatePaymentCommand{ - engine: f.engine, - logger: f.logger.Named("initiate_payment"), - } -} - -func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand { - return &initiatePaymentsCommand{ - engine: f.engine, - logger: f.logger.Named("initiate_payments"), - } -} - -func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand { - return &cancelPaymentCommand{ - engine: f.engine, - logger: f.logger.Named("cancel_payment"), - } -} - -func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand { - return &initiateConversionCommand{ - engine: f.engine, - logger: f.logger.Named("initiate_conversion"), + logger: f.logger.Named("quote.payments"), } } diff --git a/api/payments/quotation/internal/service/quotation/compat_helpers.go b/api/payments/quotation/internal/service/quotation/compat_helpers.go new file mode 100644 index 00000000..940912b9 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/compat_helpers.go @@ -0,0 +1,6 @@ +package quotation + +const ( + providerSettlementMetaPaymentIntentID = "payment_ref" + providerSettlementMetaOutgoingLeg = "outgoing_leg" +) diff --git a/api/payments/quotation/internal/service/orchestrator/composite_gateway_registry.go b/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go similarity index 98% rename from api/payments/quotation/internal/service/orchestrator/composite_gateway_registry.go rename to api/payments/quotation/internal/service/quotation/composite_gateway_registry.go index d2a266a5..0b393d3b 100644 --- a/api/payments/quotation/internal/service/orchestrator/composite_gateway_registry.go +++ b/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" diff --git a/api/payments/quotation/internal/service/orchestrator/convert.go b/api/payments/quotation/internal/service/quotation/convert.go similarity index 70% rename from api/payments/quotation/internal/service/orchestrator/convert.go rename to api/payments/quotation/internal/service/quotation/convert.go index b01fa470..e1ab5969 100644 --- a/api/payments/quotation/internal/service/orchestrator/convert.go +++ b/api/payments/quotation/internal/service/quotation/convert.go @@ -1,8 +1,7 @@ -package orchestrator +package quotation import ( "strings" - "time" "github.com/tech/sendico/payments/storage/model" chainasset "github.com/tech/sendico/pkg/chain" @@ -10,12 +9,10 @@ import ( feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "google.golang.org/protobuf/types/known/timestamppb" ) func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent { @@ -123,46 +120,6 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS } } -func toProtoPayment(src *model.Payment) *orchestratorv1.Payment { - if src == nil { - return nil - } - payment := &orchestratorv1.Payment{ - PaymentRef: src.PaymentRef, - IdempotencyKey: src.IdempotencyKey, - Intent: protoIntentFromModel(src.Intent), - State: protoStateFromModel(src.State), - FailureCode: protoFailureFromModel(src.FailureCode), - FailureReason: src.FailureReason, - LastQuote: modelQuoteToProto(src.LastQuote), - Execution: protoExecutionFromModel(src.Execution), - ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan), - PaymentPlan: protoPaymentPlanFromModel(src.PaymentPlan), - Metadata: cloneMetadata(src.Metadata), - } - if src.CardPayout != nil { - payment.CardPayout = &orchestratorv1.CardPayout{ - PayoutRef: src.CardPayout.PayoutRef, - ProviderPaymentId: src.CardPayout.ProviderPaymentID, - Status: src.CardPayout.Status, - FailureReason: src.CardPayout.FailureReason, - CardCountry: src.CardPayout.CardCountry, - MaskedPan: src.CardPayout.MaskedPan, - ProviderCode: src.CardPayout.ProviderCode, - GatewayReference: src.CardPayout.GatewayReference, - } - } - if src.CreatedAt.IsZero() { - payment.CreatedAt = timestamppb.New(time.Now().UTC()) - } else { - payment.CreatedAt = timestamppb.New(src.CreatedAt.UTC()) - } - if src.UpdatedAt != (time.Time{}) { - payment.UpdatedAt = timestamppb.New(src.UpdatedAt.UTC()) - } - return payment -} - func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent { intent := &orchestratorv1.PaymentIntent{ Ref: src.Ref, @@ -291,99 +248,6 @@ func protoFXIntentFromModel(src *model.FXIntent) *orchestratorv1.FXIntent { } } -func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.ExecutionRefs { - if src == nil { - return nil - } - return &orchestratorv1.ExecutionRefs{ - DebitEntryRef: src.DebitEntryRef, - CreditEntryRef: src.CreditEntryRef, - FxEntryRef: src.FXEntryRef, - ChainTransferRef: src.ChainTransferRef, - CardPayoutRef: src.CardPayoutRef, - FeeTransferRef: src.FeeTransferRef, - } -} - -func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.ExecutionStep { - if src == nil { - return nil - } - return &orchestratorv1.ExecutionStep{ - Code: src.Code, - Description: src.Description, - Amount: protoMoney(src.Amount), - NetworkFee: protoMoney(src.NetworkFee), - SourceWalletRef: src.SourceWalletRef, - DestinationRef: src.DestinationRef, - TransferRef: src.TransferRef, - Metadata: cloneMetadata(src.Metadata), - OperationRef: src.OperationRef, - } -} - -func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.ExecutionPlan { - if src == nil { - return nil - } - steps := make([]*orchestratorv1.ExecutionStep, 0, len(src.Steps)) - for _, step := range src.Steps { - if protoStep := protoExecutionStepFromModel(step); protoStep != nil { - steps = append(steps, protoStep) - } - } - if len(steps) == 0 { - steps = nil - } - return &orchestratorv1.ExecutionPlan{ - Steps: steps, - TotalNetworkFee: protoMoney(src.TotalNetworkFee), - } -} - -func protoPaymentStepFromModel(src *model.PaymentStep) *orchestratorv1.PaymentStep { - if src == nil { - return nil - } - return &orchestratorv1.PaymentStep{ - Rail: protoRailFromModel(src.Rail), - GatewayId: strings.TrimSpace(src.GatewayID), - Action: protoRailOperationFromModel(src.Action), - Amount: protoMoney(src.Amount), - StepId: strings.TrimSpace(src.StepID), - InstanceId: strings.TrimSpace(src.InstanceID), - DependsOn: cloneStringList(src.DependsOn), - CommitPolicy: strings.TrimSpace(string(src.CommitPolicy)), - CommitAfter: cloneStringList(src.CommitAfter), - } -} - -func protoPaymentPlanFromModel(src *model.PaymentPlan) *orchestratorv1.PaymentPlan { - if src == nil { - return nil - } - steps := make([]*orchestratorv1.PaymentStep, 0, len(src.Steps)) - for _, step := range src.Steps { - if protoStep := protoPaymentStepFromModel(step); protoStep != nil { - steps = append(steps, protoStep) - } - } - if len(steps) == 0 { - steps = nil - } - plan := &orchestratorv1.PaymentPlan{ - Id: strings.TrimSpace(src.ID), - Steps: steps, - IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), - FxQuote: fxQuoteToProto(src.FXQuote), - Fees: feeLinesToProto(src.Fees), - } - if !src.CreatedAt.IsZero() { - plan.CreatedAt = timestamppb.New(src.CreatedAt.UTC()) - } - return plan -} - func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote { if src == nil { return nil @@ -401,28 +265,6 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ } } -func filterFromProto(req *orchestratorv1.ListPaymentsRequest) *model.PaymentFilter { - if req == nil { - return &model.PaymentFilter{} - } - filter := &model.PaymentFilter{ - SourceRef: strings.TrimSpace(req.GetSourceRef()), - DestinationRef: strings.TrimSpace(req.GetDestinationRef()), - OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()), - } - if req.GetPage() != nil { - filter.Cursor = strings.TrimSpace(req.GetPage().GetCursor()) - filter.Limit = req.GetPage().GetLimit() - } - if len(req.GetFilterStates()) > 0 { - filter.States = make([]model.PaymentState, 0, len(req.GetFilterStates())) - for _, st := range req.GetFilterStates() { - filter.States = append(filter.States, modelStateFromProto(st)) - } - } - return filter -} - func protoKindFromModel(kind model.PaymentKind) orchestratorv1.PaymentKind { switch kind { case model.PaymentKindPayout: @@ -449,109 +291,6 @@ func modelKindFromProto(kind orchestratorv1.PaymentKind) model.PaymentKind { } } -func protoRailFromModel(rail model.Rail) gatewayv1.Rail { - switch strings.ToUpper(strings.TrimSpace(string(rail))) { - case string(model.RailCrypto): - return gatewayv1.Rail_RAIL_CRYPTO - case string(model.RailProviderSettlement): - return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT - case string(model.RailLedger): - return gatewayv1.Rail_RAIL_LEDGER - case string(model.RailCardPayout): - return gatewayv1.Rail_RAIL_CARD_PAYOUT - case string(model.RailFiatOnRamp): - return gatewayv1.Rail_RAIL_FIAT_ONRAMP - default: - return gatewayv1.Rail_RAIL_UNSPECIFIED - } -} - -func protoRailOperationFromModel(action model.RailOperation) gatewayv1.RailOperation { - switch strings.ToUpper(strings.TrimSpace(string(action))) { - case string(model.RailOperationDebit): - return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT - case string(model.RailOperationCredit): - return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT - case string(model.RailOperationExternalDebit): - return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT - case string(model.RailOperationExternalCredit): - return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT - case string(model.RailOperationMove): - return gatewayv1.RailOperation_RAIL_OPERATION_MOVE - case string(model.RailOperationSend): - return gatewayv1.RailOperation_RAIL_OPERATION_SEND - case string(model.RailOperationFee): - return gatewayv1.RailOperation_RAIL_OPERATION_FEE - case string(model.RailOperationObserveConfirm): - return gatewayv1.RailOperation_RAIL_OPERATION_OBSERVE_CONFIRM - case string(model.RailOperationFXConvert): - return gatewayv1.RailOperation_RAIL_OPERATION_FX_CONVERT - case string(model.RailOperationBlock): - return gatewayv1.RailOperation_RAIL_OPERATION_BLOCK - case string(model.RailOperationRelease): - return gatewayv1.RailOperation_RAIL_OPERATION_RELEASE - default: - return gatewayv1.RailOperation_RAIL_OPERATION_UNSPECIFIED - } -} - -func protoStateFromModel(state model.PaymentState) orchestratorv1.PaymentState { - switch state { - case model.PaymentStateAccepted: - return orchestratorv1.PaymentState_PAYMENT_STATE_ACCEPTED - case model.PaymentStateFundsReserved: - return orchestratorv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED - case model.PaymentStateSubmitted: - return orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED - case model.PaymentStateSettled: - return orchestratorv1.PaymentState_PAYMENT_STATE_SETTLED - case model.PaymentStateFailed: - return orchestratorv1.PaymentState_PAYMENT_STATE_FAILED - case model.PaymentStateCancelled: - return orchestratorv1.PaymentState_PAYMENT_STATE_CANCELLED - default: - return orchestratorv1.PaymentState_PAYMENT_STATE_UNSPECIFIED - } -} - -func modelStateFromProto(state orchestratorv1.PaymentState) model.PaymentState { - switch state { - case orchestratorv1.PaymentState_PAYMENT_STATE_ACCEPTED: - return model.PaymentStateAccepted - case orchestratorv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED: - return model.PaymentStateFundsReserved - case orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED: - return model.PaymentStateSubmitted - case orchestratorv1.PaymentState_PAYMENT_STATE_SETTLED: - return model.PaymentStateSettled - case orchestratorv1.PaymentState_PAYMENT_STATE_FAILED: - return model.PaymentStateFailed - case orchestratorv1.PaymentState_PAYMENT_STATE_CANCELLED: - return model.PaymentStateCancelled - default: - return model.PaymentStateUnspecified - } -} - -func protoFailureFromModel(code model.PaymentFailureCode) orchestratorv1.PaymentFailureCode { - switch code { - case model.PaymentFailureCodeBalance: - return orchestratorv1.PaymentFailureCode_FAILURE_BALANCE - case model.PaymentFailureCodeLedger: - return orchestratorv1.PaymentFailureCode_FAILURE_LEDGER - case model.PaymentFailureCodeFX: - return orchestratorv1.PaymentFailureCode_FAILURE_FX - case model.PaymentFailureCodeChain: - return orchestratorv1.PaymentFailureCode_FAILURE_CHAIN - case model.PaymentFailureCodeFees: - return orchestratorv1.PaymentFailureCode_FAILURE_FEES - case model.PaymentFailureCodePolicy: - return orchestratorv1.PaymentFailureCode_FAILURE_POLICY - default: - return orchestratorv1.PaymentFailureCode_FAILURE_UNSPECIFIED - } -} - func settlementModeFromProto(mode orchestratorv1.SettlementMode) model.SettlementMode { switch mode { case orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE: diff --git a/api/payments/quotation/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/discovery_gateway_registry.go rename to api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go index e5afe437..0a6fd864 100644 --- a/api/payments/quotation/internal/service/orchestrator/discovery_gateway_registry.go +++ b/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" diff --git a/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go b/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go new file mode 100644 index 00000000..49564387 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go @@ -0,0 +1,12 @@ +package quotation + +func (s *Service) Shutdown() { + if s == nil { + return + } + for _, consumer := range s.gatewayConsumers { + if consumer != nil { + consumer.Close() + } + } +} diff --git a/api/payments/quotation/internal/service/orchestrator/gateway_registry.go b/api/payments/quotation/internal/service/quotation/gateway_registry.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/gateway_registry.go rename to api/payments/quotation/internal/service/quotation/gateway_registry.go index 9abf3673..cc7b5482 100644 --- a/api/payments/quotation/internal/service/orchestrator/gateway_registry.go +++ b/api/payments/quotation/internal/service/quotation/gateway_registry.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" diff --git a/api/payments/quotation/internal/service/orchestrator/gateway_resolution.go b/api/payments/quotation/internal/service/quotation/gateway_resolution.go similarity index 94% rename from api/payments/quotation/internal/service/orchestrator/gateway_resolution.go rename to api/payments/quotation/internal/service/quotation/gateway_resolution.go index 68229a10..4934ea06 100644 --- a/api/payments/quotation/internal/service/orchestrator/gateway_resolution.go +++ b/api/payments/quotation/internal/service/quotation/gateway_resolution.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" @@ -7,6 +7,7 @@ import ( "github.com/shopspring/decimal" chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/payments/quotation/internal/service/plan" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" @@ -55,7 +56,7 @@ func (s *Service) resolveChainGatewayClient(ctx context.Context, network string, return nil, nil, merrors.NoData("chain gateway unavailable") } -func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { +func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, dir plan.SendDirection) (*model.GatewayInstanceDescriptor, error) { if registry == nil { return nil, merrors.NoData("gateway registry unavailable") } diff --git a/api/payments/quotation/internal/service/quotation/handlers_commands.go b/api/payments/quotation/internal/service/quotation/handlers_commands.go new file mode 100644 index 00000000..00599220 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/handlers_commands.go @@ -0,0 +1,602 @@ +package quotation + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "sort" + "strings" + "time" + + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/mutil/mzap" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" + "google.golang.org/protobuf/proto" +) + +type quotePaymentCommand struct { + engine paymentEngine + logger mlogger.Logger +} + +var ( + errIdempotencyRequired = errors.New("idempotency key is required") + errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") + errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") +) + +type quoteCtx struct { + orgID string + orgRef bson.ObjectID + intent *orchestratorv1.PaymentIntent + previewOnly bool + idempotencyKey string + hash string +} + +func (h *quotePaymentCommand) Execute( + ctx context.Context, + req *orchestratorv1.QuotePaymentRequest, +) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { + + if err := h.engine.EnsureRepository(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + + qc, err := h.prepareQuoteCtx(req) + if err != nil { + return h.mapQuoteErr(err) + } + + quotesStore, err := ensureQuotesStore(h.engine.Repository()) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req) + if err != nil { + return h.mapQuoteErr(err) + } + + return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{ + IdempotencyKey: req.GetIdempotencyKey(), + Quote: quoteProto, + }) +} + +func (h *quotePaymentCommand) prepareQuoteCtx(req *orchestratorv1.QuotePaymentRequest) (*quoteCtx, error) { + orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + if err != nil { + return nil, err + } + if err := requireNonNilIntent(req.GetIntent()); err != nil { + return nil, err + } + + intent := req.GetIntent() + preview := req.GetPreviewOnly() + idem := strings.TrimSpace(req.GetIdempotencyKey()) + + if preview && idem != "" { + return nil, errPreviewWithIdempotency + } + if !preview && idem == "" { + return nil, errIdempotencyRequired + } + + return "eCtx{ + orgID: orgRef, + orgRef: orgID, + intent: intent, + previewOnly: preview, + idempotencyKey: idem, + hash: hashQuoteRequest(req), + }, nil +} + +func (h *quotePaymentCommand) quotePayment( + ctx context.Context, + quotesStore storage.QuotesStore, + qc *quoteCtx, + req *orchestratorv1.QuotePaymentRequest, +) (*orchestratorv1.PaymentQuote, error) { + + if qc.previewOnly { + quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) + if err != nil { + h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID)) + return nil, err + } + quote.QuoteRef = bson.NewObjectID().Hex() + return quote, nil + } + + existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) + if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) { + h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err), + mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey), + ) + return nil, err + } + if existing != nil { + if existing.Hash != qc.hash { + return nil, errIdempotencyParamMismatch + } + h.logger.Debug( + "Idempotent quote reused", + mzap.ObjRef("org_ref", qc.orgRef), + zap.String("idempotency_key", qc.idempotencyKey), + zap.String("quote_ref", existing.QuoteRef), + ) + return modelQuoteToProto(existing.Quote), nil + } + + quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) + if err != nil { + h.logger.Warn( + "Failed to build payment quote", + zap.Error(err), + mzap.ObjRef("org_ref", qc.orgRef), + zap.String("idempotency_key", qc.idempotencyKey), + ) + return nil, err + } + + quoteRef := bson.NewObjectID().Hex() + quote.QuoteRef = quoteRef + + plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote) + if err != nil { + h.logger.Warn( + "Failed to build payment plan", + zap.Error(err), + mzap.ObjRef("org_ref", qc.orgRef), + zap.String("idempotency_key", qc.idempotencyKey), + ) + return nil, err + } + + record := &model.PaymentQuoteRecord{ + QuoteRef: quoteRef, + IdempotencyKey: qc.idempotencyKey, + Hash: qc.hash, + Intent: intentFromProto(qc.intent), + Quote: quoteSnapshotToModel(quote), + Plan: cloneStoredPaymentPlan(plan), + ExpiresAt: expiresAt, + } + record.SetID(bson.NewObjectID()) + record.SetOrganizationRef(qc.orgRef) + + if err := quotesStore.Create(ctx, record); err != nil { + if errors.Is(err, storage.ErrDuplicateQuote) { + existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) + if getErr == nil && existing != nil { + if existing.Hash != qc.hash { + return nil, errIdempotencyParamMismatch + } + return modelQuoteToProto(existing.Quote), nil + } + } + return nil, err + } + + h.logger.Info( + "Stored payment quote", + zap.String("quote_ref", quoteRef), + mzap.ObjRef("org_ref", qc.orgRef), + zap.String("idempotency_key", qc.idempotencyKey), + zap.String("kind", qc.intent.GetKind().String()), + ) + + return quote, nil +} + +func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { + if errors.Is(err, errIdempotencyRequired) || + errors.Is(err, errPreviewWithIdempotency) || + errors.Is(err, errIdempotencyParamMismatch) { + return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) + } + return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) +} + +// TODO: temprorarary hashing function, replace with a proper solution later +func hashQuoteRequest(req *orchestratorv1.QuotePaymentRequest) string { + cloned := proto.Clone(req).(*orchestratorv1.QuotePaymentRequest) + cloned.Meta = nil + cloned.IdempotencyKey = "" + cloned.PreviewOnly = false + + b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned) + if err != nil { + sum := sha256.Sum256([]byte("marshal_error")) + return hex.EncodeToString(sum[:]) + } + + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +type quotePaymentsCommand struct { + engine paymentEngine + logger mlogger.Logger +} + +var ( + errBatchIdempotencyRequired = errors.New("idempotency key is required") + errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") + errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") + errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape") +) + +type quotePaymentsCtx struct { + orgID string + orgRef bson.ObjectID + previewOnly bool + idempotencyKey string + hash string + intentCount int +} + +func (h *quotePaymentsCommand) Execute( + ctx context.Context, + req *orchestratorv1.QuotePaymentsRequest, +) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] { + + if err := h.engine.EnsureRepository(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + + qc, intents, err := h.prepare(req) + if err != nil { + return h.mapErr(err) + } + + quotesStore, err := ensureQuotesStore(h.engine.Repository()) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + if qc.previewOnly { + quotes, _, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, true) + if err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + aggregate, expiresAt, err := h.aggregate(quotes, expires) + if err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + _ = expiresAt + return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{ + QuoteRef: "", + Aggregate: aggregate, + Quotes: quotes, + }) + } + + if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } else if ok { + return gsresponse.Success(h.responseFromRecord(rec)) + } + + quotes, plans, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, false) + if err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + aggregate, expiresAt, err := h.aggregate(quotes, expires) + if err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + quoteRef := bson.NewObjectID().Hex() + for _, q := range quotes { + if q != nil { + q.QuoteRef = quoteRef + } + } + + rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, plans, expiresAt) + if err != nil { + return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + if rec != nil { + return gsresponse.Success(h.responseFromRecord(rec)) + } + + h.logger.Info( + "Stored payment quotes", + h.logFields(qc, quoteRef, expiresAt, len(quotes))..., + ) + + return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{ + IdempotencyKey: req.GetIdempotencyKey(), + QuoteRef: quoteRef, + Aggregate: aggregate, + Quotes: quotes, + }) +} + +func (h *quotePaymentsCommand) prepare(req *orchestratorv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*orchestratorv1.PaymentIntent, error) { + orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + if err != nil { + return nil, nil, err + } + + intents := req.GetIntents() + if len(intents) == 0 { + return nil, nil, merrors.InvalidArgument("intents are required") + } + for _, intent := range intents { + if err := requireNonNilIntent(intent); err != nil { + return nil, nil, err + } + } + + preview := req.GetPreviewOnly() + idem := strings.TrimSpace(req.GetIdempotencyKey()) + + if preview && idem != "" { + return nil, nil, errBatchPreviewWithIdempotency + } + if !preview && idem == "" { + return nil, nil, errBatchIdempotencyRequired + } + + hash, err := hashQuotePaymentsIntents(intents) + if err != nil { + return nil, nil, err + } + + return "ePaymentsCtx{ + orgID: orgRefStr, + orgRef: orgID, + previewOnly: preview, + idempotencyKey: idem, + hash: hash, + intentCount: len(intents), + }, intents, nil +} + +func (h *quotePaymentsCommand) tryReuse( + ctx context.Context, + quotesStore storage.QuotesStore, + qc *quotePaymentsCtx, +) (*model.PaymentQuoteRecord, bool, error) { + + rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) + if err != nil { + if errors.Is(err, storage.ErrQuoteNotFound) { + return nil, false, nil + } + h.logger.Warn( + "Failed to lookup payment quotes by idempotency key", + h.logFields(qc, "", time.Time{}, 0)..., + ) + return nil, false, err + } + + if len(rec.Quotes) == 0 { + return nil, false, errBatchIdempotencyShapeMismatch + } + if rec.Hash != qc.hash { + return nil, false, errBatchIdempotencyParamMismatch + } + + h.logger.Debug( + "Idempotent payment quotes reused", + h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))..., + ) + + return rec, true, nil +} + +func (h *quotePaymentsCommand) buildQuotes( + ctx context.Context, + meta *orchestratorv1.RequestMeta, + orgRef bson.ObjectID, + baseKey string, + intents []*orchestratorv1.PaymentIntent, + preview bool, +) ([]*orchestratorv1.PaymentQuote, []*model.PaymentPlan, []time.Time, error) { + + quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents)) + plans := make([]*model.PaymentPlan, 0, len(intents)) + expires := make([]time.Time, 0, len(intents)) + + for i, intent := range intents { + perKey := perIntentIdempotencyKey(baseKey, i, len(intents)) + req := &orchestratorv1.QuotePaymentRequest{ + Meta: meta, + IdempotencyKey: perKey, + Intent: intent, + PreviewOnly: preview, + } + q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req) + if err != nil { + h.logger.Warn( + "Failed to build payment quote (batch item)", + zap.Int("idx", i), + zap.Error(err), + ) + return nil, nil, nil, err + } + if !preview { + plan, err := h.engine.BuildPaymentPlan(ctx, orgRef, intent, perKey, q) + if err != nil { + h.logger.Warn( + "Failed to build payment plan (batch item)", + zap.Int("idx", i), + zap.Error(err), + ) + return nil, nil, nil, err + } + plans = append(plans, cloneStoredPaymentPlan(plan)) + } + quotes = append(quotes, q) + expires = append(expires, exp) + } + + return quotes, plans, expires, nil +} + +func (h *quotePaymentsCommand) aggregate( + quotes []*orchestratorv1.PaymentQuote, + expires []time.Time, +) (*orchestratorv1.PaymentQuoteAggregate, time.Time, error) { + + agg, err := aggregatePaymentQuotes(quotes) + if err != nil { + return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed") + } + + expiresAt, ok := minQuoteExpiry(expires) + if !ok { + return nil, time.Time{}, merrors.Internal("quote expiry missing") + } + + return agg, expiresAt, nil +} + +func (h *quotePaymentsCommand) storeBatch( + ctx context.Context, + quotesStore storage.QuotesStore, + qc *quotePaymentsCtx, + quoteRef string, + intents []*orchestratorv1.PaymentIntent, + quotes []*orchestratorv1.PaymentQuote, + plans []*model.PaymentPlan, + expiresAt time.Time, +) (*model.PaymentQuoteRecord, error) { + + record := &model.PaymentQuoteRecord{ + QuoteRef: quoteRef, + IdempotencyKey: qc.idempotencyKey, + Hash: qc.hash, + Intents: intentsFromProto(intents), + Quotes: quoteSnapshotsFromProto(quotes), + Plans: cloneStoredPaymentPlans(plans), + ExpiresAt: expiresAt, + } + record.SetID(bson.NewObjectID()) + record.SetOrganizationRef(qc.orgRef) + + if err := quotesStore.Create(ctx, record); err != nil { + if errors.Is(err, storage.ErrDuplicateQuote) { + rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc) + if reuseErr != nil { + return nil, reuseErr + } + if ok { + return rec, nil + } + return nil, err + } + return nil, err + } + + return nil, nil +} + +func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *orchestratorv1.QuotePaymentsResponse { + quotes := modelQuotesToProto(rec.Quotes) + for _, q := range quotes { + if q != nil { + q.QuoteRef = rec.QuoteRef + } + } + aggregate, _ := aggregatePaymentQuotes(quotes) + + return &orchestratorv1.QuotePaymentsResponse{ + QuoteRef: rec.QuoteRef, + Aggregate: aggregate, + Quotes: quotes, + } +} + +func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field { + fields := []zap.Field{ + mzap.ObjRef("org_ref", qc.orgRef), + zap.String("org_ref_str", qc.orgID), + zap.String("idempotency_key", qc.idempotencyKey), + zap.String("hash", qc.hash), + zap.Bool("preview_only", qc.previewOnly), + zap.Int("intent_count", qc.intentCount), + } + if quoteRef != "" { + fields = append(fields, zap.String("quote_ref", quoteRef)) + } + if !expiresAt.IsZero() { + fields = append(fields, zap.Time("expires_at", expiresAt)) + } + if quoteCount > 0 { + fields = append(fields, zap.Int("quote_count", quoteCount)) + } + return fields +} + +func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] { + if errors.Is(err, errBatchIdempotencyRequired) || + errors.Is(err, errBatchPreviewWithIdempotency) || + errors.Is(err, errBatchIdempotencyParamMismatch) || + errors.Is(err, errBatchIdempotencyShapeMismatch) { + return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) +} + +func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*orchestratorv1.PaymentQuote { + if len(snaps) == 0 { + return nil + } + out := make([]*orchestratorv1.PaymentQuote, 0, len(snaps)) + for _, s := range snaps { + out = append(out, modelQuoteToProto(s)) + } + return out +} + +func hashQuotePaymentsIntents(intents []*orchestratorv1.PaymentIntent) (string, error) { + type item struct { + Idx int + H [32]byte + } + items := make([]item, 0, len(intents)) + + for i, intent := range intents { + b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent) + if err != nil { + return "", err + } + items = append(items, item{Idx: i, H: sha256.Sum256(b)}) + } + + sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx }) + + h := sha256.New() + h.Write([]byte("quote-payments-fp/v1")) + h.Write([]byte{0}) + for _, it := range items { + h.Write(it.H[:]) + h.Write([]byte{0}) + } + + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/api/payments/quotation/internal/service/orchestrator/helpers.go b/api/payments/quotation/internal/service/quotation/helpers.go similarity index 82% rename from api/payments/quotation/internal/service/orchestrator/helpers.go rename to api/payments/quotation/internal/service/quotation/helpers.go index 3b42580d..cae9f5a7 100644 --- a/api/payments/quotation/internal/service/orchestrator/helpers.go +++ b/api/payments/quotation/internal/service/quotation/helpers.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "strings" @@ -6,9 +6,7 @@ import ( "github.com/shopspring/decimal" oracleclient "github.com/tech/sendico/fx/oracle/client" - "github.com/tech/sendico/pkg/merrors" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "google.golang.org/protobuf/proto" @@ -359,45 +357,6 @@ func setFeeLineWalletRef(lines []*feesv1.DerivedPostingLine, walletRef, walletTy } } -func ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.PostingLine { - if len(lines) == 0 { - return nil - } - charges := make([]*ledgerv1.PostingLine, 0, len(lines)) - for _, line := range lines { - if line == nil || isWalletTargetFeeLine(line) || strings.TrimSpace(line.GetLedgerAccountRef()) == "" { - continue - } - money := cloneProtoMoney(line.GetMoney()) - if money == nil { - continue - } - charges = append(charges, &ledgerv1.PostingLine{ - LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), - Money: money, - LineType: ledgerLineTypeFromAccounting(line.GetLineType()), - }) - } - if len(charges) == 0 { - return nil - } - return charges -} - -func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv1.LineType { - switch lineType { - case accountingv1.PostingLineType_POSTING_LINE_SPREAD: - return ledgerv1.LineType_LINE_SPREAD - case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: - return ledgerv1.LineType_LINE_REVERSAL - case accountingv1.PostingLineType_POSTING_LINE_FEE, - accountingv1.PostingLineType_POSTING_LINE_TAX: - return ledgerv1.LineType_LINE_FEE - default: - return ledgerv1.LineType_LINE_MAIN - } -} - func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time { expiry := time.Time{} if feeQuote != nil && feeQuote.GetExpiresAt() != nil { @@ -430,34 +389,3 @@ func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) [] } return lines } - -func moneyEquals(a, b moneyGetter) bool { - if a == nil || b == nil { - return false - } - if !strings.EqualFold(a.GetCurrency(), b.GetCurrency()) { - return false - } - return strings.TrimSpace(a.GetAmount()) == strings.TrimSpace(b.GetAmount()) -} - -func conversionAmountFromMetadata(meta map[string]string, fx *orchestratorv1.FXIntent) (*moneyv1.Money, error) { - if meta == nil { - meta = map[string]string{} - } - amount := strings.TrimSpace(meta["amount"]) - if amount == "" { - return nil, merrors.InvalidArgument("conversion amount metadata is required") - } - currency := strings.TrimSpace(meta["currency"]) - if currency == "" && fx != nil && fx.GetPair() != nil { - currency = strings.TrimSpace(fx.GetPair().GetBase()) - } - if currency == "" { - return nil, merrors.InvalidArgument("conversion currency metadata is required") - } - return &moneyv1.Money{ - Currency: currency, - Amount: amount, - }, nil -} diff --git a/api/payments/quotation/internal/service/orchestrator/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go similarity index 80% rename from api/payments/quotation/internal/service/orchestrator/internal_helpers.go rename to api/payments/quotation/internal/service/quotation/internal_helpers.go index 94d5b22d..3e37b356 100644 --- a/api/payments/quotation/internal/service/orchestrator/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -1,16 +1,14 @@ -package orchestrator +package quotation import ( "context" "strings" "time" - "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/mservice" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -107,26 +105,3 @@ func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIn Side: fxv1.Side_SELL_BASE_BUY_QUOTE, } } - -func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState { - switch status { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: - return model.PaymentStateFundsReserved - - case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: - return model.PaymentStateSubmitted - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - return model.PaymentStateSettled - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - return model.PaymentStateFailed - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - return model.PaymentStateCancelled - - default: - return model.PaymentStateUnspecified - } -} diff --git a/api/payments/quotation/internal/service/orchestrator/metrics.go b/api/payments/quotation/internal/service/quotation/metrics.go similarity index 98% rename from api/payments/quotation/internal/service/orchestrator/metrics.go rename to api/payments/quotation/internal/service/quotation/metrics.go index 417eb90e..d6a5bf19 100644 --- a/api/payments/quotation/internal/service/orchestrator/metrics.go +++ b/api/payments/quotation/internal/service/quotation/metrics.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "errors" diff --git a/api/payments/quotation/internal/service/orchestrator/model_money.go b/api/payments/quotation/internal/service/quotation/model_money.go similarity index 92% rename from api/payments/quotation/internal/service/orchestrator/model_money.go rename to api/payments/quotation/internal/service/quotation/model_money.go index 3a8184d3..cc9d09e0 100644 --- a/api/payments/quotation/internal/service/orchestrator/model_money.go +++ b/api/payments/quotation/internal/service/quotation/model_money.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import paymenttypes "github.com/tech/sendico/pkg/payments/types" diff --git a/api/payments/quotation/internal/service/orchestrator/options.go b/api/payments/quotation/internal/service/quotation/options.go similarity index 62% rename from api/payments/quotation/internal/service/orchestrator/options.go rename to api/payments/quotation/internal/service/quotation/options.go index ccc2833f..576c2af2 100644 --- a/api/payments/quotation/internal/service/orchestrator/options.go +++ b/api/payments/quotation/internal/service/quotation/options.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" @@ -6,10 +6,8 @@ import ( "strings" "time" - "github.com/shopspring/decimal" oracleclient "github.com/tech/sendico/fx/oracle/client" chainclient "github.com/tech/sendico/gateway/chain/client" - mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" @@ -18,7 +16,6 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - "go.uber.org/zap" ) // Option configures service dependencies. @@ -67,139 +64,6 @@ type railGatewayDependency struct { logger mlogger.Logger } -func (g railGatewayDependency) available() bool { - return len(g.byID) > 0 || len(g.byRail) > 0 || (g.registry != nil && (g.chainResolver != nil || g.providerResolver != nil)) -} - -func (g railGatewayDependency) resolve(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) { - if step == nil { - return nil, merrors.InvalidArgument("rail gateway: step is required") - } - if id := strings.TrimSpace(step.GatewayID); id != "" { - if gw, ok := g.byID[id]; ok { - return gw, nil - } - return g.resolveDynamic(ctx, step) - } - if len(g.byRail) == 0 { - return g.resolveDynamic(ctx, step) - } - list := g.byRail[step.Rail] - if len(list) == 0 { - return g.resolveDynamic(ctx, step) - } - return list[0], nil -} - -func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) { - if g.registry == nil { - return nil, merrors.InvalidArgument("rail gateway: registry is required") - } - if g.chainResolver == nil && g.providerResolver == nil { - return nil, merrors.InvalidArgument("rail gateway: gateway resolver is required") - } - items, err := g.registry.List(ctx) - if err != nil { - return nil, err - } - if len(items) == 0 { - return nil, merrors.InvalidArgument("rail gateway: no gateway instances available") - } - - currency := "" - amount := decimal.Zero - if step.Amount != nil && strings.TrimSpace(step.Amount.GetAmount()) != "" { - value, err := decimalFromMoney(step.Amount) - if err != nil { - return nil, err - } - amount = value - currency = strings.ToUpper(strings.TrimSpace(step.Amount.GetCurrency())) - } - - candidates := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error - for _, entry := range items { - if entry == nil || !entry.IsEnabled { - continue - } - if entry.Rail != step.Rail { - continue - } - if step.GatewayID != "" && entry.ID != step.GatewayID { - continue - } - if step.InstanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(step.InstanceID)) { - continue - } - if step.Action != model.RailOperationUnspecified { - if err := isGatewayEligible(entry, step.Rail, "", currency, step.Action, sendDirectionForRail(step.Rail), amount); err != nil { - lastErr = err - continue - } - } - candidates = append(candidates, entry) - } - if len(candidates) == 0 { - if lastErr != nil { - return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail: " + lastErr.Error()) - } - return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail") - } - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].ID < candidates[j].ID - }) - entry := candidates[0] - invokeURI := strings.TrimSpace(entry.InvokeURI) - if invokeURI == "" { - return nil, merrors.InvalidArgument("rail gateway: invoke uri is required") - } - - cfg := chainclient.RailGatewayConfig{ - Rail: string(entry.Rail), - Network: entry.Network, - Capabilities: rail.RailCapabilities{ - CanPayIn: entry.Capabilities.CanPayIn, - CanPayOut: entry.Capabilities.CanPayOut, - CanReadBalance: entry.Capabilities.CanReadBalance, - CanSendFee: entry.Capabilities.CanSendFee, - RequiresObserveConfirm: entry.Capabilities.RequiresObserveConfirm, - CanBlock: entry.Capabilities.CanBlock, - CanRelease: entry.Capabilities.CanRelease, - }, - } - - g.logger.Info("Rail gateway resolved", - zap.String("step_id", strings.TrimSpace(step.StepID)), - zap.String("action", string(step.Action)), - zap.String("gateway_id", entry.ID), - zap.String("instance_id", entry.InstanceID), - zap.String("rail", string(entry.Rail)), - zap.String("network", entry.Network), - zap.String("invoke_uri", invokeURI)) - - switch entry.Rail { - case model.RailProviderSettlement: - if g.providerResolver == nil { - return nil, merrors.InvalidArgument("rail gateway: provider settlement resolver required") - } - client, err := g.providerResolver.Resolve(ctx, invokeURI) - if err != nil { - return nil, err - } - return NewProviderSettlementGateway(client, cfg), nil - default: - if g.chainResolver == nil { - return nil, merrors.InvalidArgument("rail gateway: chain gateway resolver required") - } - client, err := g.chainResolver.Resolve(ctx, invokeURI) - if err != nil { - return nil, err - } - return chainclient.NewRailGateway(client, cfg), nil - } -} - type oracleDependency struct { client oracleclient.Client } @@ -214,20 +78,6 @@ func (o oracleDependency) available() bool { return true } -type mntxDependency struct { - client mntxclient.Client -} - -func (m mntxDependency) available() bool { - if m.client == nil { - return false - } - if checker, ok := m.client.(interface{ Available() bool }); ok { - return checker.Available() - } - return true -} - type providerGatewayDependency struct { resolver ChainGatewayResolver } @@ -339,13 +189,6 @@ func WithOracleClient(client oracleclient.Client) Option { } } -// WithMntxGateway wires the Monetix gateway client. -func WithMntxGateway(client mntxclient.Client) Option { - return func(s *Service) { - s.deps.mntx = mntxDependency{client: client} - } -} - // WithCardGatewayRoutes configures funding/fee wallet routing per gateway. func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option { return func(s *Service) { diff --git a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go b/api/payments/quotation/internal/service/quotation/payment_plan_factory.go new file mode 100644 index 00000000..7403a512 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/payment_plan_factory.go @@ -0,0 +1,159 @@ +package quotation + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/quotation/internal/service/shared" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (s *Service) buildPaymentPlan( + ctx context.Context, + orgID bson.ObjectID, + intent *orchestratorv1.PaymentIntent, + idempotencyKey string, + quote *orchestratorv1.PaymentQuote, +) (*model.PaymentPlan, error) { + if s == nil || s.storage == nil { + return nil, errStorageUnavailable + } + if err := requireNonNilIntent(intent); err != nil { + return nil, err + } + + routeStore := s.storage.Routes() + if routeStore == nil { + return nil, merrors.InvalidArgument("routes store is required") + } + planTemplates := s.storage.PlanTemplates() + if planTemplates == nil { + return nil, merrors.InvalidArgument("plan templates store is required") + } + + builder := s.deps.planBuilder + if builder == nil { + builder = newDefaultPlanBuilder(s.logger.Named("plan_builder")) + } + + planQuote := quote + if planQuote == nil { + planQuote = &orchestratorv1.PaymentQuote{} + } + payment := newPayment(orgID, intent, strings.TrimSpace(idempotencyKey), nil, planQuote) + if ref := strings.TrimSpace(planQuote.GetQuoteRef()); ref != "" { + payment.PaymentRef = ref + } + + plan, err := builder.Build(ctx, payment, planQuote, routeStore, planTemplates, s.deps.gatewayRegistry) + if err != nil { + return nil, err + } + if plan == nil || len(plan.Steps) == 0 { + return nil, merrors.InvalidArgument("payment plan is required") + } + return plan, nil +} + +func cloneStoredPaymentPlans(plans []*model.PaymentPlan) []*model.PaymentPlan { + if len(plans) == 0 { + return nil + } + out := make([]*model.PaymentPlan, 0, len(plans)) + for _, p := range plans { + if p == nil { + out = append(out, nil) + continue + } + out = append(out, cloneStoredPaymentPlan(p)) + } + return out +} + +func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan { + if src == nil { + return nil + } + clone := &model.PaymentPlan{ + ID: strings.TrimSpace(src.ID), + IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), + CreatedAt: src.CreatedAt, + FXQuote: cloneStoredFXQuote(src.FXQuote), + Fees: cloneStoredFeeLines(src.Fees), + } + if len(src.Steps) > 0 { + clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps)) + for _, step := range src.Steps { + if step == nil { + clone.Steps = append(clone.Steps, nil) + continue + } + stepClone := &model.PaymentStep{ + StepID: strings.TrimSpace(step.StepID), + Rail: step.Rail, + GatewayID: strings.TrimSpace(step.GatewayID), + InstanceID: strings.TrimSpace(step.InstanceID), + Action: step.Action, + DependsOn: cloneStringList(step.DependsOn), + CommitPolicy: step.CommitPolicy, + CommitAfter: cloneStringList(step.CommitAfter), + Amount: cloneMoney(step.Amount), + FromRole: shared.CloneAccountRole(step.FromRole), + ToRole: shared.CloneAccountRole(step.ToRole), + } + clone.Steps = append(clone.Steps, stepClone) + } + } + return clone +} + +func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote { + if src == nil { + return nil + } + result := &paymenttypes.FXQuote{ + QuoteRef: strings.TrimSpace(src.QuoteRef), + Side: src.Side, + ExpiresAtUnixMs: src.ExpiresAtUnixMs, + Provider: strings.TrimSpace(src.Provider), + RateRef: strings.TrimSpace(src.RateRef), + Firm: src.Firm, + BaseAmount: cloneMoney(src.BaseAmount), + QuoteAmount: cloneMoney(src.QuoteAmount), + } + if src.Pair != nil { + result.Pair = &paymenttypes.CurrencyPair{ + Base: strings.TrimSpace(src.Pair.Base), + Quote: strings.TrimSpace(src.Pair.Quote), + } + } + if src.Price != nil { + result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)} + } + return result +} + +func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine { + if len(lines) == 0 { + return nil + } + result := make([]*paymenttypes.FeeLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + result = append(result, nil) + continue + } + result = append(result, &paymenttypes.FeeLine{ + LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), + Money: cloneMoney(line.Money), + LineType: line.LineType, + Side: line.Side, + Meta: cloneMetadata(line.Meta), + }) + } + return result +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder.go b/api/payments/quotation/internal/service/quotation/plan_builder.go similarity index 98% rename from api/payments/quotation/internal/service/orchestrator/plan_builder.go rename to api/payments/quotation/internal/service/quotation/plan_builder.go index 40b8414b..3e3a422e 100644 --- a/api/payments/quotation/internal/service/orchestrator/plan_builder.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go new file mode 100644 index 00000000..8bfc2a5a --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go @@ -0,0 +1,34 @@ +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" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +type defaultPlanBuilder struct { + inner plan.Builder +} + +func newDefaultPlanBuilder(logger mlogger.Logger) PlanBuilder { + return &defaultPlanBuilder{inner: plan.NewDefaultBuilder(logger)} +} + +func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { + return b.inner.Build(ctx, payment, quote, routes, templates, gateways) +} + +func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { + return plan.RailFromEndpoint(endpoint, attrs, isSource) +} + +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 PlanTemplateStore, sourceRail, 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/plan_builder_compat.go b/api/payments/quotation/internal/service/quotation/plan_builder_compat.go new file mode 100644 index 00000000..9fc9d32a --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/plan_builder_compat.go @@ -0,0 +1,19 @@ +package quotation + +import ( + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/quotation/internal/service/plan" + "github.com/tech/sendico/payments/storage/model" +) + +func sendDirectionForRail(rail model.Rail) plan.SendDirection { + return plan.SendDirectionForRail(rail) +} + +func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir plan.SendDirection, amount decimal.Decimal) error { + return plan.IsGatewayEligible(gw, rail, network, currency, action, dir, amount) +} + +func parseRailValue(value string) model.Rail { + return plan.ParseRailValue(value) +} diff --git a/api/payments/quotation/internal/service/orchestrator/provider_settlement_gateway.go b/api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/provider_settlement_gateway.go rename to api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go index dbdc8093..43f00cca 100644 --- a/api/payments/quotation/internal/service/orchestrator/provider_settlement_gateway.go +++ b/api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" diff --git a/api/payments/quotation/internal/service/orchestrator/quotation_app.go b/api/payments/quotation/internal/service/quotation/quotation_app.go similarity index 86% rename from api/payments/quotation/internal/service/orchestrator/quotation_app.go rename to api/payments/quotation/internal/service/quotation/quotation_app.go index aaa5fe8d..d2e8d08c 100644 --- a/api/payments/quotation/internal/service/orchestrator/quotation_app.go +++ b/api/payments/quotation/internal/service/quotation/quotation_app.go @@ -1,10 +1,10 @@ -package orchestrator +package quotation import ( "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/mlogger" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "google.golang.org/grpc" ) @@ -29,7 +29,7 @@ func (s *QuotationService) Register(router routers.GRPC) error { return nil } return router.Register(func(reg grpc.ServiceRegistrar) { - orchestratorv1.RegisterPaymentQuotationServer(reg, s.quote) + quotationv1.RegisterQuotationServiceServer(reg, s.quote) }) } diff --git a/api/payments/quotation/internal/service/orchestrator/quotation_service.go b/api/payments/quotation/internal/service/quotation/quotation_service.go similarity index 53% rename from api/payments/quotation/internal/service/orchestrator/quotation_service.go rename to api/payments/quotation/internal/service/quotation/quotation_service.go index bab834c2..95f9b55f 100644 --- a/api/payments/quotation/internal/service/orchestrator/quotation_service.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service.go @@ -1,24 +1,24 @@ -package orchestrator +package quotation import ( "context" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" ) type quotationService struct { svc *Service - orchestratorv1.UnimplementedPaymentQuotationServer + quotationv1.UnimplementedQuotationServiceServer } func newQuotationService(svc *Service) *quotationService { return "ationService{svc: svc} } -func (s *quotationService) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { +func (s *quotationService) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) { return s.svc.QuotePayment(ctx, req) } -func (s *quotationService) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) { +func (s *quotationService) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) { return s.svc.QuotePayments(ctx, req) } diff --git a/api/payments/quotation/internal/service/orchestrator/quote_batch.go b/api/payments/quotation/internal/service/quotation/quote_batch.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/quote_batch.go rename to api/payments/quotation/internal/service/quotation/quote_batch.go index 33b265e8..8eb982ad 100644 --- a/api/payments/quotation/internal/service/orchestrator/quote_batch.go +++ b/api/payments/quotation/internal/service/quotation/quote_batch.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "fmt" diff --git a/api/payments/quotation/internal/service/orchestrator/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go similarity index 99% rename from api/payments/quotation/internal/service/orchestrator/quote_engine.go rename to api/payments/quotation/internal/service/quotation/quote_engine.go index e8d0cca2..2a140361 100644 --- a/api/payments/quotation/internal/service/orchestrator/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" diff --git a/api/payments/quotation/internal/service/quotation/service.go b/api/payments/quotation/internal/service/quotation/service.go new file mode 100644 index 00000000..18c9233c --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/service.go @@ -0,0 +1,114 @@ +package quotation + +import ( + "context" + + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/pkg/api/routers" + clockpkg "github.com/tech/sendico/pkg/clock" + msg "github.com/tech/sendico/pkg/messaging" + mb "github.com/tech/sendico/pkg/messaging/broker" + "github.com/tech/sendico/pkg/mlogger" + orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "google.golang.org/grpc" +) + +type serviceError string + +func (e serviceError) Error() string { + return string(e) +} + +const ( + defaultFeeQuoteTTLMillis int64 = 120000 + defaultOracleTTLMillis int64 = 60000 +) + +var ( + errStorageUnavailable = serviceError("payments.quotation: storage not initialised") +) + +// Service handles payment quotation and read models. +type Service struct { + logger mlogger.Logger + storage storage.Repository + clock clockpkg.Clock + + deps serviceDependencies + h handlerSet + + gatewayBroker mb.Broker + gatewayConsumers []msg.Consumer + + orchestrationv1.UnimplementedPaymentExecutionServiceServer +} + +type serviceDependencies struct { + fees feesDependency + ledger ledgerDependency + gateway gatewayDependency + railGateways railGatewayDependency + providerGateway providerGatewayDependency + oracle oracleDependency + gatewayRegistry GatewayRegistry + gatewayInvokeResolver GatewayInvokeResolver + cardRoutes map[string]CardGatewayRoute + feeLedgerAccounts map[string]string + planBuilder PlanBuilder +} + +type handlerSet struct { + commands *paymentCommandFactory +} + +// NewService constructs the quotation service core. +func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { + svc := &Service{ + logger: logger.Named("payments.quotation"), + storage: repo, + clock: clockpkg.NewSystem(), + } + + initMetrics() + + for _, opt := range opts { + if opt != nil { + opt(svc) + } + } + + if svc.clock == nil { + svc.clock = clockpkg.NewSystem() + } + + engine := defaultPaymentEngine{svc: svc} + svc.h.commands = newPaymentCommandFactory(engine, svc.logger) + + return svc +} + +func (s *Service) ensureHandlers() { + if s.h.commands == nil { + s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger) + } +} + +// Register attaches the service to the supplied gRPC router. +func (s *Service) Register(router routers.GRPC) error { + return router.Register(func(reg grpc.ServiceRegistrar) { + orchestrationv1.RegisterPaymentExecutionServiceServer(reg, s) + }) +} + +// QuotePayment aggregates downstream quotes. +func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { + s.ensureHandlers() + return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req) +} + +// QuotePayments aggregates downstream quotes for multiple intents. +func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) { + s.ensureHandlers() + return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req) +} diff --git a/api/payments/quotation/internal/service/orchestrator/service_helpers.go b/api/payments/quotation/internal/service/quotation/service_helpers.go similarity index 70% rename from api/payments/quotation/internal/service/orchestrator/service_helpers.go rename to api/payments/quotation/internal/service/quotation/service_helpers.go index 6272594e..e7b6c5bc 100644 --- a/api/payments/quotation/internal/service/orchestrator/service_helpers.go +++ b/api/payments/quotation/internal/service/quotation/service_helpers.go @@ -1,4 +1,4 @@ -package orchestrator +package quotation import ( "context" @@ -7,10 +7,7 @@ import ( "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "go.mongodb.org/mongo-driver/v2/bson" "google.golang.org/protobuf/proto" @@ -31,22 +28,6 @@ func validateMetaAndOrgRef(meta *orchestratorv1.RequestMeta) (string, bson.Objec return orgRef, orgID, nil } -func requireIdempotencyKey(k string) (string, error) { - key := strings.TrimSpace(k) - if key == "" { - return "", merrors.InvalidArgument("idempotency_key is required") - } - return key, nil -} - -func requirePaymentRef(ref string) (string, error) { - val := strings.TrimSpace(ref) - if val == "" { - return "", merrors.InvalidArgument("payment_ref is required") - } - return val, nil -} - func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error { if intent == nil { return merrors.InvalidArgument("intent is required") @@ -60,17 +41,6 @@ func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error { return nil } -func ensurePaymentsStore(repo storage.Repository) (storage.PaymentsStore, error) { - if repo == nil { - return nil, errStorageUnavailable - } - store := repo.Payments() - if store == nil { - return nil, errStorageUnavailable - } - return store, nil -} - func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) { if repo == nil { return nil, errStorageUnavailable @@ -82,14 +52,6 @@ func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) { return store, nil } -func getPaymentByIdempotencyKey(ctx context.Context, store storage.PaymentsStore, orgID bson.ObjectID, key string) (*model.Payment, error) { - payment, err := store.GetByIdempotencyKey(ctx, orgID, key) - if err != nil { - return nil, err - } - return payment, nil -} - type quoteResolutionInput struct { OrgRef string OrgID bson.ObjectID @@ -106,39 +68,43 @@ type quoteResolutionError struct { func (e quoteResolutionError) Error() string { return e.err.Error() } -func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) { +func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) { if ref := strings.TrimSpace(in.QuoteRef); ref != "" { quotesStore, err := ensureQuotesStore(s.storage) if err != nil { - return nil, nil, err + return nil, nil, nil, err } record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) if err != nil { if errors.Is(err, storage.ErrQuoteNotFound) { - return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} + return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} } - return nil, nil, err + return nil, nil, nil, err } if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { - return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} + return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} } intent, err := recordIntentFromQuote(record) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if in.Intent != nil && !proto.Equal(intent, in.Intent) { - return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} + return nil, nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} } quote, err := recordQuoteFromQuote(record) if err != nil { - return nil, nil, err + return nil, nil, nil, err } quote.QuoteRef = ref - return quote, intent, nil + plan, err := recordPlanFromQuote(record) + if err != nil { + return nil, nil, nil, err + } + return quote, intent, plan, nil } if in.Intent == nil { - return nil, nil, merrors.InvalidArgument("intent is required") + return nil, nil, nil, merrors.InvalidArgument("intent is required") } req := &orchestratorv1.QuotePaymentRequest{ Meta: in.Meta, @@ -148,9 +114,13 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp } quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req) if err != nil { - return nil, nil, err + return nil, nil, nil, err } - return quote, in.Intent, nil + plan, err := s.buildPaymentPlan(ctx, in.OrgID, in.Intent, in.IdempotencyKey, quote) + if err != nil { + return nil, nil, nil, err + } + return quote, in.Intent, plan, nil } func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) { @@ -185,6 +155,22 @@ func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.Pay return nil, merrors.InvalidArgument("stored quote is empty") } +func recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) { + if record == nil { + return nil, merrors.InvalidArgument("stored quote payload is incomplete") + } + if len(record.Plans) > 0 { + if len(record.Plans) != 1 { + return nil, merrors.InvalidArgument("stored quote payload is incomplete") + } + return cloneStoredPaymentPlan(record.Plans[0]), nil + } + if record.Plan != nil { + return cloneStoredPaymentPlan(record.Plan), nil + } + return nil, nil +} + func newPayment(orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment { entity := &model.Payment{} entity.SetID(bson.NewObjectID()) @@ -198,10 +184,3 @@ func newPayment(orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idemp entity.Normalize() return entity } - -func paymentNotFoundResponder[T any](svc mservice.Type, logger mlogger.Logger, err error) gsresponse.Responder[T] { - if errors.Is(err, storage.ErrPaymentNotFound) { - return gsresponse.NotFound[T](logger, svc, err) - } - return gsresponse.Auto[T](logger, svc, err) -} diff --git a/api/payments/quotation/internal/service/shared/account.go b/api/payments/quotation/internal/service/shared/account.go new file mode 100644 index 00000000..e37fc57a --- /dev/null +++ b/api/payments/quotation/internal/service/shared/account.go @@ -0,0 +1,11 @@ +package shared + +import "github.com/tech/sendico/pkg/model/account_role" + +func CloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole { + if role == nil { + return nil + } + cloned := *role + return &cloned +} diff --git a/api/payments/storage/model/quote.go b/api/payments/storage/model/quote.go index bce8de66..73f19e1f 100644 --- a/api/payments/storage/model/quote.go +++ b/api/payments/storage/model/quote.go @@ -18,6 +18,8 @@ type PaymentQuoteRecord struct { Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"` Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"` Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"` + Plan *PaymentPlan `bson:"plan,omitempty" json:"plan,omitempty"` + Plans []*PaymentPlan `bson:"plans,omitempty" json:"plans,omitempty"` ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` PurgeAt time.Time `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"` Hash string `bson:"hash" json:"hash"` diff --git a/api/proto/payments/orchestration/v1/orchestration.proto b/api/proto/payments/orchestration/v1/orchestration.proto new file mode 100644 index 00000000..3aba9ceb --- /dev/null +++ b/api/proto/payments/orchestration/v1/orchestration.proto @@ -0,0 +1,120 @@ +syntax = "proto3"; + +package payments.orchestration.v1; + +option go_package = "github.com/tech/sendico/pkg/proto/payments/orchestration/v1;orchestrationv1"; + +import "common/pagination/v1/cursor.proto"; +import "billing/fees/v1/fees.proto"; +import "gateway/chain/v1/chain.proto"; +import "gateway/mntx/v1/mntx.proto"; +import "payments/shared/v1/shared.proto"; + +message InitiatePaymentsRequest { + payments.shared.v1.RequestMeta meta = 1; + string idempotency_key = 2; + string quote_ref = 3; + map metadata = 4; +} + +message InitiatePaymentsResponse { + repeated payments.shared.v1.Payment payments = 1; +} + +message InitiatePaymentRequest { + payments.shared.v1.RequestMeta meta = 1; + string idempotency_key = 2; + payments.shared.v1.PaymentIntent intent = 3; + map metadata = 4; + string quote_ref = 5; +} + +message InitiatePaymentResponse { + payments.shared.v1.Payment payment = 1; +} + +message GetPaymentRequest { + payments.shared.v1.RequestMeta meta = 1; + string payment_ref = 2; +} + +message GetPaymentResponse { + payments.shared.v1.Payment payment = 1; +} + +message ListPaymentsRequest { + payments.shared.v1.RequestMeta meta = 1; + repeated payments.shared.v1.PaymentState filter_states = 2; + string source_ref = 3; + string destination_ref = 4; + common.pagination.v1.CursorPageRequest page = 5; + string organization_ref = 6; +} + +message ListPaymentsResponse { + repeated payments.shared.v1.Payment payments = 1; + common.pagination.v1.CursorPageResponse page = 2; +} + +message CancelPaymentRequest { + payments.shared.v1.RequestMeta meta = 1; + string payment_ref = 2; + string reason = 3; +} + +message CancelPaymentResponse { + payments.shared.v1.Payment payment = 1; +} + +message ProcessTransferUpdateRequest { + payments.shared.v1.RequestMeta meta = 1; + chain.gateway.v1.TransferStatusChangedEvent event = 2; +} + +message ProcessTransferUpdateResponse { + payments.shared.v1.Payment payment = 1; +} + +message ProcessDepositObservedRequest { + payments.shared.v1.RequestMeta meta = 1; + chain.gateway.v1.WalletDepositObservedEvent event = 2; +} + +message ProcessDepositObservedResponse { + payments.shared.v1.Payment payment = 1; +} + +message ProcessCardPayoutUpdateRequest { + payments.shared.v1.RequestMeta meta = 1; + mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2; +} + +message ProcessCardPayoutUpdateResponse { + payments.shared.v1.Payment payment = 1; +} + +message InitiateConversionRequest { + payments.shared.v1.RequestMeta meta = 1; + string idempotency_key = 2; + payments.shared.v1.PaymentEndpoint source = 3; + payments.shared.v1.PaymentEndpoint destination = 4; + payments.shared.v1.FXIntent fx = 5; + fees.v1.PolicyOverrides fee_policy = 6; + map metadata = 7; +} + +message InitiateConversionResponse { + payments.shared.v1.Payment conversion = 1; +} + +service PaymentExecutionService { + rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse); + rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse); + rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse); + rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse); + rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse); + rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse); + rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse); + rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse); + rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse); +} diff --git a/api/proto/payments/quotation/v1/quotation.proto b/api/proto/payments/quotation/v1/quotation.proto new file mode 100644 index 00000000..b733ca6d --- /dev/null +++ b/api/proto/payments/quotation/v1/quotation.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package payments.quotation.v1; + +option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v1;quotationv1"; + +import "payments/shared/v1/shared.proto"; + +message QuotePaymentRequest { + payments.shared.v1.RequestMeta meta = 1; + string idempotency_key = 2; + payments.shared.v1.PaymentIntent intent = 3; + bool preview_only = 4; +} + +message QuotePaymentResponse { + payments.shared.v1.PaymentQuote quote = 1; + string idempotency_key = 2; +} + +message QuotePaymentsRequest { + payments.shared.v1.RequestMeta meta = 1; + string idempotency_key = 2; + repeated payments.shared.v1.PaymentIntent intents = 3; + bool preview_only = 4; +} + +message QuotePaymentsResponse { + string quote_ref = 1; + payments.shared.v1.PaymentQuoteAggregate aggregate = 2; + repeated payments.shared.v1.PaymentQuote quotes = 3; + string idempotency_key = 4; +} + +service QuotationService { + rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); + rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse); +} diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/shared/v1/shared.proto similarity index 60% rename from api/proto/payments/orchestrator/v1/orchestrator.proto rename to api/proto/payments/shared/v1/shared.proto index a27ace1b..e14d0e4f 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/shared/v1/shared.proto @@ -1,18 +1,16 @@ syntax = "proto3"; -package payments.orchestrator.v1; +package payments.shared.v1; -option go_package = "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1;orchestratorv1"; +option go_package = "github.com/tech/sendico/pkg/proto/payments/shared/v1;sharedv1"; import "google/protobuf/timestamp.proto"; import "common/money/v1/money.proto"; import "common/fx/v1/fx.proto"; import "common/gateway/v1/gateway.proto"; import "common/trace/v1/trace.proto"; -import "common/pagination/v1/cursor.proto"; import "billing/fees/v1/fees.proto"; import "gateway/chain/v1/chain.proto"; -import "gateway/mntx/v1/mntx.proto"; import "oracle/v1/oracle.proto"; enum PaymentKind { @@ -226,143 +224,3 @@ message Payment { ExecutionPlan execution_plan = 13; PaymentPlan payment_plan = 14; } - -message QuotePaymentRequest { - RequestMeta meta = 1; - string idempotency_key = 2; - PaymentIntent intent = 3; - bool preview_only = 4; -} - -message QuotePaymentResponse { - PaymentQuote quote = 1; - string idempotency_key = 2; -} - -message QuotePaymentsRequest { - RequestMeta meta = 1; - string idempotency_key = 2; - repeated PaymentIntent intents = 3; - bool preview_only = 4; -} - -message QuotePaymentsResponse { - string quote_ref = 1; - PaymentQuoteAggregate aggregate = 2; - repeated PaymentQuote quotes = 3; - string idempotency_key = 4; -} - -message InitiatePaymentsRequest { - RequestMeta meta = 1; - string idempotency_key = 2; - string quote_ref = 3; - map metadata = 4; -} - -message InitiatePaymentsResponse { - repeated Payment payments = 1; -} - -message InitiatePaymentRequest { - RequestMeta meta = 1; - string idempotency_key = 2; - PaymentIntent intent = 3; - map metadata = 4; - string quote_ref = 5; -} - -message InitiatePaymentResponse { - Payment payment = 1; -} - -message GetPaymentRequest { - RequestMeta meta = 1; - string payment_ref = 2; -} - -message GetPaymentResponse { - Payment payment = 1; -} - -message ListPaymentsRequest { - RequestMeta meta = 1; - repeated PaymentState filter_states = 2; - string source_ref = 3; - string destination_ref = 4; - common.pagination.v1.CursorPageRequest page = 5; - string organization_ref = 6; -} - -message ListPaymentsResponse { - repeated Payment payments = 1; - common.pagination.v1.CursorPageResponse page = 2; -} - -message CancelPaymentRequest { - RequestMeta meta = 1; - string payment_ref = 2; - string reason = 3; -} - -message CancelPaymentResponse { - Payment payment = 1; -} - -message ProcessTransferUpdateRequest { - RequestMeta meta = 1; - chain.gateway.v1.TransferStatusChangedEvent event = 2; -} - -message ProcessTransferUpdateResponse { - Payment payment = 1; -} - -message ProcessDepositObservedRequest { - RequestMeta meta = 1; - chain.gateway.v1.WalletDepositObservedEvent event = 2; -} - -message ProcessDepositObservedResponse { - Payment payment = 1; -} - -message ProcessCardPayoutUpdateRequest { - RequestMeta meta = 1; - mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2; -} - -message ProcessCardPayoutUpdateResponse { - Payment payment = 1; -} - -message InitiateConversionRequest { - RequestMeta meta = 1; - string idempotency_key = 2; - PaymentEndpoint source = 3; - PaymentEndpoint destination = 4; - FXIntent fx = 5; - fees.v1.PolicyOverrides fee_policy = 6; - map metadata = 7; -} - -message InitiateConversionResponse { - Payment conversion = 1; -} - -service PaymentOrchestrator { - rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse); - rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse); - rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse); - rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse); - rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse); - rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse); - rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse); - rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse); - rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse); -} - -service PaymentQuotation { - rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); - rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse); -} diff --git a/ci/scripts/proto/generate.sh b/ci/scripts/proto/generate.sh index b855399d..c6ff673b 100755 --- a/ci/scripts/proto/generate.sh +++ b/ci/scripts/proto/generate.sh @@ -122,10 +122,22 @@ if [ -f "${PROTO_DIR}/connector/v1/connector.proto" ]; then generate_go_with_grpc "${PROTO_DIR}/connector/v1/connector.proto" fi -if [ -f "${PROTO_DIR}/payments/orchestrator/v1/orchestrator.proto" ]; then - info "Compiling payments orchestrator protos" - clean_pb_files "./pkg/proto/payments/orchestrator" - generate_go_with_grpc "${PROTO_DIR}/payments/orchestrator/v1/orchestrator.proto" +if [ -f "${PROTO_DIR}/payments/shared/v1/shared.proto" ]; then + info "Compiling payments shared protos" + clean_pb_files "./pkg/proto/payments/shared" + generate_go_with_grpc "${PROTO_DIR}/payments/shared/v1/shared.proto" +fi + +if [ -f "${PROTO_DIR}/payments/orchestration/v1/orchestration.proto" ]; then + info "Compiling payments orchestration protos" + clean_pb_files "./pkg/proto/payments/orchestration" + generate_go_with_grpc "${PROTO_DIR}/payments/orchestration/v1/orchestration.proto" +fi + +if [ -f "${PROTO_DIR}/payments/quotation/v1/quotation.proto" ]; then + info "Compiling payments quotation protos" + clean_pb_files "./pkg/proto/payments/quotation" + generate_go_with_grpc "${PROTO_DIR}/payments/quotation/v1/quotation.proto" fi if [ -f "${PROTO_DIR}/billing/fees/v1/fees.proto" ]; then