From 296cc7b86af89da8bc586ac867c649785e32bc20 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 10 Feb 2026 18:29:47 +0100 Subject: [PATCH] separated quotation and payments --- .woodpecker/payments_quotation.yml | 81 ++ api/payments/orchestrator/client/client.go | 95 +- api/payments/orchestrator/client/config.go | 9 +- api/payments/orchestrator/go.mod | 3 + .../internal/server/internal/config.go | 22 +- .../internal/server/internal/discovery.go | 2 +- .../internal/server/internal/serverimp.go | 4 +- .../internal/server/internal/types.go | 16 +- .../orchestrator/card_payout_funding.go | 2 +- .../orchestrator/card_payout_helpers.go | 2 +- .../orchestrator/card_payout_submit.go | 2 +- .../service/orchestrator/card_payout_test.go | 2 +- .../service/orchestrator/command_factory.go | 4 +- .../composite_gateway_registry.go | 2 +- .../internal/service/orchestrator/convert.go | 2 +- .../service/orchestrator/convert_card_test.go | 2 +- .../discovery_gateway_registry.go | 2 +- .../service/orchestrator/execution_plan.go | 2 +- .../gateway_execution_consumer.go | 2 +- .../gateway_execution_consumer_test.go | 2 +- .../service/orchestrator/gateway_registry.go | 2 +- .../orchestrator/gateway_resolution.go | 2 +- .../service/orchestrator/handlers_commands.go | 4 +- .../service/orchestrator/handlers_events.go | 4 +- .../service/orchestrator/handlers_queries.go | 2 +- .../service/orchestrator/internal_helpers.go | 2 +- .../orchestrator/internal_helpers_test.go | 2 +- .../internal/service/orchestrator/options.go | 2 +- .../service/orchestrator/payment_executor.go | 4 +- .../orchestrator/payment_plan_analyzer.go | 2 +- .../service/orchestrator/payment_plan_card.go | 2 +- .../orchestrator/payment_plan_chain.go | 2 +- .../orchestrator/payment_plan_executor.go | 4 +- .../payment_plan_executor_test.go | 2 +- .../orchestrator/payment_plan_helpers.go | 2 +- .../orchestrator/payment_plan_ledger.go | 2 +- .../orchestrator/payment_plan_ledger_test.go | 2 +- .../orchestrator/payment_plan_order.go | 2 +- .../orchestrator/payment_plan_release.go | 4 +- .../orchestrator/payment_plan_release_test.go | 2 +- .../orchestrator/payment_plan_steps.go | 2 +- .../service/orchestrator/plan_builder.go | 2 +- .../orchestrator/plan_builder_default.go | 2 +- .../orchestrator/plan_builder_default_test.go | 2 +- .../orchestrator/plan_builder_endpoints.go | 2 +- .../orchestrator/plan_builder_gateways.go | 2 +- .../orchestrator/plan_builder_plans.go | 2 +- .../orchestrator/plan_builder_routes.go | 2 +- .../orchestrator/plan_builder_steps.go | 2 +- .../orchestrator/plan_builder_templates.go | 2 +- .../orchestrator/provider_settlement.go | 2 +- .../service/orchestrator/quote_batch.go | 2 +- .../service/orchestrator/quote_engine.go | 2 +- ...te_payment_idempotency_integration_test.go | 4 +- .../internal/service/orchestrator/service.go | 4 +- .../service/orchestrator/service_helpers.go | 4 +- .../orchestrator/service_helpers_test.go | 4 +- .../service/orchestrator/service_test.go | 4 +- api/payments/quotation/.air.toml | 46 + api/payments/quotation/.gitignore | 4 + api/payments/quotation/config.dev.yml | 59 ++ api/payments/quotation/config.yml | 59 ++ api/payments/quotation/env/.gitignore | 1 + api/payments/quotation/go.mod | 67 ++ api/payments/quotation/go.sum | 224 +++++ .../quotation/internal/appversion/version.go | 28 + .../internal/server/internal/config.go | 101 ++ .../internal/server/internal/dependencies.go | 107 ++ .../internal/server/internal/discovery.go | 45 + .../internal/server/internal/lifecycle.go | 18 + .../internal/server/internal/serverimp.go | 70 ++ .../internal/server/internal/types.go | 37 + .../quotation/internal/server/server.go | 12 + .../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/command_factory.go | 97 ++ .../composite_gateway_registry.go | 64 ++ .../internal/service/orchestrator/convert.go | 947 ++++++++++++++++++ .../discovery_gateway_registry.go | 137 +++ .../service/orchestrator/execution_plan.go | 163 +++ .../gateway_execution_consumer.go | 295 ++++++ .../service/orchestrator/gateway_registry.go | 120 +++ .../orchestrator/gateway_resolution.go | 138 +++ .../service/orchestrator/handlers_commands.go | 922 +++++++++++++++++ .../service/orchestrator/handlers_events.go | 318 ++++++ .../service/orchestrator/handlers_queries.go | 80 ++ .../internal/service/orchestrator/helpers.go | 463 +++++++++ .../service/orchestrator/internal_helpers.go | 132 +++ .../internal/service/orchestrator/metrics.go | 65 ++ .../service/orchestrator/model_money.go | 13 + .../internal/service/orchestrator/options.go | 456 +++++++++ .../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_helpers.go | 215 ++++ .../orchestrator/payment_plan_ledger.go | 596 +++++++++++ .../orchestrator/payment_plan_order.go | 226 +++++ .../orchestrator/payment_plan_release.go | 50 + .../orchestrator/payment_plan_steps.go | 446 +++++++++ .../service/orchestrator/plan_builder.go | 28 + .../orchestrator/plan_builder_default.go | 102 ++ .../orchestrator/plan_builder_endpoints.go | 101 ++ .../orchestrator/plan_builder_gateways.go | 269 +++++ .../orchestrator/plan_builder_plans.go | 77 ++ .../orchestrator/plan_builder_routes.go | 121 +++ .../orchestrator/plan_builder_steps.go | 453 +++++++++ .../orchestrator/plan_builder_templates.go | 205 ++++ .../orchestrator/provider_settlement.go | 132 +++ .../provider_settlement_gateway.go | 180 ++++ .../service/orchestrator/quotation_app.go | 41 + .../service/orchestrator/quotation_service.go | 24 + .../service/orchestrator/quote_batch.go | 145 +++ .../service/orchestrator/quote_engine.go | 579 +++++++++++ .../internal/service/orchestrator/service.go | 210 ++++ .../service/orchestrator/service_helpers.go | 207 ++++ api/payments/quotation/main.go | 17 + api/payments/storage/go.mod | 30 + api/payments/storage/go.sum | 171 ++++ .../storage/model/operation.go | 0 .../storage/model/payment.go | 0 .../storage/model/plan_template.go | 0 .../{orchestrator => }/storage/model/quote.go | 0 .../{orchestrator => }/storage/model/route.go | 0 .../storage/mongo/repository.go | 11 +- .../storage/mongo/store/payments.go | 4 +- .../storage/mongo/store/plan_templates.go | 4 +- .../storage/mongo/store/routes.go | 4 +- .../storage/quote/mongo/repository.go | 90 ++ .../quote}/mongo/store/quotes.go | 16 +- api/payments/storage/quote/storage.go | 34 + .../{orchestrator => }/storage/storage.go | 38 +- .../internal/mongo/verificationimp/consume.go | 362 ++++++- .../orchestrator/v1/orchestrator.proto | 7 +- api/server/config.dev.yml | 6 + api/server/config.yml | 6 + api/server/go.mod | 2 + api/server/interface/api/config.go | 1 + api/server/internal/api/api.go | 2 + api/server/internal/api/discovery_resolver.go | 466 +++++++++ .../internal/api/discovery_resolver_test.go | 140 +++ .../internal/server/paymentapiimp/service.go | 24 +- .../server/verificationimp/request.go | 4 +- .../internal/server/verificationimp/store.go | 6 +- .../internal/server/verificationimp/types.go | 4 +- .../internal/server/verificationimp/verify.go | 11 +- ci/dev/bff.dockerfile | 2 + ci/dev/payments-orchestrator.dockerfile | 2 + ci/dev/payments-quotation.dockerfile | 52 + ci/prod/.env.runtime | 9 +- ci/prod/compose/bff.yml | 1 + ci/prod/compose/payments_quotation.dockerfile | 40 + ci/prod/compose/payments_quotation.yml | 47 + ci/prod/scripts/deploy/payments_quotation.sh | 135 +++ ci/scripts/payments_quotation/build-image.sh | 85 ++ ci/scripts/payments_quotation/deploy.sh | 55 + docker-compose.dev.yml | 47 + .../api/responses/verification/response.dart | 4 +- frontend/pshared/lib/provider/account.dart | 2 +- 163 files changed, 13516 insertions(+), 191 deletions(-) create mode 100644 .woodpecker/payments_quotation.yml create mode 100644 api/payments/quotation/.air.toml create mode 100644 api/payments/quotation/.gitignore create mode 100644 api/payments/quotation/config.dev.yml create mode 100644 api/payments/quotation/config.yml create mode 100644 api/payments/quotation/env/.gitignore create mode 100644 api/payments/quotation/go.mod create mode 100644 api/payments/quotation/go.sum create mode 100644 api/payments/quotation/internal/appversion/version.go create mode 100644 api/payments/quotation/internal/server/internal/config.go create mode 100644 api/payments/quotation/internal/server/internal/dependencies.go create mode 100644 api/payments/quotation/internal/server/internal/discovery.go create mode 100644 api/payments/quotation/internal/server/internal/lifecycle.go create mode 100644 api/payments/quotation/internal/server/internal/serverimp.go create mode 100644 api/payments/quotation/internal/server/internal/types.go create mode 100644 api/payments/quotation/internal/server/server.go create mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_constants.go create mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_funding.go create mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_helpers.go create mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_routes.go create mode 100644 api/payments/quotation/internal/service/orchestrator/card_payout_submit.go create mode 100644 api/payments/quotation/internal/service/orchestrator/command_factory.go create mode 100644 api/payments/quotation/internal/service/orchestrator/composite_gateway_registry.go create mode 100644 api/payments/quotation/internal/service/orchestrator/convert.go create mode 100644 api/payments/quotation/internal/service/orchestrator/discovery_gateway_registry.go create mode 100644 api/payments/quotation/internal/service/orchestrator/execution_plan.go create mode 100644 api/payments/quotation/internal/service/orchestrator/gateway_execution_consumer.go create mode 100644 api/payments/quotation/internal/service/orchestrator/gateway_registry.go create mode 100644 api/payments/quotation/internal/service/orchestrator/gateway_resolution.go create mode 100644 api/payments/quotation/internal/service/orchestrator/handlers_commands.go create mode 100644 api/payments/quotation/internal/service/orchestrator/handlers_events.go create mode 100644 api/payments/quotation/internal/service/orchestrator/handlers_queries.go create mode 100644 api/payments/quotation/internal/service/orchestrator/helpers.go create mode 100644 api/payments/quotation/internal/service/orchestrator/internal_helpers.go create mode 100644 api/payments/quotation/internal/service/orchestrator/metrics.go create mode 100644 api/payments/quotation/internal/service/orchestrator/model_money.go create mode 100644 api/payments/quotation/internal/service/orchestrator/options.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_executor.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_analyzer.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_card.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_chain.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_executor.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_helpers.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_ledger.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_order.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_release.go create mode 100644 api/payments/quotation/internal/service/orchestrator/payment_plan_steps.go create mode 100644 api/payments/quotation/internal/service/orchestrator/plan_builder.go create mode 100644 api/payments/quotation/internal/service/orchestrator/plan_builder_default.go create mode 100644 api/payments/quotation/internal/service/orchestrator/plan_builder_endpoints.go create mode 100644 api/payments/quotation/internal/service/orchestrator/plan_builder_gateways.go create mode 100644 api/payments/quotation/internal/service/orchestrator/plan_builder_plans.go create mode 100644 api/payments/quotation/internal/service/orchestrator/plan_builder_routes.go create mode 100644 api/payments/quotation/internal/service/orchestrator/plan_builder_steps.go create mode 100644 api/payments/quotation/internal/service/orchestrator/plan_builder_templates.go create mode 100644 api/payments/quotation/internal/service/orchestrator/provider_settlement.go create mode 100644 api/payments/quotation/internal/service/orchestrator/provider_settlement_gateway.go create mode 100644 api/payments/quotation/internal/service/orchestrator/quotation_app.go create mode 100644 api/payments/quotation/internal/service/orchestrator/quotation_service.go create mode 100644 api/payments/quotation/internal/service/orchestrator/quote_batch.go create mode 100644 api/payments/quotation/internal/service/orchestrator/quote_engine.go create mode 100644 api/payments/quotation/internal/service/orchestrator/service.go create mode 100644 api/payments/quotation/internal/service/orchestrator/service_helpers.go create mode 100644 api/payments/quotation/main.go create mode 100644 api/payments/storage/go.mod create mode 100644 api/payments/storage/go.sum rename api/payments/{orchestrator => }/storage/model/operation.go (100%) rename api/payments/{orchestrator => }/storage/model/payment.go (100%) rename api/payments/{orchestrator => }/storage/model/plan_template.go (100%) rename api/payments/{orchestrator => }/storage/model/quote.go (100%) rename api/payments/{orchestrator => }/storage/model/route.go (100%) rename api/payments/{orchestrator => }/storage/mongo/repository.go (90%) rename api/payments/{orchestrator => }/storage/mongo/store/payments.go (98%) rename api/payments/{orchestrator => }/storage/mongo/store/plan_templates.go (97%) rename api/payments/{orchestrator => }/storage/mongo/store/routes.go (97%) create mode 100644 api/payments/storage/quote/mongo/repository.go rename api/payments/{orchestrator/storage => storage/quote}/mongo/store/quotes.go (94%) create mode 100644 api/payments/storage/quote/storage.go rename api/payments/{orchestrator => }/storage/storage.go (61%) create mode 100644 api/server/internal/api/discovery_resolver.go create mode 100644 api/server/internal/api/discovery_resolver_test.go create mode 100644 ci/dev/payments-quotation.dockerfile create mode 100644 ci/prod/compose/payments_quotation.dockerfile create mode 100644 ci/prod/compose/payments_quotation.yml create mode 100755 ci/prod/scripts/deploy/payments_quotation.sh create mode 100755 ci/scripts/payments_quotation/build-image.sh create mode 100755 ci/scripts/payments_quotation/deploy.sh diff --git a/.woodpecker/payments_quotation.yml b/.woodpecker/payments_quotation.yml new file mode 100644 index 00000000..0b328ee5 --- /dev/null +++ b/.woodpecker/payments_quotation.yml @@ -0,0 +1,81 @@ +matrix: + include: + - PAYMENTS_QUOTATION_IMAGE_PATH: payments/quotation + PAYMENTS_QUOTATION_DOCKERFILE: ci/prod/compose/payments_quotation.dockerfile + PAYMENTS_QUOTATION_MONGO_SECRET_PATH: sendico/db + PAYMENTS_QUOTATION_ENV: prod + +when: + - event: push + branch: main + path: + include: + - api/payments/quotation/** + - api/payments/storage/** + - api/payments/orchestrator/** + - api/proto/** + - api/pkg/** + ignore_message: '[rebuild]' + +steps: + - name: version + image: alpine:latest + commands: + - set -euo pipefail 2>/dev/null || set -eu + - apk add --no-cache git + - GIT_REV="$(git rev-parse --short HEAD)" + - BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + - APP_V="$(cat version)" + - BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + - BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}" + - printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \ + "$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version + + - name: proto + image: golang:alpine + depends_on: [ version ] + commands: + - set -eu + - apk add --no-cache bash git build-base protoc protobuf-dev + - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + - export PATH="$(go env GOPATH)/bin:$PATH" + - bash ci/scripts/proto/generate.sh + + - name: secrets + image: alpine:latest + depends_on: [ version ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash coreutils openssh-keygen curl sed python3 + - mkdir -p secrets + - ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600 + - base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY + - chmod 600 secrets/SSH_KEY + - ssh-keygen -y -f secrets/SSH_KEY >/dev/null + - ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER + - ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD + + - name: build-image + image: gcr.io/kaniko-project/executor:debug + depends_on: [ proto, secrets ] + commands: + - sh ci/scripts/payments_quotation/build-image.sh + + - name: deploy + image: alpine:latest + depends_on: [ secrets, build-image ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash openssh-client rsync coreutils curl sed python3 + - mkdir -p /root/.ssh + - install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa + - sh ci/scripts/payments_quotation/deploy.sh diff --git a/api/payments/orchestrator/client/client.go b/api/payments/orchestrator/client/client.go index 355d3e0b..df1b6193 100644 --- a/api/payments/orchestrator/client/client.go +++ b/api/payments/orchestrator/client/client.go @@ -14,7 +14,7 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -// Client exposes typed helpers around the payment orchestrator gRPC API. +// Client exposes typed helpers around the payment orchestration and quotation gRPC APIs. type Client interface { QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) @@ -30,8 +30,6 @@ type Client interface { } type grpcOrchestratorClient interface { - QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error) - QuotePayments(ctx context.Context, in *orchestratorv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentsResponse, error) InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error) InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error) CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error) @@ -42,10 +40,17 @@ type grpcOrchestratorClient interface { ProcessDepositObserved(ctx context.Context, in *orchestratorv1.ProcessDepositObservedRequest, opts ...grpc.CallOption) (*orchestratorv1.ProcessDepositObservedResponse, error) } +type grpcQuotationClient interface { + QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error) + QuotePayments(ctx context.Context, in *orchestratorv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentsResponse, error) +} + type orchestratorClient struct { - cfg Config - conn *grpc.ClientConn - client grpcOrchestratorClient + cfg Config + conn *grpc.ClientConn + quoteConn *grpc.ClientConn + client grpcOrchestratorClient + quoteClient grpcQuotationClient } // New dials the payment orchestrator endpoint and returns a ready client. @@ -54,10 +59,36 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro if strings.TrimSpace(cfg.Address) == "" { return nil, merrors.InvalidArgument("payment-orchestrator: address is required") } + if strings.TrimSpace(cfg.QuoteAddress) == "" { + cfg.QuoteAddress = cfg.Address + } + conn, err := dial(ctx, cfg, cfg.Address, opts...) + if err != nil { + return nil, err + } + + quoteConn := conn + if cfg.QuoteAddress != cfg.Address { + quoteConn, err = dial(ctx, cfg, cfg.QuoteAddress, opts...) + if err != nil { + _ = conn.Close() + return nil, err + } + } + + return &orchestratorClient{ + cfg: cfg, + conn: conn, + quoteConn: quoteConn, + client: orchestratorv1.NewPaymentOrchestratorClient(conn), + quoteClient: orchestratorv1.NewPaymentQuotationClient(quoteConn), + }, nil +} + +func dial(ctx context.Context, cfg Config, address string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout) defer cancel() - dialOpts := make([]grpc.DialOption, 0, len(opts)+1) dialOpts = append(dialOpts, opts...) @@ -67,44 +98,64 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) } - conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...) + conn, err := grpc.DialContext(dialCtx, address, dialOpts...) if err != nil { - return nil, merrors.InternalWrap(err, fmt.Sprintf("payment-orchestrator: dial %s", cfg.Address)) + return nil, merrors.InternalWrap(err, fmt.Sprintf("payment-orchestrator: dial %s", address)) } - - return &orchestratorClient{ - cfg: cfg, - conn: conn, - client: orchestratorv1.NewPaymentOrchestratorClient(conn), - }, nil + return conn, nil } // NewWithClient injects a pre-built orchestrator client (useful for tests). func NewWithClient(cfg Config, oc grpcOrchestratorClient) Client { + return NewWithClients(cfg, oc, nil) +} + +// NewWithClients injects pre-built orchestrator and quotation clients (useful for tests). +func NewWithClients(cfg Config, oc grpcOrchestratorClient, qc grpcQuotationClient) Client { cfg.setDefaults() + if qc == nil { + if q, ok := any(oc).(grpcQuotationClient); ok { + qc = q + } + } return &orchestratorClient{ - cfg: cfg, - client: oc, + cfg: cfg, + client: oc, + quoteClient: qc, } } func (c *orchestratorClient) Close() error { - if c.conn != nil { - return c.conn.Close() + var firstErr error + if c.quoteConn != nil && c.quoteConn != c.conn { + if err := c.quoteConn.Close(); err != nil && firstErr == nil { + firstErr = err + } } - return nil + if c.conn != nil { + if err := c.conn.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr } func (c *orchestratorClient) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { + if c.quoteClient == nil { + return nil, merrors.InvalidArgument("payment-orchestrator: quotation client is not configured") + } ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.QuotePayment(ctx, req) + return c.quoteClient.QuotePayment(ctx, req) } func (c *orchestratorClient) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) { + if c.quoteClient == nil { + return nil, merrors.InvalidArgument("payment-orchestrator: quotation client is not configured") + } ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.QuotePayments(ctx, req) + return c.quoteClient.QuotePayments(ctx, req) } func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { diff --git a/api/payments/orchestrator/client/config.go b/api/payments/orchestrator/client/config.go index 9255d80f..d818ccdf 100644 --- a/api/payments/orchestrator/client/config.go +++ b/api/payments/orchestrator/client/config.go @@ -4,10 +4,11 @@ import "time" // Config captures connection settings for the payment orchestrator gRPC service. type Config struct { - Address string - DialTimeout time.Duration - CallTimeout time.Duration - Insecure bool + Address string + QuoteAddress string + DialTimeout time.Duration + CallTimeout time.Duration + Insecure bool } func (c *Config) setDefaults() { diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index 9ea1ab9f..2662e1cc 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -14,6 +14,8 @@ replace github.com/tech/sendico/fx/oracle => ../../fx/oracle 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 @@ -22,6 +24,7 @@ require ( 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 github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 diff --git a/api/payments/orchestrator/internal/server/internal/config.go b/api/payments/orchestrator/internal/server/internal/config.go index 47007ad9..267e95ec 100644 --- a/api/payments/orchestrator/internal/server/internal/config.go +++ b/api/payments/orchestrator/internal/server/internal/config.go @@ -12,17 +12,17 @@ import ( ) type config struct { - *grpcapp.Config `yaml:",inline"` - Fees clientConfig `yaml:"fees"` - Ledger clientConfig `yaml:"ledger"` - Gateway clientConfig `yaml:"gateway"` - PaymentGateway clientConfig `yaml:"payment_gateway"` - Mntx clientConfig `yaml:"mntx"` - Oracle clientConfig `yaml:"oracle"` - CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"` - FeeAccounts map[string]string `yaml:"fee_ledger_accounts"` - GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"` - QuoteRetentionHrs int `yaml:"quote_retention_hours"` + *grpcapp.Config `yaml:",inline"` + Fees clientConfig `yaml:"fees"` + Ledger clientConfig `yaml:"ledger"` + Gateway clientConfig `yaml:"gateway"` + PaymentGateway clientConfig `yaml:"payment_gateway"` + Mntx clientConfig `yaml:"mntx"` + Oracle clientConfig `yaml:"oracle"` + CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"` + FeeAccounts map[string]string `yaml:"fee_ledger_accounts"` + GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"` + QuoteRetentionHrs int `yaml:"quote_retention_hours"` } type clientConfig struct { diff --git a/api/payments/orchestrator/internal/server/internal/discovery.go b/api/payments/orchestrator/internal/server/internal/discovery.go index 8184c3b5..cf1ddec7 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery.go +++ b/api/payments/orchestrator/internal/server/internal/discovery.go @@ -33,7 +33,7 @@ func (i *Imp) initDiscovery(cfg *config) { } announce := discovery.Announcement{ Service: "PAYMENTS_ORCHESTRATOR", - Operations: []string{"payment.quote", "payment.initiate"}, + Operations: []string{"payment.initiate"}, InvokeURI: cfg.GRPC.DiscoveryInvokeURI(), Version: appversion.Create().Short(), } diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 8f0954ff..02916535 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -2,8 +2,8 @@ package serverimp import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" - "github.com/tech/sendico/payments/orchestrator/storage" - mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo" + "github.com/tech/sendico/payments/storage" + mongostorage "github.com/tech/sendico/payments/storage/mongo" "github.com/tech/sendico/pkg/db" msg "github.com/tech/sendico/pkg/messaging" mb "github.com/tech/sendico/pkg/messaging/broker" diff --git a/api/payments/orchestrator/internal/server/internal/types.go b/api/payments/orchestrator/internal/server/internal/types.go index de5a155c..aa7de665 100644 --- a/api/payments/orchestrator/internal/server/internal/types.go +++ b/api/payments/orchestrator/internal/server/internal/types.go @@ -2,7 +2,7 @@ package serverimp import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" - "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/server/grpcapp" @@ -13,11 +13,11 @@ type Imp struct { file string debug bool - config *config - app *grpcapp.App[storage.Repository] - discoveryWatcher *discovery.RegistryWatcher - discoveryReg *discovery.Registry - discoveryAnnouncer *discovery.Announcer - discoveryClients *discoveryClientResolver - service *orchestrator.Service + config *config + app *grpcapp.App[storage.Repository] + discoveryWatcher *discovery.RegistryWatcher + discoveryReg *discovery.Registry + discoveryAnnouncer *discovery.Announcer + discoveryClients *discoveryClientResolver + service *orchestrator.Service } diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go index 6135b892..e558d08e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go @@ -6,7 +6,7 @@ import ( "github.com/shopspring/decimal" chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go index 0ddcbdaa..86554e87 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go index eacac6c9..4f5d8b9b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go @@ -6,7 +6,7 @@ import ( "github.com/google/uuid" "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go index 2e2b8ad3..d438b670 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go @@ -7,7 +7,7 @@ import ( chainclient "github.com/tech/sendico/gateway/chain/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" mo "github.com/tech/sendico/pkg/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" diff --git a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go index bd7f2335..ed7b6c09 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go +++ b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go @@ -4,8 +4,8 @@ import ( "context" "time" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go index 319a0947..d2a266a5 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go @@ -4,7 +4,7 @@ import ( "context" "sort" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index 706fed45..b01fa470 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -4,7 +4,7 @@ import ( "strings" "time" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" chainasset "github.com/tech/sendico/pkg/chain" paymenttypes "github.com/tech/sendico/pkg/payments/types" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go index 5a08cc09..928b585b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go @@ -3,7 +3,7 @@ package orchestrator import ( "testing" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go index 1fe95a2e..e5afe437 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/mlogger" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go b/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go index 24426f66..fb093504 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go +++ b/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go @@ -3,7 +3,7 @@ package orchestrator import ( "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go index 76bb34fb..a6065af9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - paymodel "github.com/tech/sendico/payments/orchestrator/storage/model" + 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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go index 78b0ca79..79c429f6 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - paymodel "github.com/tech/sendico/payments/orchestrator/storage/model" + paymodel "github.com/tech/sendico/payments/storage/model" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/payments/rail" diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go index ac0e65f8..9abf3673 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go index cbeabc16..68229a10 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go @@ -7,7 +7,7 @@ import ( "github.com/shopspring/decimal" chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.uber.org/zap" diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go index 68b84a7d..57e9cb40 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -10,8 +10,8 @@ import ( "time" "github.com/google/uuid" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go index 121111a6..c4d75792 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go @@ -4,8 +4,8 @@ import ( "context" "strings" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go index c41e8a42..9ae60074 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go @@ -3,7 +3,7 @@ package orchestrator import ( "context" - "github.com/tech/sendico/payments/orchestrator/storage" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go index 91b1b90d..94d5b22d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go index 558b8c74..bee5b7d0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go @@ -3,7 +3,7 @@ package orchestrator import ( "testing" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index 13d2483d..ccc2833f 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -11,7 +11,7 @@ import ( chainclient "github.com/tech/sendico/gateway/chain/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" "github.com/tech/sendico/pkg/merrors" mb "github.com/tech/sendico/pkg/messaging/broker" diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go index d80666e1..c6e09490 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go @@ -4,8 +4,8 @@ import ( "context" "strings" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_analyzer.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_analyzer.go index dcc111f2..0e19e24b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_analyzer.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_analyzer.go @@ -3,7 +3,7 @@ package orchestrator import ( "errors" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go index 2a0eb08a..2a1fb32a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go index 619df9cc..b495a7c9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go @@ -3,7 +3,7 @@ package orchestrator import ( "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" 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 daa41d3a..f90780ad 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go @@ -3,8 +3,8 @@ package orchestrator import ( "context" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go index eb8a5739..ca243ccd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go @@ -7,7 +7,7 @@ import ( mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" mo "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go index c384fc65..1e590775 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/google/uuid" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go index f85dec67..594cafa5 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go index 3016fd02..804275da 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go @@ -5,7 +5,7 @@ import ( "testing" ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/model/account_role" paymenttypes "github.com/tech/sendico/pkg/payments/types" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go index 59f10af0..064f7971 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go @@ -3,7 +3,7 @@ package orchestrator import ( "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go index 436b5e29..d0362fb8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go @@ -3,8 +3,8 @@ package orchestrator import ( "context" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" "go.uber.org/zap" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go index a40ba343..be8ed450 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go @@ -6,7 +6,7 @@ import ( "testing" ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" mo "github.com/tech/sendico/pkg/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" 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 4eaa2510..37272a3e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go @@ -6,7 +6,7 @@ import ( "math/big" "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go index e5a1ade6..40b8414b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go @@ -3,7 +3,7 @@ package orchestrator import ( "context" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go index 6c358426..ba322676 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go @@ -3,7 +3,7 @@ package orchestrator import ( "context" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mutil/mzap" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go index 2c80e41e..8f93cebd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" "github.com/tech/sendico/pkg/model/account_role" paymenttypes "github.com/tech/sendico/pkg/payments/types" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go index 324e9038..d825937d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go @@ -3,7 +3,7 @@ package orchestrator import ( "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go index 97d9683b..8d3f573d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" paymenttypes "github.com/tech/sendico/pkg/payments/types" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go index 0fc746b4..b6e3b073 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go @@ -3,7 +3,7 @@ package orchestrator import ( "time" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go index 51abc301..de979ece 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go index 37814aa5..c9460f41 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go index 92cb1a9d..bbaed284 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mutil/mzap" diff --git a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go index 3ef2ec92..96ba0f55 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go +++ b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go @@ -3,7 +3,7 @@ package orchestrator import ( "strings" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_batch.go b/api/payments/orchestrator/internal/service/orchestrator/quote_batch.go index fda645b9..33b265e8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_batch.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_batch.go @@ -7,7 +7,7 @@ import ( "time" "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go index 4aa1f576..e8d0cca2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go @@ -8,7 +8,7 @@ import ( "time" oracleclient "github.com/tech/sendico/fx/oracle/client" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" 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 index ec1ba5f6..534e4bfc 100644 --- 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 @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/tech/sendico/payments/orchestrator/storage/model" - storagemongo "github.com/tech/sendico/payments/orchestrator/storage/mongo" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 0b23d204..69e6c11f 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -3,8 +3,8 @@ package orchestrator import ( "context" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go index 7808aa38..6272594e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go @@ -5,8 +5,8 @@ import ( "errors" "strings" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" 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 5b00ad43..fc0847e0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -7,8 +7,8 @@ import ( oracleclient "github.com/tech/sendico/fx/oracle/client" ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index 9903a93c..cfcc61d1 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -8,8 +8,8 @@ import ( "time" ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "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" mo "github.com/tech/sendico/pkg/model" diff --git a/api/payments/quotation/.air.toml b/api/payments/quotation/.air.toml new file mode 100644 index 00000000..16f8c34b --- /dev/null +++ b/api/payments/quotation/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + entrypoint = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go", "_templ.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/api/payments/quotation/.gitignore b/api/payments/quotation/.gitignore new file mode 100644 index 00000000..436d3e5e --- /dev/null +++ b/api/payments/quotation/.gitignore @@ -0,0 +1,4 @@ +internal/generated +.gocache +app +tmp diff --git a/api/payments/quotation/config.dev.yml b/api/payments/quotation/config.dev.yml new file mode 100644 index 00000000..964ab789 --- /dev/null +++ b/api/payments/quotation/config.dev.yml @@ -0,0 +1,59 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50064" + advertise_host: "dev-payments-quotation" + enable_reflection: true + enable_health: true + +metrics: + address: ":9414" + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Payments Quotation Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +database: + driver: mongodb + settings: + host_env: PAYMENTS_MONGO_HOST + port_env: PAYMENTS_MONGO_PORT + database_env: PAYMENTS_MONGO_DATABASE + user_env: PAYMENTS_MONGO_USER + password_env: PAYMENTS_MONGO_PASSWORD + auth_source_env: PAYMENTS_MONGO_AUTH_SOURCE + replica_set_env: PAYMENTS_MONGO_REPLICA_SET + +fees: + address: dev-billing-fees:50060 + address_env: FEES_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + +oracle: + address: dev-fx-oracle:50051 + address_env: ORACLE_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + +gateway: + address: dev-chain-gateway:50053 + address_env: CHAIN_GATEWAY_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + +quote_retention_hours: 72 diff --git a/api/payments/quotation/config.yml b/api/payments/quotation/config.yml new file mode 100644 index 00000000..b52ebf45 --- /dev/null +++ b/api/payments/quotation/config.yml @@ -0,0 +1,59 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50064" + advertise_host: "sendico_payments_quotation" + enable_reflection: true + enable_health: true + +metrics: + address: ":9414" + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Payments Quotation Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +database: + driver: mongodb + settings: + host_env: PAYMENTS_MONGO_HOST + port_env: PAYMENTS_MONGO_PORT + database_env: PAYMENTS_MONGO_DATABASE + user_env: PAYMENTS_MONGO_USER + password_env: PAYMENTS_MONGO_PASSWORD + auth_source_env: PAYMENTS_MONGO_AUTH_SOURCE + replica_set_env: PAYMENTS_MONGO_REPLICA_SET + +fees: + address: sendico_billing_fees:50060 + address_env: FEES_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + +oracle: + address: sendico_fx_oracle:50051 + address_env: ORACLE_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + +gateway: + address: sendico_chain_gateway:50053 + address_env: CHAIN_GATEWAY_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true + +quote_retention_hours: 72 diff --git a/api/payments/quotation/env/.gitignore b/api/payments/quotation/env/.gitignore new file mode 100644 index 00000000..f2a8cbe3 --- /dev/null +++ b/api/payments/quotation/env/.gitignore @@ -0,0 +1 @@ +.env.api diff --git a/api/payments/quotation/go.mod b/api/payments/quotation/go.mod new file mode 100644 index 00000000..e3ea6409 --- /dev/null +++ b/api/payments/quotation/go.mod @@ -0,0 +1,67 @@ +module github.com/tech/sendico/payments/quotation + +go 1.25.7 + +replace github.com/tech/sendico/pkg => ../../pkg + +replace github.com/tech/sendico/fx/oracle => ../../fx/oracle + +replace github.com/tech/sendico/gateway/chain => ../../gateway/chain + +replace github.com/tech/sendico/billing/fees => ../../billing/fees + +replace github.com/tech/sendico/gateway/mntx => ../../gateway/mntx + +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 + go.mongodb.org/mongo-driver/v2 v2.5.0 + go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + 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/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // 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 + github.com/mitchellh/mapstructure v1.5.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/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/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 + 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 + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect +) diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum new file mode 100644 index 00000000..b5ad332c --- /dev/null +++ b/api/payments/quotation/go.sum @@ -0,0 +1,224 @@ +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/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= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ= +github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +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/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= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +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.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.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= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +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/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/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= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +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/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= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +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/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.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.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= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +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.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/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= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +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= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +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-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.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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +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-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-20201119102817-f84b799fce68/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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/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/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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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/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= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/payments/quotation/internal/appversion/version.go b/api/payments/quotation/internal/appversion/version.go new file mode 100644 index 00000000..6a5bcd30 --- /dev/null +++ b/api/payments/quotation/internal/appversion/version.go @@ -0,0 +1,28 @@ +package appversion + +import ( + "github.com/tech/sendico/pkg/version" + vf "github.com/tech/sendico/pkg/version/factory" +) + +// Build information populated via ldflags. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +// Create returns a printer configured for the payment quotation service. +func Create() version.Printer { + vi := version.Info{ + Program: "Sendico Payment Quotation Service", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + return vf.Create(&vi) +} diff --git a/api/payments/quotation/internal/server/internal/config.go b/api/payments/quotation/internal/server/internal/config.go new file mode 100644 index 00000000..5483bb55 --- /dev/null +++ b/api/payments/quotation/internal/server/internal/config.go @@ -0,0 +1,101 @@ +package serverimp + +import ( + "os" + "strings" + "time" + + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type config struct { + *grpcapp.Config `yaml:",inline"` + Fees clientConfig `yaml:"fees"` + Oracle clientConfig `yaml:"oracle"` + Gateway clientConfig `yaml:"gateway"` + QuoteRetentionHrs int `yaml:"quote_retention_hours"` +} + +type clientConfig struct { + Address string `yaml:"address"` + AddressEnv string `yaml:"address_env"` + DialTimeoutSecs int `yaml:"dial_timeout_seconds"` + CallTimeoutSecs int `yaml:"call_timeout_seconds"` + InsecureTransport bool `yaml:"insecure"` +} + +func (c clientConfig) resolveAddress() string { + if address := strings.TrimSpace(c.Address); address != "" { + return address + } + if env := strings.TrimSpace(c.AddressEnv); env != "" { + return strings.TrimSpace(os.Getenv(env)) + } + return "" +} + +func (c clientConfig) dialTimeout() time.Duration { + if c.DialTimeoutSecs <= 0 { + return 5 * time.Second + } + return time.Duration(c.DialTimeoutSecs) * time.Second +} + +func (c clientConfig) callTimeout() time.Duration { + if c.CallTimeoutSecs <= 0 { + return 3 * time.Second + } + return time.Duration(c.CallTimeoutSecs) * time.Second +} + +func (c *config) quoteRetention() time.Duration { + if c == nil || c.QuoteRetentionHrs <= 0 { + return 72 * time.Hour + } + return time.Duration(c.QuoteRetentionHrs) * time.Hour +} + +func (i *Imp) loadConfig() (*config, error) { + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err + } + + cfg := &config{Config: &grpcapp.Config{}} + if err := yaml.Unmarshal(data, cfg); err != nil { + i.logger.Error("Failed to parse configuration", zap.Error(err)) + return nil, err + } + + if cfg.Runtime == nil { + cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} + } + + if cfg.GRPC == nil { + cfg.GRPC = &routers.GRPCConfig{ + Network: "tcp", + Address: ":50064", + EnableReflection: true, + EnableHealth: true, + } + } else { + if strings.TrimSpace(cfg.GRPC.Address) == "" { + cfg.GRPC.Address = ":50064" + } + if strings.TrimSpace(cfg.GRPC.Network) == "" { + cfg.GRPC.Network = "tcp" + } + } + + if cfg.Metrics == nil { + cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9414"} + } else if strings.TrimSpace(cfg.Metrics.Address) == "" { + cfg.Metrics.Address = ":9414" + } + + return cfg, nil +} diff --git a/api/payments/quotation/internal/server/internal/dependencies.go b/api/payments/quotation/internal/server/internal/dependencies.go new file mode 100644 index 00000000..675e6b1c --- /dev/null +++ b/api/payments/quotation/internal/server/internal/dependencies.go @@ -0,0 +1,107 @@ +package serverimp + +import ( + "context" + "crypto/tls" + "strings" + "time" + + oracleclient "github.com/tech/sendico/fx/oracle/client" + chainclient "github.com/tech/sendico/gateway/chain/client" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +func (i *Imp) initDependencies(cfg *config) *clientDependencies { + deps := &clientDependencies{} + if cfg == nil { + return deps + } + + if feesAddress := cfg.Fees.resolveAddress(); feesAddress != "" { + dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Fees.dialTimeout()) + conn, err := dialGRPC(dialCtx, cfg.Fees, feesAddress) + cancel() + if err != nil { + i.logger.Warn("Failed to dial fee engine", zap.Error(err), zap.String("address", feesAddress)) + } else { + deps.feesConn = conn + deps.feesClient = feesv1.NewFeeEngineClient(conn) + } + } + + if oracleAddress := cfg.Oracle.resolveAddress(); oracleAddress != "" { + client, err := oracleclient.New(context.Background(), oracleclient.Config{ + Address: oracleAddress, + DialTimeout: cfg.Oracle.dialTimeout(), + CallTimeout: cfg.Oracle.callTimeout(), + Insecure: cfg.Oracle.InsecureTransport, + }) + if err != nil { + i.logger.Warn("Failed to initialise oracle client", zap.Error(err), zap.String("address", oracleAddress)) + } else { + deps.oracleClient = client + } + } + + if gatewayAddress := cfg.Gateway.resolveAddress(); gatewayAddress != "" { + client, err := chainclient.New(context.Background(), chainclient.Config{ + Address: gatewayAddress, + DialTimeout: cfg.Gateway.dialTimeout(), + CallTimeout: cfg.Gateway.callTimeout(), + Insecure: cfg.Gateway.InsecureTransport, + }) + if err != nil { + i.logger.Warn("Failed to initialise chain gateway client", zap.Error(err), zap.String("address", gatewayAddress)) + } else { + deps.gatewayClient = client + } + } + + return deps +} + +func (i *Imp) closeDependencies() { + if i.deps == nil { + return + } + if i.deps.oracleClient != nil { + _ = i.deps.oracleClient.Close() + i.deps.oracleClient = nil + } + if i.deps.gatewayClient != nil { + _ = i.deps.gatewayClient.Close() + i.deps.gatewayClient = nil + } + if i.deps.feesConn != nil { + _ = i.deps.feesConn.Close() + i.deps.feesConn = nil + } +} + +func dialGRPC(ctx context.Context, cfg clientConfig, address string) (*grpc.ClientConn, error) { + address = strings.TrimSpace(address) + if ctx == nil { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + } + if cfg.InsecureTransport { + return grpc.DialContext( + ctx, + address, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock(), + ) + } + + return grpc.DialContext( + ctx, + address, + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})), + grpc.WithBlock(), + ) +} diff --git a/api/payments/quotation/internal/server/internal/discovery.go b/api/payments/quotation/internal/server/internal/discovery.go new file mode 100644 index 00000000..e68f1096 --- /dev/null +++ b/api/payments/quotation/internal/server/internal/discovery.go @@ -0,0 +1,45 @@ +package serverimp + +import ( + "strings" + + "github.com/tech/sendico/payments/quotation/internal/appversion" + "github.com/tech/sendico/pkg/discovery" + msg "github.com/tech/sendico/pkg/messaging" + "go.uber.org/zap" +) + +const quotationDiscoverySender = "payment_quotation" + +func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) { + if i == nil || cfg == nil || producer == nil || cfg.GRPC == nil { + return + } + + invokeURI := strings.TrimSpace(cfg.GRPC.DiscoveryInvokeURI()) + if invokeURI == "" { + i.logger.Warn("Skipping discovery announcement: missing advertise host/port in gRPC config") + return + } + + announce := discovery.Announcement{ + Service: "PAYMENTS_QUOTATION", + Operations: []string{"payment.quote", "payment.multiquote"}, + InvokeURI: invokeURI, + Version: appversion.Create().Short(), + } + + i.discoveryAnnouncer = discovery.NewAnnouncer(i.logger, producer, quotationDiscoverySender, announce) + i.discoveryAnnouncer.Start() + i.logger.Info("Discovery announcer started", + zap.String("service", announce.Service), + zap.String("invoke_uri", announce.InvokeURI)) +} + +func (i *Imp) stopDiscoveryAnnouncer() { + if i == nil || i.discoveryAnnouncer == nil { + return + } + i.discoveryAnnouncer.Stop() + i.discoveryAnnouncer = nil +} diff --git a/api/payments/quotation/internal/server/internal/lifecycle.go b/api/payments/quotation/internal/server/internal/lifecycle.go new file mode 100644 index 00000000..583700b2 --- /dev/null +++ b/api/payments/quotation/internal/server/internal/lifecycle.go @@ -0,0 +1,18 @@ +package serverimp + +import ( + "context" + "time" +) + +func (i *Imp) shutdownApp() { + if i.app != nil { + timeout := 15 * time.Second + if i.config != nil && i.config.Runtime != nil { + timeout = i.config.Runtime.ShutdownTimeout() + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + i.app.Shutdown(ctx) + cancel() + } +} diff --git a/api/payments/quotation/internal/server/internal/serverimp.go b/api/payments/quotation/internal/server/internal/serverimp.go new file mode 100644 index 00000000..6038738b --- /dev/null +++ b/api/payments/quotation/internal/server/internal/serverimp.go @@ -0,0 +1,70 @@ +package serverimp + +import ( + quotesvc "github.com/tech/sendico/payments/quotation/internal/service/orchestrator" + "github.com/tech/sendico/payments/storage" + mongostorage "github.com/tech/sendico/payments/storage/mongo" + "github.com/tech/sendico/pkg/db" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server/grpcapp" +) + +func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { + return &Imp{ + logger: logger.Named("server"), + file: file, + debug: debug, + }, nil +} + +func (i *Imp) Shutdown() { + i.stopDiscoveryAnnouncer() + if i.service != nil { + i.service.Shutdown() + } + i.shutdownApp() + i.closeDependencies() +} + +func (i *Imp) Start() error { + cfg, err := i.loadConfig() + if err != nil { + return err + } + i.config = cfg + + i.deps = i.initDependencies(cfg) + + quoteRetention := cfg.quoteRetention() + repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { + return mongostorage.New(logger, conn, mongostorage.WithQuoteRetention(quoteRetention)) + } + + serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { + opts := []quotesvc.Option{} + if i.deps != nil { + if i.deps.feesClient != nil { + opts = append(opts, quotesvc.WithFeeEngine(i.deps.feesClient, cfg.Fees.callTimeout())) + } + if i.deps.oracleClient != nil { + opts = append(opts, quotesvc.WithOracleClient(i.deps.oracleClient)) + } + if i.deps.gatewayClient != nil { + opts = append(opts, quotesvc.WithChainGatewayClient(i.deps.gatewayClient)) + } + } + i.startDiscoveryAnnouncer(cfg, producer) + svc := quotesvc.NewQuotationService(logger, repo, opts...) + i.service = svc + return svc, nil + } + + app, err := grpcapp.NewApp(i.logger, "payments_quotation", cfg.Config, i.debug, repoFactory, serviceFactory) + if err != nil { + return err + } + i.app = app + + return i.app.Start() +} diff --git a/api/payments/quotation/internal/server/internal/types.go b/api/payments/quotation/internal/server/internal/types.go new file mode 100644 index 00000000..9722c592 --- /dev/null +++ b/api/payments/quotation/internal/server/internal/types.go @@ -0,0 +1,37 @@ +package serverimp + +import ( + oracleclient "github.com/tech/sendico/fx/oracle/client" + chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/mlogger" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + "github.com/tech/sendico/pkg/server/grpcapp" + "google.golang.org/grpc" +) + +type quoteService interface { + grpcapp.Service + Shutdown() +} + +type clientDependencies struct { + feesConn *grpc.ClientConn + feesClient feesv1.FeeEngineClient + oracleClient oracleclient.Client + gatewayClient chainclient.Client +} + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config + app *grpcapp.App[storage.Repository] + service quoteService + deps *clientDependencies + + discoveryAnnouncer *discovery.Announcer +} diff --git a/api/payments/quotation/internal/server/server.go b/api/payments/quotation/internal/server/server.go new file mode 100644 index 00000000..61523efa --- /dev/null +++ b/api/payments/quotation/internal/server/server.go @@ -0,0 +1,12 @@ +package server + +import ( + serverimp "github.com/tech/sendico/payments/quotation/internal/server/internal" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" +) + +// Create initialises the payment quotation server implementation. +func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return serverimp.Create(logger, file, debug) +} diff --git a/api/payments/quotation/internal/service/orchestrator/card_payout_constants.go b/api/payments/quotation/internal/service/orchestrator/card_payout_constants.go new file mode 100644 index 00000000..eff95946 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/card_payout_constants.go @@ -0,0 +1,10 @@ +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 new file mode 100644 index 00000000..e558d08e --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/card_payout_funding.go @@ -0,0 +1,367 @@ +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 new file mode 100644 index 00000000..86554e87 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/card_payout_helpers.go @@ -0,0 +1,80 @@ +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 new file mode 100644 index 00000000..621e693f --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/card_payout_routes.go @@ -0,0 +1,29 @@ +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 new file mode 100644 index 00000000..4f5d8b9b --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/card_payout_submit.go @@ -0,0 +1,351 @@ +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/command_factory.go b/api/payments/quotation/internal/service/orchestrator/command_factory.go new file mode 100644 index 00000000..ed7b6c09 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/command_factory.go @@ -0,0 +1,97 @@ +package orchestrator + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +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 + Repository() storage.Repository +} + +type defaultPaymentEngine struct { + svc *Service +} + +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) { + return e.svc.resolvePaymentQuote(ctx, in) +} + +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) Repository() storage.Repository { + return e.svc.storage +} + +type paymentCommandFactory struct { + engine paymentEngine + logger mlogger.Logger +} + +func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory { + return &paymentCommandFactory{ + engine: engine, + logger: logger.Named("commands"), + } +} + +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, + 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"), + } +} diff --git a/api/payments/quotation/internal/service/orchestrator/composite_gateway_registry.go b/api/payments/quotation/internal/service/orchestrator/composite_gateway_registry.go new file mode 100644 index 00000000..d2a266a5 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/composite_gateway_registry.go @@ -0,0 +1,64 @@ +package orchestrator + +import ( + "context" + "sort" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type compositeGatewayRegistry struct { + logger mlogger.Logger + registries []GatewayRegistry +} + +func NewCompositeGatewayRegistry(logger mlogger.Logger, registries ...GatewayRegistry) GatewayRegistry { + items := make([]GatewayRegistry, 0, len(registries)) + for _, registry := range registries { + if registry != nil { + items = append(items, registry) + } + } + if len(items) == 0 { + return nil + } + if logger != nil { + logger = logger.Named("gateway_registry") + } + return &compositeGatewayRegistry{ + logger: logger, + registries: items, + } +} + +func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if r == nil || len(r.registries) == 0 { + return nil, nil + } + items := map[string]*model.GatewayInstanceDescriptor{} + for _, registry := range r.registries { + list, err := registry.List(ctx) + if err != nil { + if r.logger != nil { + r.logger.Warn("Failed to list gateway registry", zap.Error(err)) + } + continue + } + for _, entry := range list { + if entry == nil || entry.ID == "" { + continue + } + items[entry.ID] = entry + } + } + result := make([]*model.GatewayInstanceDescriptor, 0, len(items)) + for _, entry := range items { + result = append(result, entry) + } + sort.Slice(result, func(i, j int) bool { + return result[i].ID < result[j].ID + }) + return result, nil +} diff --git a/api/payments/quotation/internal/service/orchestrator/convert.go b/api/payments/quotation/internal/service/orchestrator/convert.go new file mode 100644 index 00000000..b01fa470 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/convert.go @@ -0,0 +1,947 @@ +package orchestrator + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/storage/model" + chainasset "github.com/tech/sendico/pkg/chain" + 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" + 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 { + if src == nil { + return model.PaymentIntent{} + } + intent := model.PaymentIntent{ + Ref: src.GetRef(), + Kind: modelKindFromProto(src.GetKind()), + Source: endpointFromProto(src.GetSource()), + Destination: endpointFromProto(src.GetDestination()), + Amount: moneyFromProto(src.GetAmount()), + RequiresFX: src.GetRequiresFx(), + FeePolicy: feePolicyFromProto(src.GetFeePolicy()), + SettlementMode: settlementModeFromProto(src.GetSettlementMode()), + SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()), + Attributes: cloneMetadata(src.GetAttributes()), + Customer: customerFromProto(src.GetCustomer()), + } + if src.GetFx() != nil { + intent.FX = fxIntentFromProto(src.GetFx()) + } + return intent +} + +func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoint { + if src == nil { + return model.PaymentEndpoint{Type: model.EndpointTypeUnspecified} + } + result := model.PaymentEndpoint{ + Type: model.EndpointTypeUnspecified, + InstanceID: strings.TrimSpace(src.GetInstanceId()), + Metadata: cloneMetadata(src.GetMetadata()), + } + if ledger := src.GetLedger(); ledger != nil { + result.Type = model.EndpointTypeLedger + result.Ledger = &model.LedgerEndpoint{ + LedgerAccountRef: strings.TrimSpace(ledger.GetLedgerAccountRef()), + ContraLedgerAccountRef: strings.TrimSpace(ledger.GetContraLedgerAccountRef()), + } + return result + } + if managed := src.GetManagedWallet(); managed != nil { + result.Type = model.EndpointTypeManagedWallet + result.ManagedWallet = &model.ManagedWalletEndpoint{ + ManagedWalletRef: strings.TrimSpace(managed.GetManagedWalletRef()), + Asset: assetFromProto(managed.GetAsset()), + } + return result + } + if external := src.GetExternalChain(); external != nil { + result.Type = model.EndpointTypeExternalChain + result.ExternalChain = &model.ExternalChainEndpoint{ + Asset: assetFromProto(external.GetAsset()), + Address: strings.TrimSpace(external.GetAddress()), + Memo: strings.TrimSpace(external.GetMemo()), + } + return result + } + if card := src.GetCard(); card != nil { + result.Type = model.EndpointTypeCard + result.Card = &model.CardEndpoint{ + Pan: strings.TrimSpace(card.GetPan()), + Token: strings.TrimSpace(card.GetToken()), + Cardholder: strings.TrimSpace(card.GetCardholderName()), + CardholderSurname: strings.TrimSpace(card.GetCardholderSurname()), + ExpMonth: card.GetExpMonth(), + ExpYear: card.GetExpYear(), + Country: strings.TrimSpace(card.GetCountry()), + MaskedPan: strings.TrimSpace(card.GetMaskedPan()), + } + return result + } + return result +} + +func fxIntentFromProto(src *orchestratorv1.FXIntent) *model.FXIntent { + if src == nil { + return nil + } + return &model.FXIntent{ + Pair: pairFromProto(src.GetPair()), + Side: fxSideFromProto(src.GetSide()), + Firm: src.GetFirm(), + TTLMillis: src.GetTtlMs(), + PreferredProvider: strings.TrimSpace(src.GetPreferredProvider()), + MaxAgeMillis: src.GetMaxAgeMs(), + } +} + +func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteSnapshot { + if src == nil { + return nil + } + return &model.PaymentQuoteSnapshot{ + DebitAmount: moneyFromProto(src.GetDebitAmount()), + DebitSettlementAmount: moneyFromProto(src.GetDebitSettlementAmount()), + ExpectedSettlementAmount: moneyFromProto(src.GetExpectedSettlementAmount()), + ExpectedFeeTotal: moneyFromProto(src.GetExpectedFeeTotal()), + FeeLines: feeLinesFromProto(src.GetFeeLines()), + FeeRules: feeRulesFromProto(src.GetFeeRules()), + FXQuote: fxQuoteFromProto(src.GetFxQuote()), + NetworkFee: networkFeeFromProto(src.GetNetworkFee()), + QuoteRef: strings.TrimSpace(src.GetQuoteRef()), + } +} + +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, + Kind: protoKindFromModel(src.Kind), + Source: protoEndpointFromModel(src.Source), + Destination: protoEndpointFromModel(src.Destination), + Amount: protoMoney(src.Amount), + RequiresFx: src.RequiresFX, + FeePolicy: feePolicyToProto(src.FeePolicy), + SettlementMode: settlementModeToProto(src.SettlementMode), + SettlementCurrency: strings.TrimSpace(src.SettlementCurrency), + Attributes: cloneMetadata(src.Attributes), + Customer: protoCustomerFromModel(src.Customer), + } + if src.FX != nil { + intent.Fx = protoFXIntentFromModel(src.FX) + } + return intent +} + +func customerFromProto(src *orchestratorv1.Customer) *model.Customer { + if src == nil { + return nil + } + return &model.Customer{ + ID: strings.TrimSpace(src.GetId()), + FirstName: strings.TrimSpace(src.GetFirstName()), + MiddleName: strings.TrimSpace(src.GetMiddleName()), + LastName: strings.TrimSpace(src.GetLastName()), + IP: strings.TrimSpace(src.GetIp()), + Zip: strings.TrimSpace(src.GetZip()), + Country: strings.TrimSpace(src.GetCountry()), + State: strings.TrimSpace(src.GetState()), + City: strings.TrimSpace(src.GetCity()), + Address: strings.TrimSpace(src.GetAddress()), + } +} + +func protoCustomerFromModel(src *model.Customer) *orchestratorv1.Customer { + if src == nil { + return nil + } + return &orchestratorv1.Customer{ + Id: strings.TrimSpace(src.ID), + FirstName: strings.TrimSpace(src.FirstName), + MiddleName: strings.TrimSpace(src.MiddleName), + LastName: strings.TrimSpace(src.LastName), + Ip: strings.TrimSpace(src.IP), + Zip: strings.TrimSpace(src.Zip), + Country: strings.TrimSpace(src.Country), + State: strings.TrimSpace(src.State), + City: strings.TrimSpace(src.City), + Address: strings.TrimSpace(src.Address), + } +} + +func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint { + endpoint := &orchestratorv1.PaymentEndpoint{ + Metadata: cloneMetadata(src.Metadata), + InstanceId: strings.TrimSpace(src.InstanceID), + } + switch src.Type { + case model.EndpointTypeLedger: + if src.Ledger != nil { + endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Ledger{ + Ledger: &orchestratorv1.LedgerEndpoint{ + LedgerAccountRef: src.Ledger.LedgerAccountRef, + ContraLedgerAccountRef: src.Ledger.ContraLedgerAccountRef, + }, + } + } + case model.EndpointTypeManagedWallet: + if src.ManagedWallet != nil { + endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{ + ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ + ManagedWalletRef: src.ManagedWallet.ManagedWalletRef, + Asset: assetToProto(src.ManagedWallet.Asset), + }, + } + } + case model.EndpointTypeExternalChain: + if src.ExternalChain != nil { + endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{ + ExternalChain: &orchestratorv1.ExternalChainEndpoint{ + Asset: assetToProto(src.ExternalChain.Asset), + Address: src.ExternalChain.Address, + Memo: src.ExternalChain.Memo, + }, + } + } + case model.EndpointTypeCard: + if src.Card != nil { + card := &orchestratorv1.CardEndpoint{ + CardholderName: src.Card.Cardholder, + CardholderSurname: src.Card.CardholderSurname, + ExpMonth: src.Card.ExpMonth, + ExpYear: src.Card.ExpYear, + Country: src.Card.Country, + MaskedPan: src.Card.MaskedPan, + } + if pan := strings.TrimSpace(src.Card.Pan); pan != "" { + card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan} + } + if token := strings.TrimSpace(src.Card.Token); token != "" { + card.Card = &orchestratorv1.CardEndpoint_Token{Token: token} + } + endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card} + } + default: + // leave unspecified + } + return endpoint +} + +func protoFXIntentFromModel(src *model.FXIntent) *orchestratorv1.FXIntent { + if src == nil { + return nil + } + return &orchestratorv1.FXIntent{ + Pair: pairToProto(src.Pair), + Side: fxSideToProto(src.Side), + Firm: src.Firm, + TtlMs: src.TTLMillis, + PreferredProvider: src.PreferredProvider, + MaxAgeMs: src.MaxAgeMillis, + } +} + +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 + } + 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 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: + return orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT + case model.PaymentKindInternalTransfer: + return orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER + case model.PaymentKindFXConversion: + return orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION + default: + return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED + } +} + +func modelKindFromProto(kind orchestratorv1.PaymentKind) model.PaymentKind { + switch kind { + case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT: + return model.PaymentKindPayout + case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER: + return model.PaymentKindInternalTransfer + case orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION: + return model.PaymentKindFXConversion + default: + return model.PaymentKindUnspecified + } +} + +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: + return model.SettlementModeFixSource + case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return model.SettlementModeFixReceived + default: + return model.SettlementModeUnspecified + } +} + +func settlementModeToProto(mode model.SettlementMode) orchestratorv1.SettlementMode { + switch mode { + case model.SettlementModeFixSource: + return orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE + case model.SettlementModeFixReceived: + return orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + default: + return orchestratorv1.SettlementMode_SETTLEMENT_UNSPECIFIED + } +} + +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 feePolicyFromProto(src *feesv1.PolicyOverrides) *paymenttypes.FeePolicy { + if src == nil { + return nil + } + return &paymenttypes.FeePolicy{ + InsufficientNet: insufficientPolicyFromProto(src.GetInsufficientNet()), + } +} + +func feePolicyToProto(src *paymenttypes.FeePolicy) *feesv1.PolicyOverrides { + if src == nil { + return nil + } + return &feesv1.PolicyOverrides{ + InsufficientNet: insufficientPolicyToProto(src.InsufficientNet), + } +} + +func insufficientPolicyFromProto(policy feesv1.InsufficientNetPolicy) paymenttypes.InsufficientNetPolicy { + switch policy { + case feesv1.InsufficientNetPolicy_BLOCK_POSTING: + return paymenttypes.InsufficientNetBlockPosting + case feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH: + return paymenttypes.InsufficientNetSweepOrgCash + case feesv1.InsufficientNetPolicy_INVOICE_LATER: + return paymenttypes.InsufficientNetInvoiceLater + default: + return paymenttypes.InsufficientNetUnspecified + } +} + +func insufficientPolicyToProto(policy paymenttypes.InsufficientNetPolicy) feesv1.InsufficientNetPolicy { + switch policy { + case paymenttypes.InsufficientNetBlockPosting: + return feesv1.InsufficientNetPolicy_BLOCK_POSTING + case paymenttypes.InsufficientNetSweepOrgCash: + return feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH + case paymenttypes.InsufficientNetInvoiceLater: + return feesv1.InsufficientNetPolicy_INVOICE_LATER + default: + return feesv1.InsufficientNetPolicy_INSUFFICIENT_NET_UNSPECIFIED + } +} + +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 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 assetFromProto(asset *chainv1.Asset) *paymenttypes.Asset { + if asset == nil { + return nil + } + return &paymenttypes.Asset{ + Chain: chainasset.NetworkAlias(asset.GetChain()), + TokenSymbol: asset.GetTokenSymbol(), + ContractAddress: asset.GetContractAddress(), + } +} + +func assetToProto(asset *paymenttypes.Asset) *chainv1.Asset { + if asset == nil { + return nil + } + return &chainv1.Asset{ + Chain: chainasset.NetworkFromString(asset.Chain), + TokenSymbol: asset.TokenSymbol, + ContractAddress: asset.ContractAddress, + } +} + +func networkFeeFromProto(resp *chainv1.EstimateTransferFeeResponse) *paymenttypes.NetworkFeeEstimate { + if resp == nil { + return nil + } + return &paymenttypes.NetworkFeeEstimate{ + NetworkFee: moneyFromProto(resp.GetNetworkFee()), + EstimationContext: strings.TrimSpace(resp.GetEstimationContext()), + } +} + +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 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 feeRulesFromProto(rules []*feesv1.AppliedRule) []*paymenttypes.AppliedRule { + if len(rules) == 0 { + return nil + } + result := make([]*paymenttypes.AppliedRule, 0, len(rules)) + for _, rule := range rules { + if rule == nil { + continue + } + result = append(result, &paymenttypes.AppliedRule{ + RuleID: strings.TrimSpace(rule.GetRuleId()), + RuleVersion: strings.TrimSpace(rule.GetRuleVersion()), + Formula: strings.TrimSpace(rule.GetFormula()), + Rounding: roundingModeFromProto(rule.GetRounding()), + TaxCode: strings.TrimSpace(rule.GetTaxCode()), + TaxRate: strings.TrimSpace(rule.GetTaxRate()), + Parameters: cloneMetadata(rule.GetParameters()), + }) + } + 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 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 roundingModeFromProto(mode moneyv1.RoundingMode) paymenttypes.RoundingMode { + switch mode { + case moneyv1.RoundingMode_ROUND_HALF_EVEN: + return paymenttypes.RoundingModeHalfEven + case moneyv1.RoundingMode_ROUND_HALF_UP: + return paymenttypes.RoundingModeHalfUp + case moneyv1.RoundingMode_ROUND_DOWN: + return paymenttypes.RoundingModeDown + default: + return paymenttypes.RoundingModeUnspecified + } +} + +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/quotation/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/quotation/internal/service/orchestrator/discovery_gateway_registry.go new file mode 100644 index 00000000..e5afe437 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/discovery_gateway_registry.go @@ -0,0 +1,137 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/mlogger" +) + +type discoveryGatewayRegistry struct { + logger mlogger.Logger + registry *discovery.Registry +} + +func NewDiscoveryGatewayRegistry(logger mlogger.Logger, registry *discovery.Registry) GatewayRegistry { + if registry == nil { + return nil + } + if logger != nil { + logger = logger.Named("discovery_gateway_registry") + } + return &discoveryGatewayRegistry{ + logger: logger, + registry: registry, + } +} + +func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if r == nil || r.registry == nil { + return nil, nil + } + entries := r.registry.List(time.Now(), true) + items := make([]*model.GatewayInstanceDescriptor, 0, len(entries)) + for _, entry := range entries { + if entry.Rail == "" { + continue + } + rail := railFromDiscovery(entry.Rail) + if rail == model.RailUnspecified { + continue + } + items = append(items, &model.GatewayInstanceDescriptor{ + ID: entry.ID, + InstanceID: entry.InstanceID, + Rail: rail, + Network: entry.Network, + InvokeURI: strings.TrimSpace(entry.InvokeURI), + Currencies: normalizeCurrencies(entry.Currencies), + Capabilities: capabilitiesFromOps(entry.Operations), + Limits: limitsFromDiscovery(entry.Limits), + Version: entry.Version, + IsEnabled: entry.Healthy, + }) + } + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + return items, nil +} + +func railFromDiscovery(value string) model.Rail { + switch strings.ToUpper(strings.TrimSpace(value)) { + case string(model.RailCrypto): + return model.RailCrypto + case string(model.RailProviderSettlement): + return model.RailProviderSettlement + case string(model.RailLedger): + return model.RailLedger + case string(model.RailCardPayout): + return model.RailCardPayout + case string(model.RailFiatOnRamp): + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} + +func capabilitiesFromOps(ops []string) model.RailCapabilities { + var cap model.RailCapabilities + for _, op := range ops { + switch strings.ToLower(strings.TrimSpace(op)) { + case "payin.crypto", "payin.card", "payin.fiat": + cap.CanPayIn = true + case "payout.crypto", "payout.card", "payout.fiat": + cap.CanPayOut = true + case "balance.read": + cap.CanReadBalance = true + case "fee.send": + cap.CanSendFee = true + case "observe.confirm", "observe.confirmation": + cap.RequiresObserveConfirm = true + case "block", "funds.block", "balance.block", "ledger.block": + cap.CanBlock = true + case "release", "funds.release", "balance.release", "ledger.release": + cap.CanRelease = true + } + } + return cap +} + +func limitsFromDiscovery(src *discovery.Limits) model.Limits { + if src == nil { + return model.Limits{} + } + limits := model.Limits{ + MinAmount: strings.TrimSpace(src.MinAmount), + MaxAmount: strings.TrimSpace(src.MaxAmount), + VolumeLimit: map[string]string{}, + VelocityLimit: map[string]int{}, + } + for key, value := range src.VolumeLimit { + k := strings.TrimSpace(key) + v := strings.TrimSpace(value) + if k == "" || v == "" { + continue + } + limits.VolumeLimit[k] = v + } + for key, value := range src.VelocityLimit { + k := strings.TrimSpace(key) + if k == "" { + continue + } + limits.VelocityLimit[k] = value + } + if len(limits.VolumeLimit) == 0 { + limits.VolumeLimit = nil + } + if len(limits.VelocityLimit) == 0 { + limits.VelocityLimit = nil + } + return limits +} diff --git a/api/payments/quotation/internal/service/orchestrator/execution_plan.go b/api/payments/quotation/internal/service/orchestrator/execution_plan.go new file mode 100644 index 00000000..fb093504 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/execution_plan.go @@ -0,0 +1,163 @@ +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 new file mode 100644 index 00000000..a6065af9 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/gateway_execution_consumer.go @@ -0,0 +1,295 @@ +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/gateway_registry.go b/api/payments/quotation/internal/service/orchestrator/gateway_registry.go new file mode 100644 index 00000000..9abf3673 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/gateway_registry.go @@ -0,0 +1,120 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" +) + +type gatewayRegistry struct { + logger mlogger.Logger + static []*model.GatewayInstanceDescriptor +} + +// NewGatewayRegistry aggregates static gateway descriptors. +func NewGatewayRegistry(logger mlogger.Logger, static []*model.GatewayInstanceDescriptor) GatewayRegistry { + if len(static) == 0 { + return nil + } + if logger != nil { + logger = logger.Named("gateway_registry") + } + return &gatewayRegistry{ + logger: logger, + static: cloneGatewayDescriptors(static), + } +} + +func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) { + items := map[string]*model.GatewayInstanceDescriptor{} + for _, gw := range r.static { + if gw == nil { + continue + } + id := strings.TrimSpace(gw.ID) + if id == "" { + continue + } + items[id] = cloneGatewayDescriptor(gw) + } + + result := make([]*model.GatewayInstanceDescriptor, 0, len(items)) + for _, gw := range items { + result = append(result, gw) + } + sort.Slice(result, func(i, j int) bool { + return result[i].ID < result[j].ID + }) + return result, nil +} + +func normalizeCurrencies(values []string) []string { + if len(values) == 0 { + return nil + } + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" || seen[clean] { + continue + } + seen[clean] = true + result = append(result, clean) + } + return result +} + +func cloneGatewayDescriptors(src []*model.GatewayInstanceDescriptor) []*model.GatewayInstanceDescriptor { + if len(src) == 0 { + return nil + } + result := make([]*model.GatewayInstanceDescriptor, 0, len(src)) + for _, item := range src { + if item == nil { + continue + } + if cloned := cloneGatewayDescriptor(item); cloned != nil { + result = append(result, cloned) + } + } + return result +} + +func cloneGatewayDescriptor(src *model.GatewayInstanceDescriptor) *model.GatewayInstanceDescriptor { + if src == nil { + return nil + } + dst := *src + if src.Currencies != nil { + dst.Currencies = append([]string(nil), src.Currencies...) + } + dst.Limits = cloneLimits(src.Limits) + return &dst +} + +func cloneLimits(src model.Limits) model.Limits { + dst := src + if src.VolumeLimit != nil { + dst.VolumeLimit = map[string]string{} + for key, value := range src.VolumeLimit { + dst.VolumeLimit[key] = value + } + } + if src.VelocityLimit != nil { + dst.VelocityLimit = map[string]int{} + for key, value := range src.VelocityLimit { + dst.VelocityLimit[key] = value + } + } + if src.CurrencyLimits != nil { + dst.CurrencyLimits = map[string]model.LimitsOverride{} + for key, value := range src.CurrencyLimits { + dst.CurrencyLimits[key] = value + } + } + return dst +} diff --git a/api/payments/quotation/internal/service/orchestrator/gateway_resolution.go b/api/payments/quotation/internal/service/orchestrator/gateway_resolution.go new file mode 100644 index 00000000..68229a10 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/gateway_resolution.go @@ -0,0 +1,138 @@ +package orchestrator + +import ( + "context" + "sort" + "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" + "go.uber.org/zap" +) + +func (s *Service) resolveChainGatewayClient(ctx context.Context, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, paymentRef string) (chainclient.Client, *model.GatewayInstanceDescriptor, error) { + if s.deps.gatewayRegistry != nil && s.deps.gatewayInvokeResolver != nil { + entry, err := selectGatewayForActions(ctx, s.deps.gatewayRegistry, model.RailCrypto, network, amount, actions, instanceID, sendDirectionForRail(model.RailCrypto)) + if err != nil { + return nil, nil, err + } + invokeURI := strings.TrimSpace(entry.InvokeURI) + if invokeURI == "" { + return nil, nil, merrors.InvalidArgument("chain gateway: invoke uri is required") + } + client, err := s.deps.gatewayInvokeResolver.Resolve(ctx, invokeURI) + if err != nil { + return nil, nil, err + } + if s.logger != nil { + fields := []zap.Field{ + 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), + } + if paymentRef != "" { + fields = append(fields, zap.String("payment_ref", paymentRef)) + } + if len(actions) > 0 { + fields = append(fields, zap.Strings("actions", railActionNames(actions))) + } + s.logger.Info("Chain gateway selected", fields...) + } + return client, entry, nil + } + if s.deps.gateway.resolver != nil { + client, err := s.deps.gateway.resolver.Resolve(ctx, network) + if err != nil { + return nil, nil, err + } + return client, nil, nil + } + 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) { + if registry == nil { + return nil, merrors.NoData("gateway registry unavailable") + } + all, err := registry.List(ctx) + if err != nil { + return nil, err + } + if len(all) == 0 { + return nil, merrors.NoData("no gateway instances available") + } + if len(actions) == 0 { + actions = []model.RailOperation{model.RailOperationSend} + } + + currency := "" + amt := decimal.Zero + if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" { + amt, err = decimalFromMoney(amount) + if err != nil { + return nil, err + } + currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + } + network = strings.ToUpper(strings.TrimSpace(network)) + + eligible := make([]*model.GatewayInstanceDescriptor, 0) + var lastErr error + for _, entry := range all { + if entry == nil || !entry.IsEnabled { + continue + } + if entry.Rail != rail { + continue + } + if instanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) { + continue + } + ok := true + for _, action := range actions { + if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil { + lastErr = err + ok = false + break + } + } + if !ok { + continue + } + eligible = append(eligible, entry) + } + + if len(eligible) == 0 { + if lastErr != nil { + return nil, merrors.NoData("no eligible gateway instance found: " + lastErr.Error()) + } + return nil, merrors.NoData("no eligible gateway instance found") + } + sort.Slice(eligible, func(i, j int) bool { + return eligible[i].ID < eligible[j].ID + }) + return eligible[0], nil +} + +func railActionNames(actions []model.RailOperation) []string { + if len(actions) == 0 { + return nil + } + names := make([]string, 0, len(actions)) + for _, action := range actions { + name := strings.TrimSpace(string(action)) + if name == "" { + continue + } + names = append(names, name) + } + if len(names) == 0 { + return nil + } + return names +} diff --git a/api/payments/quotation/internal/service/orchestrator/handlers_commands.go b/api/payments/quotation/internal/service/orchestrator/handlers_commands.go new file mode 100644 index 00000000..57e9cb40 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/handlers_commands.go @@ -0,0 +1,922 @@ +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 new file mode 100644 index 00000000..c4d75792 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/handlers_events.go @@ -0,0 +1,318 @@ +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 new file mode 100644 index 00000000..9ae60074 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/handlers_queries.go @@ -0,0 +1,80 @@ +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/helpers.go b/api/payments/quotation/internal/service/orchestrator/helpers.go new file mode 100644 index 00000000..3b42580d --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/helpers.go @@ -0,0 +1,463 @@ +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" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +type moneyGetter interface { + GetAmount() string + GetCurrency() string +} + +const ( + feeLineMetaTarget = "fee_target" + feeLineTargetWallet = "wallet" + feeLineMetaWalletRef = "fee_wallet_ref" + feeLineMetaWalletType = "fee_wallet_type" +) + +func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money { + if input == nil { + return nil + } + return &moneyv1.Money{ + Currency: input.GetCurrency(), + Amount: input.GetAmount(), + } +} + +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 cloneStringList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.TrimSpace(value) + if clean == "" { + continue + } + result = append(result, clean) + } + if len(result) == 0 { + return nil + } + return result +} + +func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine { + if len(lines) == 0 { + return nil + } + 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) + } + qSide := fxQuote.GetSide() + if qSide == fxv1.Side_SIDE_UNSPECIFIED { + qSide = side + } + + switch qSide { + case fxv1.Side_BUY_BASE_SELL_QUOTE: + pay := cloneProtoMoney(fxQuote.GetQuoteAmount()) + settle := cloneProtoMoney(fxQuote.GetBaseAmount()) + if pay == nil { + pay = cloneProtoMoney(intentAmount) + } + if settle == nil { + settle = cloneProtoMoney(intentAmount) + } + return pay, settle + case fxv1.Side_SELL_BASE_BUY_QUOTE: + pay := cloneProtoMoney(fxQuote.GetBaseAmount()) + settle := cloneProtoMoney(fxQuote.GetQuoteAmount()) + if pay == nil { + pay = cloneProtoMoney(intentAmount) + } + if settle == nil { + settle = cloneProtoMoney(intentAmount) + } + return pay, settle + default: + return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount) + } +} + +func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode orchestratorv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) { + if pay == nil { + return nil, nil + } + debitDecimal, err := decimalFromMoney(pay) + if err != nil { + return cloneProtoMoney(pay), cloneProtoMoney(settlement) + } + + settlementCurrency := pay.GetCurrency() + if settlement != nil && strings.TrimSpace(settlement.GetCurrency()) != "" { + settlementCurrency = settlement.GetCurrency() + } + + settlementDecimal := debitDecimal + if settlement != nil { + if val, err := decimalFromMoney(settlement); err == nil { + settlementDecimal = val + } + } + + applyChargeToDebit := func(m *moneyv1.Money) { + converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote) + if err != nil || converted == nil { + return + } + if val, err := decimalFromMoney(converted); err == nil { + debitDecimal = debitDecimal.Add(val) + } + } + + applyChargeToSettlement := func(m *moneyv1.Money) { + converted, err := ensureCurrency(m, settlementCurrency, fxQuote) + if err != nil || converted == nil { + return + } + if val, err := decimalFromMoney(converted); err == nil { + settlementDecimal = settlementDecimal.Sub(val) + } + } + + switch mode { + case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + // Sender pays the fee: keep settlement fixed, increase debit. + applyChargeToDebit(fee) + default: + // Recipient pays the fee (default): reduce settlement, keep debit fixed. + applyChargeToSettlement(fee) + } + + if network != nil && network.GetNetworkFee() != nil { + switch mode { + case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + applyChargeToDebit(network.GetNetworkFee()) + default: + applyChargeToSettlement(network.GetNetworkFee()) + } + } + + return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal) +} + +func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) { + if m == nil { + return decimal.Zero, nil + } + return decimal.NewFromString(m.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 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 "" + } + return strings.TrimSpace(line.GetMeta()[feeLineMetaTarget]) +} + +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 + } + 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 { + 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 + } + 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/orchestrator/internal_helpers.go new file mode 100644 index 00000000..94d5b22d --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/internal_helpers.go @@ -0,0 +1,132 @@ +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" +) + +func (s *Service) ensureRepository(ctx context.Context) error { + if s.storage == nil { + return errStorageUnavailable + } + 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) + observeRPC(method, err, svc.clock.Now().Sub(start)) + 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 + } + dest := intent.GetDestination() + if dest == nil { + return false + } + if dest.GetCard() != nil { + return false + } + if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT { + return true + } + if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil { + return true + } + 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 { + + 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/orchestrator/metrics.go new file mode 100644 index 00000000..417eb90e --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/metrics.go @@ -0,0 +1,65 @@ +package orchestrator + +import ( + "errors" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/tech/sendico/pkg/merrors" +) + +var ( + metricsOnce sync.Once + + rpcLatency *prometheus.HistogramVec + rpcStatus *prometheus.CounterVec +) + +func initMetrics() { + metricsOnce.Do(func() { + rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "sendico", + Subsystem: "payment_orchestrator", + Name: "rpc_latency_seconds", + Help: "Latency distribution for payment orchestrator RPC handlers.", + Buckets: prometheus.DefBuckets, + }, []string{"method"}) + + rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "payment_orchestrator", + Name: "rpc_requests_total", + Help: "Total number of RPC invocations grouped by method and status.", + }, []string{"method", "status"}) + }) +} + +func observeRPC(method string, err error, duration time.Duration) { + if rpcLatency != nil { + rpcLatency.WithLabelValues(method).Observe(duration.Seconds()) + } + if rpcStatus != nil { + rpcStatus.WithLabelValues(method, statusLabel(err)).Inc() + } +} + +func statusLabel(err error) string { + switch { + case err == nil: + return "ok" + case errors.Is(err, merrors.ErrInvalidArg): + return "invalid_argument" + case errors.Is(err, merrors.ErrNoData): + return "not_found" + case errors.Is(err, merrors.ErrDataConflict): + return "conflict" + case errors.Is(err, merrors.ErrAccessDenied): + return "denied" + case errors.Is(err, merrors.ErrInternal): + return "internal" + default: + return "error" + } +} diff --git a/api/payments/quotation/internal/service/orchestrator/model_money.go b/api/payments/quotation/internal/service/orchestrator/model_money.go new file mode 100644 index 00000000..3a8184d3 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/model_money.go @@ -0,0 +1,13 @@ +package orchestrator + +import paymenttypes "github.com/tech/sendico/pkg/payments/types" + +func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money { + if input == nil { + return nil + } + return &paymenttypes.Money{ + Currency: input.GetCurrency(), + Amount: input.GetAmount(), + } +} diff --git a/api/payments/quotation/internal/service/orchestrator/options.go b/api/payments/quotation/internal/service/orchestrator/options.go new file mode 100644 index 00000000..ccc2833f --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/options.go @@ -0,0 +1,456 @@ +package orchestrator + +import ( + "context" + "sort" + "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" + "github.com/tech/sendico/pkg/merrors" + mb "github.com/tech/sendico/pkg/messaging/broker" + "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. +type Option func(*Service) + +// GatewayInvokeResolver resolves gateway invoke URIs into chain gateway clients. +type GatewayInvokeResolver interface { + Resolve(ctx context.Context, invokeURI string) (chainclient.Client, error) +} + +// ChainGatewayResolver resolves chain gateway clients by network. +type ChainGatewayResolver interface { + Resolve(ctx context.Context, network string) (chainclient.Client, error) +} + +type feesDependency struct { + client feesv1.FeeEngineClient + timeout time.Duration +} + +func (f feesDependency) available() bool { + if f.client == nil { + return false + } + if checker, ok := f.client.(interface{ Available() bool }); ok { + return checker.Available() + } + return true +} + +type ledgerDependency struct { + client ledgerclient.Client + internal rail.InternalLedger +} + +type gatewayDependency struct { + resolver ChainGatewayResolver +} + +type railGatewayDependency struct { + byID map[string]rail.RailGateway + byRail map[model.Rail][]rail.RailGateway + registry GatewayRegistry + chainResolver GatewayInvokeResolver + providerResolver GatewayInvokeResolver + 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 +} + +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 +} + +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 +} + +type staticChainGatewayResolver struct { + client chainclient.Client +} + +func (r staticChainGatewayResolver) Resolve(ctx context.Context, _ string) (chainclient.Client, error) { + if r.client == nil { + return nil, merrors.InvalidArgument("chain gateway client is required") + } + return r.client, nil +} + +// CardGatewayRoute maps a gateway to its funding and fee destinations. +type CardGatewayRoute struct { + FundingAddress string + FeeAddress string + FeeWalletRef string +} + +// WithFeeEngine wires the fee engine client. +func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { + return func(s *Service) { + s.deps.fees = feesDependency{ + client: client, + timeout: timeout, + } + } +} + +func WithPaymentGatewayBroker(broker mb.Broker) Option { + return func(s *Service) { + if broker != nil { + s.gatewayBroker = broker + } + } +} + +// WithLedgerClient wires the ledger client. +func WithLedgerClient(client ledgerclient.Client) Option { + return func(s *Service) { + s.deps.ledger = ledgerDependency{ + client: client, + internal: client, + } + } +} + +// WithChainGatewayClient wires the chain gateway client. +func WithChainGatewayClient(client chainclient.Client) Option { + return func(s *Service) { + s.deps.gateway = gatewayDependency{resolver: staticChainGatewayResolver{client: client}} + } +} + +// WithChainGatewayResolver wires a resolver for chain gateway clients. +func WithChainGatewayResolver(resolver ChainGatewayResolver) Option { + return func(s *Service) { + if resolver != nil { + s.deps.gateway = gatewayDependency{resolver: resolver} + } + } +} + +// WithProviderSettlementGatewayClient wires the provider settlement gateway client. +func WithProviderSettlementGatewayClient(client chainclient.Client) Option { + return func(s *Service) { + s.deps.providerGateway = providerGatewayDependency{resolver: staticChainGatewayResolver{client: client}} + } +} + +// WithProviderSettlementGatewayResolver wires a resolver for provider settlement gateway clients. +func WithProviderSettlementGatewayResolver(resolver ChainGatewayResolver) Option { + return func(s *Service) { + if resolver != nil { + s.deps.providerGateway = providerGatewayDependency{resolver: resolver} + } + } +} + +// WithGatewayInvokeResolver wires a resolver for gateway invoke URIs. +func WithGatewayInvokeResolver(resolver GatewayInvokeResolver) Option { + return func(s *Service) { + if resolver == nil { + return + } + s.deps.gatewayInvokeResolver = resolver + s.deps.railGateways.chainResolver = resolver + s.deps.railGateways.providerResolver = resolver + } +} + +// WithRailGateways wires rail gateway adapters by instance ID. +func WithRailGateways(gateways map[string]rail.RailGateway) Option { + return func(s *Service) { + if len(gateways) == 0 { + return + } + s.deps.railGateways = buildRailGatewayDependency(gateways, s.deps.gatewayRegistry, s.deps.gatewayInvokeResolver, s.deps.gatewayInvokeResolver, s.logger) + } +} + +// 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) { + 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) { + if len(routes) == 0 { + return + } + s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes)) + for k, v := range routes { + s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v + } + } +} + +// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees. +func WithFeeLedgerAccounts(routes map[string]string) Option { + return func(s *Service) { + if len(routes) == 0 { + return + } + s.deps.feeLedgerAccounts = make(map[string]string, len(routes)) + for k, v := range routes { + key := strings.ToLower(strings.TrimSpace(k)) + val := strings.TrimSpace(v) + if key == "" || val == "" { + continue + } + s.deps.feeLedgerAccounts[key] = val + } + } +} + +// 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 { + return func(s *Service) { + if registry != nil { + s.deps.gatewayRegistry = registry + s.deps.railGateways.registry = registry + 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) + } + } + } +} + +// WithClock overrides the default clock. +func WithClock(clock clockpkg.Clock) Option { + return func(s *Service) { + if clock != nil { + s.clock = clock + } + } +} + +func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainResolver GatewayInvokeResolver, providerResolver GatewayInvokeResolver, logger mlogger.Logger) railGatewayDependency { + result := railGatewayDependency{ + byID: map[string]rail.RailGateway{}, + byRail: map[model.Rail][]rail.RailGateway{}, + registry: registry, + chainResolver: chainResolver, + providerResolver: providerResolver, + logger: logger, + } + if len(gateways) == 0 { + return result + } + + type item struct { + id string + gw rail.RailGateway + } + itemsByRail := map[model.Rail][]item{} + + for id, gw := range gateways { + cleanID := strings.TrimSpace(id) + if cleanID == "" || gw == nil { + continue + } + result.byID[cleanID] = gw + railID := parseRailValue(gw.Rail()) + if railID == model.RailUnspecified { + continue + } + itemsByRail[railID] = append(itemsByRail[railID], item{id: cleanID, gw: gw}) + } + + for railID, items := range itemsByRail { + sort.Slice(items, func(i, j int) bool { + return items[i].id < items[j].id + }) + for _, entry := range items { + result.byRail[railID] = append(result.byRail[railID], entry.gw) + } + } + + return result +} diff --git a/api/payments/quotation/internal/service/orchestrator/payment_executor.go b/api/payments/quotation/internal/service/orchestrator/payment_executor.go new file mode 100644 index 00000000..c6e09490 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_executor.go @@ -0,0 +1,237 @@ +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 new file mode 100644 index 00000000..0e19e24b --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_analyzer.go @@ -0,0 +1,123 @@ +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 new file mode 100644 index 00000000..2a1fb32a --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_card.go @@ -0,0 +1,196 @@ +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 new file mode 100644 index 00000000..b495a7c9 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_chain.go @@ -0,0 +1,116 @@ +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 new file mode 100644 index 00000000..f90780ad --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_executor.go @@ -0,0 +1,208 @@ +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_helpers.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_helpers.go new file mode 100644 index 00000000..1e590775 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_helpers.go @@ -0,0 +1,215 @@ +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/quotation/internal/service/orchestrator/payment_plan_ledger.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_ledger.go new file mode 100644 index 00000000..594cafa5 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_ledger.go @@ -0,0 +1,596 @@ +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_order.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_order.go new file mode 100644 index 00000000..064f7971 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_order.go @@ -0,0 +1,226 @@ +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/quotation/internal/service/orchestrator/payment_plan_release.go b/api/payments/quotation/internal/service/orchestrator/payment_plan_release.go new file mode 100644 index 00000000..d0362fb8 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_release.go @@ -0,0 +1,50 @@ +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 new file mode 100644 index 00000000..37272a3e --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/payment_plan_steps.go @@ -0,0 +1,446 @@ +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/plan_builder.go b/api/payments/quotation/internal/service/orchestrator/plan_builder.go new file mode 100644 index 00000000..40b8414b --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/plan_builder.go @@ -0,0 +1,28 @@ +package orchestrator + +import ( + "context" + + "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) +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_default.go b/api/payments/quotation/internal/service/orchestrator/plan_builder_default.go new file mode 100644 index 00000000..ba322676 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/plan_builder_default.go @@ -0,0 +1,102 @@ +package orchestrator + +import ( + "context" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mutil/mzap" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" +) + +type defaultPlanBuilder struct { + logger mlogger.Logger +} + +func newDefaultPlanBuilder(logger mlogger.Logger) *defaultPlanBuilder { + return &defaultPlanBuilder{ + 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) { + if payment == nil { + return nil, merrors.InvalidArgument("plan builder: payment is required") + } + if routes == nil { + return nil, merrors.InvalidArgument("plan builder: routes store is required") + } + if templates == nil { + return nil, merrors.InvalidArgument("plan builder: plan templates store is required") + } + + logger := b.logger.With( + zap.String("payment_ref", payment.PaymentRef), + zap.String("payment_kind", string(payment.Intent.Kind)), + ) + logger.Debug("Building payment plan") + + intent := payment.Intent + if intent.Kind == model.PaymentKindFXConversion { + logger.Debug("Building fx conversion plan") + plan, err := buildFXConversionPlan(payment) + if err != nil { + logger.Warn("Failed to build fx conversion plan", zap.Error(err)) + return nil, err + } + logger.Info("fx conversion plan built", zap.Int("steps", len(plan.Steps))) + return plan, nil + } + + sourceRail, sourceNetwork, err := railFromEndpoint(intent.Source, intent.Attributes, true) + if err != nil { + logger.Warn("Failed to resolve source rail", zap.Error(err)) + return nil, err + } + destRail, destNetwork, err := railFromEndpoint(intent.Destination, intent.Attributes, false) + if err != nil { + logger.Warn("Failed to resolve destination rail", zap.Error(err)) + return nil, err + } + + logger = logger.With( + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destRail)), + zap.String("source_network", sourceNetwork), + zap.String("dest_network", destNetwork), + ) + + if sourceRail == model.RailUnspecified || destRail == model.RailUnspecified { + logger.Warn("Source and destination rails are required") + return nil, merrors.InvalidArgument("plan builder: source and destination rails are required") + } + if sourceRail == destRail && sourceRail != model.RailLedger { + logger.Warn("Unsupported same-rail payment") + return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment") + } + + network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) + if err != nil { + logger.Warn("Failed to resolve route network", zap.Error(err)) + return nil, err + } + logger = logger.With(zap.String("network", network)) + + route, err := selectRoute(ctx, routes, sourceRail, destRail, network) + if err != nil { + logger.Warn("Failed to select route", zap.Error(err)) + return nil, err + } + logger.Debug("Route selected", mzap.StorableRef(route)) + + template, err := selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network) + if err != nil { + logger.Warn("Failed to select plan template", zap.Error(err)) + return nil, err + } + logger.Debug("Plan template selected", mzap.StorableRef(template)) + + return b.buildPlanFromTemplate(ctx, payment, quote, template, sourceRail, destRail, sourceNetwork, destNetwork, gateways) +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_endpoints.go b/api/payments/quotation/internal/service/orchestrator/plan_builder_endpoints.go new file mode 100644 index 00000000..d825937d --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/plan_builder_endpoints.go @@ -0,0 +1,101 @@ +package orchestrator + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { + override := railOverrideFromAttributes(attrs, isSource) + if override != model.RailUnspecified { + return override, networkFromEndpoint(endpoint), nil + } + switch endpoint.Type { + case model.EndpointTypeLedger: + return model.RailLedger, "", nil + case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain: + return model.RailCrypto, networkFromEndpoint(endpoint), nil + case model.EndpointTypeCard: + return model.RailCardPayout, "", nil + default: + return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint") + } +} + +func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail { + if len(attrs) == 0 { + return model.RailUnspecified + } + keys := []string{"source_rail", "sourceRail"} + if !isSource { + keys = []string{"destination_rail", "destinationRail"} + } + lookup := map[string]struct{}{} + for _, key := range keys { + lookup[strings.ToLower(key)] = struct{}{} + } + for key, value := range attrs { + if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok { + continue + } + rail := parseRailValue(value) + if rail != model.RailUnspecified { + return rail + } + } + return model.RailUnspecified +} + +func parseRailValue(value string) model.Rail { + val := strings.ToUpper(strings.TrimSpace(value)) + switch val { + case string(model.RailCrypto): + return model.RailCrypto + case string(model.RailProviderSettlement): + return model.RailProviderSettlement + case string(model.RailLedger): + return model.RailLedger + case string(model.RailCardPayout): + return model.RailCardPayout + case string(model.RailFiatOnRamp): + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} + +func gatewayNetworkForRail(rail model.Rail, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string { + switch rail { + case model.RailCrypto: + if sourceRail == model.RailCrypto { + return strings.ToUpper(strings.TrimSpace(sourceNetwork)) + } + if destRail == model.RailCrypto { + return strings.ToUpper(strings.TrimSpace(destNetwork)) + } + case model.RailFiatOnRamp: + if sourceRail == model.RailFiatOnRamp { + return strings.ToUpper(strings.TrimSpace(sourceNetwork)) + } + if destRail == model.RailFiatOnRamp { + return strings.ToUpper(strings.TrimSpace(destNetwork)) + } + } + return "" +} + +func networkFromEndpoint(endpoint model.PaymentEndpoint) string { + switch endpoint.Type { + case model.EndpointTypeManagedWallet: + if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil { + return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain())) + } + case model.EndpointTypeExternalChain: + if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil { + return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain())) + } + } + return "" +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_gateways.go b/api/payments/quotation/internal/service/orchestrator/plan_builder_gateways.go new file mode 100644 index 00000000..8d3f573d --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/plan_builder_gateways.go @@ -0,0 +1,269 @@ +package orchestrator + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.uber.org/zap" +) + +func ensureGatewayForAction(ctx context.Context, logger mlogger.Logger, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { + if registry == nil { + return nil, merrors.InvalidArgument("plan builder: gateway registry is required") + } + if gw, ok := cache[rail]; ok && gw != nil { + if instanceID == "" || strings.EqualFold(gw.InstanceID, instanceID) { + if err := validateGatewayAction(gw, network, amount, action, dir); err != nil { + logger.Warn("Failed to validate gateway", zap.Error(err), + zap.String("instance_id", instanceID), zap.String("rail", string(rail)), + zap.String("network", network), zap.String("action", string(action)), + zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)), + ) + return nil, err + } + return gw, nil + } + } + gw, err := selectGateway(ctx, registry, rail, network, amount, action, instanceID, dir) + if err != nil { + logger.Warn("Failed to select gateway", zap.Error(err), + zap.String("instance_id", instanceID), zap.String("rail", string(rail)), + zap.String("network", network), zap.String("action", string(action)), + zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)), + ) + return nil, err + } + cache[rail] = gw + return gw, nil +} + +func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) error { + if gw == nil { + return merrors.InvalidArgument("plan builder: gateway instance is required") + } + currency := "" + amt := decimal.Zero + if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" { + value, err := decimalFromMoney(amount) + if err != nil { + return err + } + amt = value + currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + } + if err := isGatewayEligible(gw, gw.Rail, network, currency, action, dir, amt); err != nil { + return merrors.InvalidArgument("plan builder: gateway instance is not eligible: " + err.Error()) + } + return nil +} + +type sendDirection int + +const ( + sendDirectionAny sendDirection = iota + sendDirectionOut + sendDirectionIn +) + +func sendDirectionForRail(rail model.Rail) sendDirection { + switch rail { + case model.RailFiatOnRamp: + return sendDirectionIn + default: + return sendDirectionOut + } +} + +func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { + if registry == nil { + return nil, merrors.InvalidArgument("plan builder: gateway registry is required") + } + all, err := registry.List(ctx) + if err != nil { + return nil, err + } + if len(all) == 0 { + return nil, merrors.InvalidArgument("plan builder: no gateway instances available") + } + + currency := "" + amt := decimal.Zero + if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" { + amt, err = decimalFromMoney(amount) + if err != nil { + return nil, err + } + currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + } + network = strings.ToUpper(strings.TrimSpace(network)) + + eligible := make([]*model.GatewayInstanceDescriptor, 0) + var lastErr error + for _, gw := range all { + if instanceID != "" && !strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) { + continue + } + if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil { + lastErr = err + continue + } + eligible = append(eligible, gw) + } + if len(eligible) == 0 { + if lastErr != nil { + return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: " + lastErr.Error()) + } + return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found") + } + sort.Slice(eligible, func(i, j int) bool { + return eligible[i].ID < eligible[j].ID + }) + return eligible[0], 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 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 +} + +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) +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_plans.go b/api/payments/quotation/internal/service/orchestrator/plan_builder_plans.go new file mode 100644 index 00000000..b6e3b073 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/plan_builder_plans.go @@ -0,0 +1,77 @@ +package orchestrator + +import ( + "time" + + "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" +) + +func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) { + if payment == nil { + return nil, merrors.InvalidArgument("plan builder: payment is required") + } + step := &model.PaymentStep{ + StepID: "fx_convert", + Rail: model.RailLedger, + Action: model.RailOperationFXConvert, + CommitPolicy: model.CommitPolicyImmediate, + Amount: cloneMoney(payment.Intent.Amount), + } + return &model.PaymentPlan{ + ID: payment.PaymentRef, + Steps: []*model.PaymentStep{step}, + IdempotencyKey: payment.IdempotencyKey, + CreatedAt: planTimestamp(payment), + }, nil +} + +func resolveSettlementAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money { + if quote != nil && quote.GetExpectedSettlementAmount() != nil { + return moneyFromProto(quote.GetExpectedSettlementAmount()) + } + if payment != nil && payment.LastQuote != nil && payment.LastQuote.ExpectedSettlementAmount != nil { + return cloneMoney(payment.LastQuote.ExpectedSettlementAmount) + } + return cloneMoney(fallback) +} + +func resolveDebitAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money { + if quote != nil && quote.GetDebitAmount() != nil { + return moneyFromProto(quote.GetDebitAmount()) + } + if payment != nil && payment.LastQuote != nil && payment.LastQuote.DebitAmount != nil { + return cloneMoney(payment.LastQuote.DebitAmount) + } + return cloneMoney(fallback) +} + +func resolveFeeAmount(payment *model.Payment, quote *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 isPositiveMoney(amount *paymenttypes.Money) bool { + if amount == nil { + return false + } + val, err := decimalFromMoney(amount) + if err != nil { + return false + } + return val.IsPositive() +} + +func planTimestamp(payment *model.Payment) time.Time { + if payment != nil && !payment.CreatedAt.IsZero() { + return payment.CreatedAt.UTC() + } + return time.Now().UTC() +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_routes.go b/api/payments/quotation/internal/service/orchestrator/plan_builder_routes.go new file mode 100644 index 00000000..de979ece --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/plan_builder_routes.go @@ -0,0 +1,121 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { + src := strings.ToUpper(strings.TrimSpace(sourceNetwork)) + dst := strings.ToUpper(strings.TrimSpace(destNetwork)) + if src != "" && dst != "" && !strings.EqualFold(src, dst) { + return "", merrors.InvalidArgument("plan builder: source and destination networks mismatch") + } + + override := strings.ToUpper(strings.TrimSpace(attributeLookup(attrs, + "network", + "route_network", + "routeNetwork", + "source_network", + "sourceNetwork", + "destination_network", + "destinationNetwork", + ))) + if override != "" { + if src != "" && !strings.EqualFold(src, override) { + return "", merrors.InvalidArgument("plan builder: source network does not match override") + } + if dst != "" && !strings.EqualFold(dst, override) { + return "", merrors.InvalidArgument("plan builder: destination network does not match override") + } + return override, nil + } + if src != "" { + return src, nil + } + if dst != "" { + return dst, nil + } + return "", nil +} + +func selectRoute(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, network string) (*model.PaymentRoute, error) { + if routes == nil { + return nil, merrors.InvalidArgument("plan builder: routes store is required") + } + enabled := true + result, err := routes.List(ctx, &model.PaymentRouteFilter{ + FromRail: sourceRail, + ToRail: destRail, + Network: "", + IsEnabled: &enabled, + }) + if err != nil { + return nil, err + } + if result == nil || len(result.Items) == 0 { + return nil, merrors.InvalidArgument("plan builder: route not allowed") + } + candidates := make([]*model.PaymentRoute, 0, len(result.Items)) + for _, route := range result.Items { + if route == nil || !route.IsEnabled { + continue + } + if route.FromRail != sourceRail || route.ToRail != destRail { + continue + } + if !routeMatchesNetwork(route, network) { + continue + } + candidates = append(candidates, route) + } + if len(candidates) == 0 { + return nil, merrors.InvalidArgument("plan builder: route not allowed") + } + sort.Slice(candidates, func(i, j int) bool { + pi := routePriority(candidates[i], network) + pj := routePriority(candidates[j], network) + if pi != pj { + return pi < pj + } + if candidates[i].Network != candidates[j].Network { + return candidates[i].Network < candidates[j].Network + } + return candidates[i].ID.Hex() < candidates[j].ID.Hex() + }) + return candidates[0], nil +} + +func routeMatchesNetwork(route *model.PaymentRoute, network string) bool { + if route == nil { + return false + } + routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) + net := strings.ToUpper(strings.TrimSpace(network)) + if routeNetwork == "" { + return true + } + if net == "" { + return false + } + return strings.EqualFold(routeNetwork, net) +} + +func routePriority(route *model.PaymentRoute, network string) int { + if route == nil { + return 2 + } + routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) + net := strings.ToUpper(strings.TrimSpace(network)) + if net != "" && strings.EqualFold(routeNetwork, net) { + return 0 + } + if routeNetwork == "" { + return 1 + } + return 2 +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_steps.go b/api/payments/quotation/internal/service/orchestrator/plan_builder_steps.go new file mode 100644 index 00000000..c9460f41 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/plan_builder_steps.go @@ -0,0 +1,453 @@ +package orchestrator + +import ( + "context" + "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/mutil/mzap" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "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) { + if template == nil { + return nil, merrors.InvalidArgument("plan builder: plan template is required") + } + + logger := b.logger.With( + zap.String("payment_ref", payment.PaymentRef), + mzap.ObjRef("template_id", template.ID), + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destRail)), + ) + logger.Debug("Building plan from template", zap.Int("template_steps", len(template.Steps))) + + intentAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount") + if err != nil { + logger.Warn("Invalid intent amount", zap.Error(err)) + return nil, err + } + sourceAmount, err := requireMoney(resolveDebitAmount(payment, quote, intentAmount), "debit amount") + if err != nil { + logger.Warn("Failed to resolve debit amount", zap.Error(err)) + return nil, err + } + settlementAmount, err := requireMoney(resolveSettlementAmount(payment, quote, sourceAmount), "settlement amount") + if err != nil { + logger.Warn("Failed to resolve settlement amount", zap.Error(err)) + return nil, err + } + feeAmount := resolveFeeAmount(payment, quote) + feeRequired := isPositiveMoney(feeAmount) + sourceSendAmount, err := netSourceAmount(sourceAmount, feeAmount, quote) + if err != nil { + logger.Warn("Failed to calculate net source amount", zap.Error(err)) + return nil, err + } + providerSettlementAmount := settlementAmount + if payment.Intent.SettlementMode == model.SettlementModeFixReceived && feeRequired { + providerSettlementAmount, err = netSettlementAmount(settlementAmount, feeAmount, quote) + if err != nil { + logger.Warn("Failed to calculate net settlement amount", zap.Error(err)) + return nil, err + } + } + + logger.Debug("Amounts calculated", + zap.String("intent_amount", moneyString(intentAmount)), + zap.String("source_amount", moneyString(sourceAmount)), + zap.String("settlement_amount", moneyString(settlementAmount)), + zap.String("fee_amount", moneyString(feeAmount)), + zap.Bool("fee_required", feeRequired), + ) + + payoutAmount := settlementAmount + if destRail == model.RailCardPayout { + payoutAmount, err = cardPayoutAmount(payment) + if err != nil { + logger.Warn("Failed to calculate card payout amount", zap.Error(err)) + return nil, err + } + } + + ledgerCreditAmount := settlementAmount + ledgerDebitAmount := settlementAmount + if destRail == model.RailCardPayout && payoutAmount != nil { + ledgerDebitAmount = payoutAmount + } + + steps := make([]*model.PaymentStep, 0, len(template.Steps)) + gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{} + stepIDs := map[string]bool{} + sourceManagedWalletNetwork := "" + destManagedWalletNetwork := "" + if payment.Intent.Source.Type == model.EndpointTypeManagedWallet { + sourceManagedWalletNetwork = networkFromEndpoint(payment.Intent.Source) + } + if payment.Intent.Destination.Type == model.EndpointTypeManagedWallet { + destManagedWalletNetwork = networkFromEndpoint(payment.Intent.Destination) + } + + for _, tpl := range template.Steps { + stepID := strings.TrimSpace(tpl.StepID) + if stepID == "" { + return nil, merrors.InvalidArgument("plan builder: plan template step id is required") + } + if stepIDs[stepID] { + return nil, merrors.InvalidArgument("plan builder: plan template step id must be unique") + } + stepIDs[stepID] = true + + action, err := actionForOperation(tpl.Operation) + if err != nil { + b.logger.Warn("plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err)) + return nil, err + } + + amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired) + if err != nil { + return nil, err + } + if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement { + amount = cloneMoney(providerSettlementAmount) + } + if amount == nil && + action != model.RailOperationObserveConfirm && + action != model.RailOperationFee { + logger.Warn("Plan template step has no amount for action, skipping", + zap.String("step_id", stepID), zap.String("action", string(action))) + continue + } + + policy := tpl.CommitPolicy + if strings.TrimSpace(string(policy)) == "" { + policy = model.CommitPolicyImmediate + } + step := &model.PaymentStep{ + StepID: stepID, + Rail: tpl.Rail, + Action: action, + DependsOn: cloneStringList(tpl.DependsOn), + CommitPolicy: policy, + CommitAfter: cloneStringList(tpl.CommitAfter), + Amount: cloneMoney(amount), + FromRole: cloneAccountRole(tpl.FromRole), + ToRole: cloneAccountRole(tpl.ToRole), + } + + needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm + if (action == model.RailOperationBlock || action == model.RailOperationRelease) && tpl.Rail != model.RailLedger { + needsGateway = true + } + if needsGateway { + network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork) + managedWalletNetwork := "" + if tpl.Rail == sourceRail && sourceManagedWalletNetwork != "" { + managedWalletNetwork = sourceManagedWalletNetwork + } else if tpl.Rail == destRail && destManagedWalletNetwork != "" { + managedWalletNetwork = destManagedWalletNetwork + } + if managedWalletNetwork != "" { + logger.Debug("Managed wallet network resolved for gateway selection", + zap.String("step_id", stepID), + zap.String("rail", string(tpl.Rail)), + zap.String("managed_wallet_network", managedWalletNetwork), + zap.String("gateway_network", network), + ) + } + instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail) + checkAmount := amount + if action == model.RailOperationObserveConfirm { + checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount) + if tpl.Rail == model.RailProviderSettlement { + checkAmount = cloneMoney(providerSettlementAmount) + } + } + gw, err := ensureGatewayForAction(ctx, b.logger, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail)) + if err != nil { + logger.Warn("Failed to ensure gateway for plan step", zap.Error(err), + zap.String("step_id", stepID), zap.String("rail", string(tpl.Rail)), + zap.String("gateway_network", network), zap.String("managed_wallet_network", managedWalletNetwork), + zap.Int("gateways_by_rail_count", len(gatewaysByRail)), + ) + return nil, err + } + step.GatewayID = strings.TrimSpace(gw.ID) + step.InstanceID = strings.TrimSpace(gw.InstanceID) + } + + logger.Debug("Plan step added", + zap.String("step_id", step.StepID), + zap.String("rail", string(step.Rail)), + zap.String("action", string(step.Action)), + zap.String("commit_policy", string(step.CommitPolicy)), + zap.String("amount", moneyString(step.Amount)), + zap.Strings("depends_on", step.DependsOn), + ) + steps = append(steps, step) + } + + if len(steps) == 0 { + logger.Warn("Empty payment plan after processing template") + return nil, merrors.InvalidArgument("plan builder: empty payment plan") + } + + execQuote := executionQuote(payment, quote) + plan := &model.PaymentPlan{ + ID: payment.PaymentRef, + FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()), + Fees: feeLinesFromProto(execQuote.GetFeeLines()), + Steps: steps, + IdempotencyKey: payment.IdempotencyKey, + CreatedAt: planTimestamp(payment), + } + + logger.Info("Payment plan built", zap.Int("steps", len(plan.Steps)), + zap.Int("fees", len(plan.Fees)), zap.Bool("has_fx_quote", plan.FXQuote != nil), + ) + return plan, nil +} + +func moneyString(m *paymenttypes.Money) string { + if m == nil { + return "nil" + } + return m.Amount + " " + m.Currency +} + +func actionForOperation(operation string) (model.RailOperation, error) { + op := strings.ToLower(strings.TrimSpace(operation)) + if op == "ledger.block" || op == "ledger.release" { + return model.RailOperationUnspecified, merrors.InvalidArgument("unsupported legacy ledger operation, use ledger.move with roles") + } + switch op { + case "ledger.move": + return model.RailOperationMove, nil + case "external.debit": + return model.RailOperationExternalDebit, nil + case "external.credit": + return model.RailOperationExternalCredit, nil + case "debit", "wallet.debit": + return model.RailOperationExternalDebit, nil + case "credit", "wallet.credit": + return model.RailOperationExternalCredit, nil + case "fx.convert", "fx_conversion", "fx.converted": + return model.RailOperationFXConvert, nil + case "observe", "observe.confirm", "observe.confirmation", "observe.crypto", "observe.card": + return model.RailOperationObserveConfirm, nil + case "fee", "fee.send": + return model.RailOperationFee, nil + case "send", "payout.card", "payout.crypto", "payout.fiat", "payin.crypto", "payin.fiat", "fund.crypto", "fund.card": + return model.RailOperationSend, nil + case "block", "hold", "reserve": + return model.RailOperationBlock, nil + case "release", "unblock": + return model.RailOperationRelease, nil + } + + switch strings.ToUpper(strings.TrimSpace(operation)) { + case string(model.RailOperationExternalDebit), string(model.RailOperationDebit): + return model.RailOperationExternalDebit, nil + case string(model.RailOperationExternalCredit), string(model.RailOperationCredit): + return model.RailOperationExternalCredit, nil + case string(model.RailOperationMove): + return model.RailOperationMove, nil + case string(model.RailOperationSend): + return model.RailOperationSend, nil + case string(model.RailOperationFee): + return model.RailOperationFee, nil + case string(model.RailOperationObserveConfirm): + return model.RailOperationObserveConfirm, nil + case string(model.RailOperationFXConvert): + return model.RailOperationFXConvert, nil + case string(model.RailOperationBlock): + return model.RailOperationBlock, nil + case string(model.RailOperationRelease): + return model.RailOperationRelease, nil + } + + return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation") +} + +func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) { + switch action { + case model.RailOperationDebit, model.RailOperationExternalDebit: + if rail == model.RailLedger { + return cloneMoney(ledgerDebitAmount), nil + } + return cloneMoney(settlementAmount), nil + case model.RailOperationCredit, model.RailOperationExternalCredit: + if rail == model.RailLedger { + return cloneMoney(ledgerCreditAmount), nil + } + return cloneMoney(settlementAmount), nil + case model.RailOperationMove: + if rail == model.RailLedger { + return cloneMoney(ledgerDebitAmount), nil + } + return cloneMoney(settlementAmount), nil + case model.RailOperationSend: + switch rail { + case sourceRail: + return cloneMoney(sourceSendAmount), nil + case destRail: + return cloneMoney(payoutAmount), nil + default: + return cloneMoney(settlementAmount), nil + } + case model.RailOperationBlock, model.RailOperationRelease: + if rail == model.RailLedger { + return cloneMoney(ledgerDebitAmount), nil + } + return cloneMoney(settlementAmount), nil + case model.RailOperationFee: + if !feeRequired { + return nil, nil + } + return cloneMoney(feeAmount), nil + case model.RailOperationObserveConfirm: + return nil, nil + case model.RailOperationFXConvert: + return cloneMoney(settlementAmount), nil + default: + return nil, merrors.InvalidArgument("plan builder: unsupported action") + } +} + +func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string { + if rail == sourceRail { + return strings.TrimSpace(intent.Source.InstanceID) + } + if rail == destRail { + return strings.TrimSpace(intent.Destination.InstanceID) + } + return "" +} + +func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money { + switch rail { + case model.RailCrypto, model.RailFiatOnRamp: + if source != nil { + return source + } + if settlement != nil { + return settlement + } + case model.RailProviderSettlement: + if settlement != nil { + return settlement + } + case model.RailCardPayout: + if payout != nil { + return payout + } + } + if settlement != nil { + return settlement + } + return source +} + +func 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") + } + netAmount := cloneMoney(sourceAmount) + if !isPositiveMoney(feeAmount) { + return netAmount, nil + } + + currency := strings.TrimSpace(sourceAmount.GetCurrency()) + if currency == "" { + return netAmount, nil + } + var fxQuote *oraclev1.Quote + if quote != nil { + fxQuote = quote.GetFxQuote() + } + convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote) + if err != nil { + return nil, err + } + if convertedFee == nil { + return netAmount, nil + } + sourceValue, err := decimalFromMoney(sourceAmount) + if err != nil { + return nil, err + } + feeValue, err := decimalFromMoney(convertedFee) + if err != nil { + return nil, err + } + netValue := sourceValue.Sub(feeValue) + if netValue.IsNegative() { + return nil, merrors.InvalidArgument("plan builder: fee exceeds source amount") + } + return &paymenttypes.Money{ + Currency: currency, + Amount: netValue.String(), + }, nil +} + +func netSettlementAmount(settlementAmount, feeAmount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote) (*paymenttypes.Money, error) { + if settlementAmount == nil { + return nil, merrors.InvalidArgument("plan builder: settlement amount is required") + } + netAmount := cloneMoney(settlementAmount) + if !isPositiveMoney(feeAmount) { + return netAmount, nil + } + + currency := strings.TrimSpace(settlementAmount.GetCurrency()) + if currency == "" { + return netAmount, nil + } + var fxQuote *oraclev1.Quote + if quote != nil { + fxQuote = quote.GetFxQuote() + } + convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote) + if err != nil { + return nil, err + } + if convertedFee == nil { + return netAmount, nil + } + settlementValue, err := decimalFromMoney(settlementAmount) + if err != nil { + return nil, err + } + feeValue, err := decimalFromMoney(convertedFee) + if err != nil { + return nil, err + } + netValue := settlementValue.Sub(feeValue) + if netValue.IsNegative() { + return nil, merrors.InvalidArgument("plan builder: fee exceeds settlement amount") + } + return &paymenttypes.Money{ + Currency: currency, + Amount: netValue.String(), + }, nil +} + +func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) { + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("plan builder: " + label + " is required") + } + return amount, nil +} diff --git a/api/payments/quotation/internal/service/orchestrator/plan_builder_templates.go b/api/payments/quotation/internal/service/orchestrator/plan_builder_templates.go new file mode 100644 index 00000000..bbaed284 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/plan_builder_templates.go @@ -0,0 +1,205 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mutil/mzap" + "go.uber.org/zap" +) + +func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { + if templates == nil { + return nil, merrors.InvalidArgument("plan builder: plan templates store is required") + } + + logger = logger.With( + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destRail)), + zap.String("network", network), + ) + logger.Debug("Selecting plan template") + + enabled := true + result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{ + FromRail: sourceRail, + ToRail: destRail, + IsEnabled: &enabled, + }) + if err != nil { + logger.Warn("Failed to list plan templates", zap.Error(err)) + return nil, err + } + if result == nil || len(result.Items) == 0 { + logger.Warn("No plan templates found for route") + return nil, merrors.InvalidArgument("plan builder: plan template missing") + } + + logger.Debug("Fetched plan templates", zap.Int("total", len(result.Items))) + + candidates := make([]*model.PaymentPlanTemplate, 0, len(result.Items)) + for _, tpl := range result.Items { + if tpl == nil || !tpl.IsEnabled { + continue + } + if tpl.FromRail != sourceRail || tpl.ToRail != destRail { + continue + } + if !templateMatchesNetwork(tpl, network) { + logger.Debug("Template network mismatch, skipping", + mzap.StorableRef(tpl), + zap.String("template_network", tpl.Network)) + continue + } + if err := validatePlanTemplate(logger, tpl); err != nil { + return nil, err + } + candidates = append(candidates, tpl) + } + if len(candidates) == 0 { + logger.Warn("No valid plan template candidates after filtering") + return nil, merrors.InvalidArgument("plan builder: plan template missing") + } + + logger.Debug("Plan template candidates filtered", zap.Int("candidates", len(candidates))) + + sort.Slice(candidates, func(i, j int) bool { + pi := templatePriority(candidates[i], network) + pj := templatePriority(candidates[j], network) + if pi != pj { + return pi < pj + } + if candidates[i].Network != candidates[j].Network { + return candidates[i].Network < candidates[j].Network + } + return candidates[i].ID.Hex() < candidates[j].ID.Hex() + }) + + selected := candidates[0] + logger.Debug("Plan template selected", + mzap.StorableRef(selected), + zap.String("template_network", selected.Network), + zap.Int("steps", len(selected.Steps)), + zap.Int("priority", templatePriority(selected, network))) + + return selected, nil +} + +func templateMatchesNetwork(template *model.PaymentPlanTemplate, network string) bool { + if template == nil { + return false + } + templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) + net := strings.ToUpper(strings.TrimSpace(network)) + if templateNetwork == "" { + return true + } + if net == "" { + return false + } + return strings.EqualFold(templateNetwork, net) +} + +func templatePriority(template *model.PaymentPlanTemplate, network string) int { + if template == nil { + return 2 + } + templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) + net := strings.ToUpper(strings.TrimSpace(network)) + if net != "" && strings.EqualFold(templateNetwork, net) { + return 0 + } + if templateNetwork == "" { + return 1 + } + return 2 +} + +func validatePlanTemplate(logger mlogger.Logger, template *model.PaymentPlanTemplate) error { + if template == nil { + return merrors.InvalidArgument("plan builder: plan template is required") + } + + logger = logger.With( + mzap.StorableRef(template), + zap.String("from_rail", string(template.FromRail)), + zap.String("to_rail", string(template.ToRail)), + zap.String("network", template.Network), + ) + logger.Debug("Validating plan template") + + if len(template.Steps) == 0 { + logger.Warn("Plan template has no steps") + return merrors.InvalidArgument("plan builder: plan template steps are required") + } + + seen := map[string]struct{}{} + for idx, step := range template.Steps { + id := strings.TrimSpace(step.StepID) + if id == "" { + logger.Warn("Plan template step missing ID", zap.Int("step_index", idx)) + return merrors.InvalidArgument("plan builder: plan template step id is required") + } + if _, exists := seen[id]; exists { + logger.Warn("Duplicate plan template step ID", zap.String("step_id", id)) + return merrors.InvalidArgument("plan builder: plan template step id must be unique") + } + seen[id] = struct{}{} + if strings.TrimSpace(step.Operation) == "" { + logger.Warn("Plan template step missing operation", zap.String("step_id", id), + zap.Int("step_index", idx)) + return merrors.InvalidArgument("plan builder: plan template operation is required") + } + action, err := actionForOperation(step.Operation) + if err != nil { + logger.Warn("Plan template step has invalid operation", zap.String("step_id", id), + zap.String("operation", step.Operation), zap.Error(err)) + return err + } + if step.Rail == model.RailLedger && action == model.RailOperationMove { + if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" { + logger.Warn("Ledger move step missing fromRole", zap.String("step_id", id), + zap.String("operation", step.Operation)) + return merrors.InvalidArgument("plan builder: ledger.move fromRole is required") + } + if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" { + logger.Warn("Ledger move step missing toRole", zap.String("step_id", id), + zap.String("operation", step.Operation)) + return merrors.InvalidArgument("plan builder: ledger.move toRole is required") + } + from := strings.ToLower(strings.TrimSpace(string(*step.FromRole))) + to := strings.ToLower(strings.TrimSpace(string(*step.ToRole))) + if from == "" || to == "" || strings.EqualFold(from, to) { + logger.Warn("Ledger move step has invalid roles", zap.String("step_id", id), + zap.String("from_role", from), zap.String("to_role", to)) + return merrors.InvalidArgument("plan builder: ledger.move fromRole and toRole must differ") + } + } + } + + for _, step := range template.Steps { + for _, dep := range step.DependsOn { + depID := strings.TrimSpace(dep) + if _, ok := seen[depID]; !ok { + logger.Warn("Plan template step has missing dependency", zap.String("step_id", step.StepID), + zap.String("missing_dependency", depID)) + return merrors.InvalidArgument("plan builder: plan template dependency missing") + } + } + for _, dep := range step.CommitAfter { + depID := strings.TrimSpace(dep) + if _, ok := seen[depID]; !ok { + logger.Warn("Plan template step has missing commit dependency", zap.String("step_id", step.StepID), + zap.String("missing_commit_dependency", depID)) + return merrors.InvalidArgument("plan builder: plan template commit dependency missing") + } + } + } + + logger.Debug("Plan template validation successful", zap.Int("steps", len(template.Steps))) + return nil +} diff --git a/api/payments/quotation/internal/service/orchestrator/provider_settlement.go b/api/payments/quotation/internal/service/orchestrator/provider_settlement.go new file mode 100644 index 00000000..96ba0f55 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/provider_settlement.go @@ -0,0 +1,132 @@ +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/provider_settlement_gateway.go b/api/payments/quotation/internal/service/orchestrator/provider_settlement_gateway.go new file mode 100644 index 00000000..dbdc8093 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/provider_settlement_gateway.go @@ -0,0 +1,180 @@ +package orchestrator + +import ( + "context" + "strings" + + chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/payments/rail" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +type providerSettlementGateway struct { + client chainclient.Client + rail string + network string + capabilities rail.RailCapabilities +} + +func NewProviderSettlementGateway(client chainclient.Client, cfg chainclient.RailGatewayConfig) rail.RailGateway { + railName := strings.ToUpper(strings.TrimSpace(cfg.Rail)) + if railName == "" { + railName = "PROVIDER_SETTLEMENT" + } + return &providerSettlementGateway{ + client: client, + rail: railName, + network: strings.ToUpper(strings.TrimSpace(cfg.Network)), + capabilities: cfg.Capabilities, + } +} + +func (g *providerSettlementGateway) Rail() string { + return g.rail +} + +func (g *providerSettlementGateway) Network() string { + return g.network +} + +func (g *providerSettlementGateway) Capabilities() rail.RailCapabilities { + return g.capabilities +} + +func (g *providerSettlementGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { + if g.client == nil { + return rail.RailResult{}, merrors.Internal("provider settlement gateway: client is required") + } + idempotencyKey := strings.TrimSpace(req.IdempotencyKey) + if idempotencyKey == "" { + return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: idempotency_key is required") + } + currency := strings.TrimSpace(req.Currency) + amount := strings.TrimSpace(req.Amount) + if currency == "" || amount == "" { + return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: amount is required") + } + metadata := cloneMetadata(req.Metadata) + if metadata == nil { + metadata = map[string]string{} + } + if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { + if ref := strings.TrimSpace(req.PaymentRef); ref != "" { + metadata[providerSettlementMetaPaymentIntentID] = ref + } + } + if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { + return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: payment_intent_id is required") + } + if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" && g.rail != "" { + metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(g.rail)) + } + submitReq := &chainv1.SubmitTransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: strings.TrimSpace(req.OrganizationRef), + SourceWalletRef: strings.TrimSpace(req.FromAccountID), + Amount: &moneyv1.Money{ + Currency: currency, + Amount: amount, + }, + Metadata: metadata, + PaymentRef: strings.TrimSpace(req.PaymentRef), + IntentRef: req.IntentRef, + OperationRef: req.OperationRef, + } + if dest := buildProviderSettlementDestination(req); dest != nil { + submitReq.Destination = dest + } + resp, err := g.client.SubmitTransfer(ctx, submitReq) + if err != nil { + return rail.RailResult{}, err + } + if resp == nil || resp.GetTransfer() == nil { + return rail.RailResult{}, merrors.Internal("provider settlement gateway: missing transfer response") + } + transfer := resp.GetTransfer() + return rail.RailResult{ + ReferenceID: strings.TrimSpace(transfer.GetTransferRef()), + Status: providerSettlementStatusFromTransfer(transfer.GetStatus()), + FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), + }, nil +} + +func (g *providerSettlementGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { + if g.client == nil { + return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: client is required") + } + ref := strings.TrimSpace(referenceID) + if ref == "" { + return rail.ObserveResult{}, merrors.InvalidArgument("provider settlement gateway: reference_id is required") + } + resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref}) + if err != nil { + return rail.ObserveResult{}, err + } + if resp == nil || resp.GetTransfer() == nil { + return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: missing transfer response") + } + transfer := resp.GetTransfer() + return rail.ObserveResult{ + ReferenceID: ref, + Status: providerSettlementStatusFromTransfer(transfer.GetStatus()), + FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), + }, nil +} + +func (g *providerSettlementGateway) Block(ctx context.Context, req rail.BlockRequest) (rail.RailResult, error) { + return rail.RailResult{}, merrors.NotImplemented("provider settlement gateway: block not supported") +} + +func (g *providerSettlementGateway) Release(ctx context.Context, req rail.ReleaseRequest) (rail.RailResult, error) { + return rail.RailResult{}, merrors.NotImplemented("provider settlement gateway: release not supported") +} + +func buildProviderSettlementDestination(req rail.TransferRequest) *chainv1.TransferDestination { + destRef := strings.TrimSpace(req.ToAccountID) + memo := strings.TrimSpace(req.DestinationMemo) + if destRef == "" && memo == "" { + return nil + } + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef}, + Memo: memo, + } +} + +func providerSettlementStatusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus { + switch status { + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return rail.TransferStatusSuccess + + case chainv1.TransferStatus_TRANSFER_FAILED: + return rail.TransferStatusFailed + + case chainv1.TransferStatus_TRANSFER_CANCELLED: + // our cancellation, not from provider + return rail.TransferStatusFailed + + default: + // CREATED, PROCESSING, WAITING + return rail.TransferStatusWaiting + } +} + +func railMoneyFromProto(src *moneyv1.Money) *rail.Money { + if src == nil { + return nil + } + currency := strings.TrimSpace(src.GetCurrency()) + amount := strings.TrimSpace(src.GetAmount()) + if currency == "" || amount == "" { + return nil + } + return &rail.Money{ + Amount: amount, + Currency: currency, + } +} diff --git a/api/payments/quotation/internal/service/orchestrator/quotation_app.go b/api/payments/quotation/internal/service/orchestrator/quotation_app.go new file mode 100644 index 00000000..aaa5fe8d --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/quotation_app.go @@ -0,0 +1,41 @@ +package orchestrator + +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" + "google.golang.org/grpc" +) + +// QuotationService exposes only quotation RPCs as a standalone gRPC service. +type QuotationService struct { + core *Service + quote *quotationService +} + +// NewQuotationService constructs a standalone quotation service. +func NewQuotationService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *QuotationService { + core := NewService(logger, repo, opts...) + return &QuotationService{ + core: core, + quote: newQuotationService(core), + } +} + +// Register attaches only the quotation service to the supplied gRPC router. +func (s *QuotationService) Register(router routers.GRPC) error { + if s == nil || s.quote == nil { + return nil + } + return router.Register(func(reg grpc.ServiceRegistrar) { + orchestratorv1.RegisterPaymentQuotationServer(reg, s.quote) + }) +} + +// Shutdown releases resources used by the underlying core service. +func (s *QuotationService) Shutdown() { + if s != nil && s.core != nil { + s.core.Shutdown() + } +} diff --git a/api/payments/quotation/internal/service/orchestrator/quotation_service.go b/api/payments/quotation/internal/service/orchestrator/quotation_service.go new file mode 100644 index 00000000..bab834c2 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/quotation_service.go @@ -0,0 +1,24 @@ +package orchestrator + +import ( + "context" + + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +type quotationService struct { + svc *Service + orchestratorv1.UnimplementedPaymentQuotationServer +} + +func newQuotationService(svc *Service) *quotationService { + return "ationService{svc: svc} +} + +func (s *quotationService) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { + return s.svc.QuotePayment(ctx, req) +} + +func (s *quotationService) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.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/orchestrator/quote_batch.go new file mode 100644 index 00000000..33b265e8 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/quote_batch.go @@ -0,0 +1,145 @@ +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/quotation/internal/service/orchestrator/quote_engine.go b/api/payments/quotation/internal/service/orchestrator/quote_engine.go new file mode 100644 index 00000000..e8d0cca2 --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/quote_engine.go @@ -0,0 +1,579 @@ +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/quotation/internal/service/orchestrator/service.go b/api/payments/quotation/internal/service/orchestrator/service.go new file mode 100644 index 00000000..69e6c11f --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/service.go @@ -0,0 +1,210 @@ +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/orchestrator/service_helpers.go b/api/payments/quotation/internal/service/orchestrator/service_helpers.go new file mode 100644 index 00000000..6272594e --- /dev/null +++ b/api/payments/quotation/internal/service/orchestrator/service_helpers.go @@ -0,0 +1,207 @@ +package orchestrator + +import ( + "context" + "errors" + "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" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "google.golang.org/protobuf/proto" +) + +func validateMetaAndOrgRef(meta *orchestratorv1.RequestMeta) (string, bson.ObjectID, error) { + if meta == nil { + return "", bson.NilObjectID, merrors.InvalidArgument("meta is required") + } + orgRef := strings.TrimSpace(meta.GetOrganizationRef()) + if orgRef == "" { + return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref is required") + } + orgID, err := bson.ObjectIDFromHex(orgRef) + if err != nil { + return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID") + } + 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") + } + if intent.GetAmount() == nil { + return merrors.InvalidArgument("intent.amount is required") + } + if strings.TrimSpace(intent.GetSettlementCurrency()) == "" { + return merrors.InvalidArgument("intent.settlement_currency is required") + } + return nil +} + +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 + } + store := repo.Quotes() + if store == nil { + return nil, errStorageUnavailable + } + 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 + Meta *orchestratorv1.RequestMeta + Intent *orchestratorv1.PaymentIntent + QuoteRef string + IdempotencyKey string +} + +type quoteResolutionError struct { + code string + err error +} + +func (e quoteResolutionError) Error() string { return e.err.Error() } + +func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) { + if ref := strings.TrimSpace(in.QuoteRef); ref != "" { + quotesStore, err := ensureQuotesStore(s.storage) + if err != nil { + return 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, err + } + if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { + return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} + } + intent, err := recordIntentFromQuote(record) + if err != nil { + return 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")} + } + quote, err := recordQuoteFromQuote(record) + if err != nil { + return nil, nil, err + } + quote.QuoteRef = ref + return quote, intent, nil + } + + if in.Intent == nil { + return nil, nil, merrors.InvalidArgument("intent is required") + } + req := &orchestratorv1.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 + } + return quote, in.Intent, nil +} + +func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) { + if record == nil { + return nil, merrors.InvalidArgument("stored quote payload is incomplete") + } + if len(record.Intents) > 0 { + if len(record.Intents) != 1 { + return nil, merrors.InvalidArgument("stored quote payload is incomplete") + } + return protoIntentFromModel(record.Intents[0]), nil + } + if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) { + return nil, merrors.InvalidArgument("stored quote payload is incomplete") + } + return protoIntentFromModel(record.Intent), nil +} + +func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentQuote, error) { + if record == nil { + return nil, merrors.InvalidArgument("stored quote is empty") + } + if record.Quote != nil { + return modelQuoteToProto(record.Quote), nil + } + if len(record.Quotes) > 0 { + if len(record.Quotes) != 1 { + return nil, merrors.InvalidArgument("stored quote payload is incomplete") + } + return modelQuoteToProto(record.Quotes[0]), nil + } + return nil, merrors.InvalidArgument("stored quote is empty") +} + +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()) + entity.SetOrganizationRef(orgID) + entity.PaymentRef = entity.GetID().Hex() + entity.IdempotencyKey = idempotencyKey + entity.State = model.PaymentStateAccepted + entity.Intent = intentFromProto(intent) + entity.Metadata = cloneMetadata(metadata) + entity.LastQuote = quoteSnapshotToModel(quote) + 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/main.go b/api/payments/quotation/main.go new file mode 100644 index 00000000..5eaec6f9 --- /dev/null +++ b/api/payments/quotation/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/payments/quotation/internal/appversion" + si "github.com/tech/sendico/payments/quotation/internal/server" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" +) + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("main", appversion.Create(), factory) +} diff --git a/api/payments/storage/go.mod b/api/payments/storage/go.mod new file mode 100644 index 00000000..cf28916a --- /dev/null +++ b/api/payments/storage/go.mod @@ -0,0 +1,30 @@ +module github.com/tech/sendico/payments/storage + +go 1.25.7 + +replace github.com/tech/sendico/pkg => ../../pkg + +require ( + github.com/tech/sendico/pkg v0.1.0 + go.mongodb.org/mongo-driver/v2 v2.5.0 + go.uber.org/zap v1.27.1 +) + +require ( + 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/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/mitchellh/mapstructure v1.5.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 + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/api/payments/storage/go.sum b/api/payments/storage/go.sum new file mode 100644 index 00000000..b007cadd --- /dev/null +++ b/api/payments/storage/go.sum @@ -0,0 +1,171 @@ +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/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= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ= +github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +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/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= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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.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= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +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/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/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +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.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= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +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.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/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= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +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= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +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.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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +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/sync v0.0.0-20190423024810-112230192c58/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-20201119102817-f84b799fce68/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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/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/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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/payments/orchestrator/storage/model/operation.go b/api/payments/storage/model/operation.go similarity index 100% rename from api/payments/orchestrator/storage/model/operation.go rename to api/payments/storage/model/operation.go diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/storage/model/payment.go similarity index 100% rename from api/payments/orchestrator/storage/model/payment.go rename to api/payments/storage/model/payment.go diff --git a/api/payments/orchestrator/storage/model/plan_template.go b/api/payments/storage/model/plan_template.go similarity index 100% rename from api/payments/orchestrator/storage/model/plan_template.go rename to api/payments/storage/model/plan_template.go diff --git a/api/payments/orchestrator/storage/model/quote.go b/api/payments/storage/model/quote.go similarity index 100% rename from api/payments/orchestrator/storage/model/quote.go rename to api/payments/storage/model/quote.go diff --git a/api/payments/orchestrator/storage/model/route.go b/api/payments/storage/model/route.go similarity index 100% rename from api/payments/orchestrator/storage/model/route.go rename to api/payments/storage/model/route.go diff --git a/api/payments/orchestrator/storage/mongo/repository.go b/api/payments/storage/mongo/repository.go similarity index 90% rename from api/payments/orchestrator/storage/mongo/repository.go rename to api/payments/storage/mongo/repository.go index ca81df70..d716d268 100644 --- a/api/payments/orchestrator/storage/mongo/repository.go +++ b/api/payments/storage/mongo/repository.go @@ -4,9 +4,10 @@ import ( "context" "time" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" - "github.com/tech/sendico/payments/orchestrator/storage/mongo/store" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/payments/storage/mongo/store" + quotemongo "github.com/tech/sendico/payments/storage/quote/mongo" "github.com/tech/sendico/pkg/db" "github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/merrors" @@ -80,7 +81,7 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, if err != nil { return nil, err } - quotesStore, err := store.NewQuotes(childLogger, quotesRepo, cfg.quoteRetention) + quotesRepoStore, err := quotemongo.NewWithRepository(childLogger, ping, quotesRepo, quotemongo.WithQuoteRetention(cfg.quoteRetention)) if err != nil { return nil, err } @@ -96,7 +97,7 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, logger: childLogger, ping: ping, payments: paymentsStore, - quotes: quotesStore, + quotes: quotesRepoStore.Quotes(), routes: routesStore, plans: plansStore, } diff --git a/api/payments/orchestrator/storage/mongo/store/payments.go b/api/payments/storage/mongo/store/payments.go similarity index 98% rename from api/payments/orchestrator/storage/mongo/store/payments.go rename to api/payments/storage/mongo/store/payments.go index c5190d4f..ca300173 100644 --- a/api/payments/orchestrator/storage/mongo/store/payments.go +++ b/api/payments/storage/mongo/store/payments.go @@ -5,8 +5,8 @@ import ( "errors" "strings" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/repository/builder" ri "github.com/tech/sendico/pkg/db/repository/index" diff --git a/api/payments/orchestrator/storage/mongo/store/plan_templates.go b/api/payments/storage/mongo/store/plan_templates.go similarity index 97% rename from api/payments/orchestrator/storage/mongo/store/plan_templates.go rename to api/payments/storage/mongo/store/plan_templates.go index 6f058e9c..c72c2a0d 100644 --- a/api/payments/orchestrator/storage/mongo/store/plan_templates.go +++ b/api/payments/storage/mongo/store/plan_templates.go @@ -5,8 +5,8 @@ import ( "errors" "strings" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/db/repository" ri "github.com/tech/sendico/pkg/db/repository/index" "github.com/tech/sendico/pkg/merrors" diff --git a/api/payments/orchestrator/storage/mongo/store/routes.go b/api/payments/storage/mongo/store/routes.go similarity index 97% rename from api/payments/orchestrator/storage/mongo/store/routes.go rename to api/payments/storage/mongo/store/routes.go index 552622bc..4e1c4bd1 100644 --- a/api/payments/orchestrator/storage/mongo/store/routes.go +++ b/api/payments/storage/mongo/store/routes.go @@ -5,8 +5,8 @@ import ( "errors" "strings" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/db/repository" ri "github.com/tech/sendico/pkg/db/repository/index" "github.com/tech/sendico/pkg/merrors" diff --git a/api/payments/storage/quote/mongo/repository.go b/api/payments/storage/quote/mongo/repository.go new file mode 100644 index 00000000..85a107c3 --- /dev/null +++ b/api/payments/storage/quote/mongo/repository.go @@ -0,0 +1,90 @@ +package mongo + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "github.com/tech/sendico/payments/storage/quote/mongo/store" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/db/repository" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" +) + +// Store implements quotestorage.Repository backed by MongoDB. +type Store struct { + logger mlogger.Logger + ping func(context.Context) error + + quotes quotestorage.QuotesStore +} + +type options struct { + quoteRetention time.Duration +} + +// Option configures the Mongo-backed quotes repository. +type Option func(*options) + +// WithQuoteRetention sets how long quote records are retained after expiry. +func WithQuoteRetention(retention time.Duration) Option { + return func(opts *options) { + opts.quoteRetention = retention + } +} + +// New constructs a Mongo-backed quotes repository from a Mongo connection. +func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Store, error) { + if conn == nil { + return nil, merrors.InvalidArgument("payments.quote.storage.mongo: connection is nil") + } + quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection()) + return NewWithRepository(logger, conn.Ping, quotesRepo, opts...) +} + +// NewWithRepository constructs a quotes repository using the provided primitives. +func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, quotesRepo repository.Repository, opts ...Option) (*Store, error) { + if ping == nil { + return nil, merrors.InvalidArgument("payments.quote.storage.mongo: ping func is nil") + } + if quotesRepo == nil { + return nil, merrors.InvalidArgument("payments.quote.storage.mongo: quotes repository is nil") + } + + cfg := options{} + for _, opt := range opts { + if opt != nil { + opt(&cfg) + } + } + + childLogger := logger.Named("quote_storage").Named("mongo") + quotesStore, err := store.NewQuotes(childLogger, quotesRepo, cfg.quoteRetention) + if err != nil { + return nil, err + } + result := &Store{ + logger: childLogger, + ping: ping, + quotes: quotesStore, + } + + return result, nil +} + +// Ping verifies connectivity with the backing database. +func (s *Store) Ping(ctx context.Context) error { + if s.ping == nil { + return merrors.InvalidArgument("payments.quote.storage.mongo: ping func is nil") + } + return s.ping(ctx) +} + +// Quotes returns the quotes store. +func (s *Store) Quotes() quotestorage.QuotesStore { + return s.quotes +} + +var _ quotestorage.Repository = (*Store)(nil) diff --git a/api/payments/orchestrator/storage/mongo/store/quotes.go b/api/payments/storage/quote/mongo/store/quotes.go similarity index 94% rename from api/payments/orchestrator/storage/mongo/store/quotes.go rename to api/payments/storage/quote/mongo/store/quotes.go index b0ea1676..9312d747 100644 --- a/api/payments/orchestrator/storage/mongo/store/quotes.go +++ b/api/payments/storage/quote/mongo/store/quotes.go @@ -6,8 +6,8 @@ import ( "strings" "time" - "github.com/tech/sendico/payments/orchestrator/storage" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/repository/builder" ri "github.com/tech/sendico/pkg/db/repository/index" @@ -119,7 +119,7 @@ func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) er if err := q.repo.Insert(ctx, quote, filter); err != nil { if errors.Is(err, merrors.ErrDataConflict) { - return storage.ErrDuplicateQuote + return quotestorage.ErrDuplicateQuote } q.logger.Warn("Failed to insert quote", mzap.ObjRef("org_ref", quote.OrganizationRef), zap.String("quote_ref", quote.QuoteRef), zap.Error(err)) return err @@ -140,14 +140,14 @@ func (q *Quotes) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef st if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil { if errors.Is(err, merrors.ErrNoData) { q.logger.Debug("Quote not found by ref", zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef)) - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } q.logger.Warn("Failed to fetch quote by ref", zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef), zap.Error(err)) return nil, err } if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) { q.logger.Debug("Quote expired by idempotency key", zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef), zap.Time("expires_at", entity.ExpiresAt)) - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } return entity, nil } @@ -165,19 +165,19 @@ func (q *Quotes) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil { if errors.Is(err, merrors.ErrNoData) { q.logger.Debug("Quote not found by idempotency key", zap.String("idempotency_key", idempotencyKey), mzap.ObjRef("org_ref", orgRef)) - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } q.logger.Warn("Failed to fetch quoteby idempotency key", zap.String("idempotency_key", idempotencyKey), mzap.ObjRef("org_ref", orgRef)) return nil, err } if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) { q.logger.Debug("Quote expired by idempotency key", zap.String("idempotency_key", idempotencyKey), mzap.ObjRef("org_ref", orgRef), zap.Time("expires_at", entity.ExpiresAt)) - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } return entity, nil } -var _ storage.QuotesStore = (*Quotes)(nil) +var _ quotestorage.QuotesStore = (*Quotes)(nil) func int32Ptr(v int32) *int32 { return &v diff --git a/api/payments/storage/quote/storage.go b/api/payments/storage/quote/storage.go new file mode 100644 index 00000000..df507504 --- /dev/null +++ b/api/payments/storage/quote/storage.go @@ -0,0 +1,34 @@ +package storage + +import ( + "context" + + "github.com/tech/sendico/payments/storage/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type storageError string + +func (e storageError) Error() string { + return string(e) +} + +var ( + // ErrQuoteNotFound signals that a stored quote does not exist or expired. + ErrQuoteNotFound = storageError("payments.storage.quote: quote not found") + // ErrDuplicateQuote signals that a quote reference already exists. + ErrDuplicateQuote = storageError("payments.storage.quote: duplicate quote") +) + +// Repository exposes persistence primitives for quote records. +type Repository interface { + Ping(ctx context.Context) error + Quotes() QuotesStore +} + +// QuotesStore manages temporary stored payment quotes. +type QuotesStore interface { + Create(ctx context.Context, quote *model.PaymentQuoteRecord) error + GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) + GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) +} diff --git a/api/payments/orchestrator/storage/storage.go b/api/payments/storage/storage.go similarity index 61% rename from api/payments/orchestrator/storage/storage.go rename to api/payments/storage/storage.go index a64b94cb..2eba4e2c 100644 --- a/api/payments/orchestrator/storage/storage.go +++ b/api/payments/storage/storage.go @@ -3,7 +3,8 @@ package storage import ( "context" - "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -15,28 +16,31 @@ func (e storageError) Error() string { var ( // ErrPaymentNotFound signals that a payment record does not exist. - ErrPaymentNotFound = storageError("payments.orchestrator.storage: payment not found") + ErrPaymentNotFound = storageError("payments.storage: payment not found") // ErrDuplicatePayment signals that idempotency constraints were violated. - ErrDuplicatePayment = storageError("payments.orchestrator.storage: duplicate payment") - // ErrQuoteNotFound signals that a stored quote does not exist or expired. - ErrQuoteNotFound = storageError("payments.orchestrator.storage: quote not found") - // ErrDuplicateQuote signals that a quote reference already exists. - ErrDuplicateQuote = storageError("payments.orchestrator.storage: duplicate quote") + ErrDuplicatePayment = storageError("payments.storage: duplicate payment") // ErrRouteNotFound signals that a payment route record does not exist. - ErrRouteNotFound = storageError("payments.orchestrator.storage: route not found") + ErrRouteNotFound = storageError("payments.storage: route not found") // ErrDuplicateRoute signals that a route already exists for the same transition. - ErrDuplicateRoute = storageError("payments.orchestrator.storage: duplicate route") + ErrDuplicateRoute = storageError("payments.storage: duplicate route") // ErrPlanTemplateNotFound signals that a plan template record does not exist. - ErrPlanTemplateNotFound = storageError("payments.orchestrator.storage: plan template not found") + ErrPlanTemplateNotFound = storageError("payments.storage: plan template not found") // ErrDuplicatePlanTemplate signals that a plan template already exists for the same transition. - ErrDuplicatePlanTemplate = storageError("payments.orchestrator.storage: duplicate plan template") + ErrDuplicatePlanTemplate = storageError("payments.storage: duplicate plan template") ) -// Repository exposes persistence primitives for the orchestrator domain. +var ( + // Deprecated: use quote/storage.ErrQuoteNotFound. + ErrQuoteNotFound = quotestorage.ErrQuoteNotFound + // Deprecated: use quote/storage.ErrDuplicateQuote. + ErrDuplicateQuote = quotestorage.ErrDuplicateQuote +) + +// Repository exposes persistence primitives for the payments domain. type Repository interface { Ping(ctx context.Context) error Payments() PaymentsStore - Quotes() QuotesStore + Quotes() quotestorage.QuotesStore Routes() RoutesStore PlanTemplates() PlanTemplatesStore } @@ -51,12 +55,8 @@ type PaymentsStore interface { List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error) } -// QuotesStore manages temporary stored payment quotes. -type QuotesStore interface { - Create(ctx context.Context, quote *model.PaymentQuoteRecord) error - GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) - GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) -} +// Deprecated: use quote/storage.QuotesStore. +type QuotesStore = quotestorage.QuotesStore // RoutesStore manages allowed routing transitions. type RoutesStore interface { diff --git a/api/pkg/db/internal/mongo/verificationimp/consume.go b/api/pkg/db/internal/mongo/verificationimp/consume.go index d9547130..d93d7d51 100644 --- a/api/pkg/db/internal/mongo/verificationimp/consume.go +++ b/api/pkg/db/internal/mongo/verificationimp/consume.go @@ -3,6 +3,7 @@ package verificationimp import ( "context" "errors" + "strings" "time" "github.com/tech/sendico/pkg/db/repository" @@ -12,6 +13,7 @@ import ( "github.com/tech/sendico/pkg/model" mutil "github.com/tech/sendico/pkg/mutil/db" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func (db *verificationDB) Consume( @@ -23,54 +25,144 @@ func (db *verificationDB) Consume( now := time.Now().UTC() accountScoped := accountRef != bson.NilObjectID + accountRefHex := "" + if accountScoped { + accountRefHex = accountRef.Hex() + } + trimmedRawToken := strings.TrimSpace(rawToken) + + db.Logger.Debug("Verification consume started", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Time("now_utc", now), + zap.Int("raw_token_len", len(rawToken)), + zap.Int("raw_token_trimmed_len", len(trimmedRawToken)), + zap.Bool("raw_token_digits_only", tokenIsDigitsOnly(rawToken)), + zap.Bool("raw_token_has_edge_whitespace", rawToken != trimmedRawToken), + zap.String("raw_token_hash_prefix", hashPreview(tokenHash(rawToken))), + ) t, e := db.tf.CreateTransaction().Execute( ct, func(ctx context.Context) (any, error) { scopeFilter := repository.Query().And( - repository.Filter("purpose", purpose), + scopeFilters(accountScoped, accountRef, purpose)..., + ) + db.Logger.Debug("Verification consume scope filter prepared", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Any("scope_filter", scopeFilter.BuildQuery()), ) - if accountScoped { - scopeFilter = scopeFilter.And(repository.Filter("accountRef", accountRef)) - } // 1) Fast path for magic-link tokens: hash is deterministic and globally unique. var token *model.VerificationToken - magicFilter := scopeFilter.And( - repository.Filter("verifyTokenHash", tokenHash(rawToken)), + magicFilter := repository.Query().And( + append( + scopeFilters(accountScoped, accountRef, purpose), + repository.Filter("verifyTokenHash", tokenHash(rawToken)), + )..., + ) + db.Logger.Debug("Verification consume magic path lookup", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Any("magic_filter", magicFilter.BuildQuery()), ) var direct model.VerificationToken err := db.DBImp.FindOne(ctx, magicFilter, &direct) switch { case err == nil: token = &direct + db.Logger.Debug("Verification consume matched by magic hash path", + append([]zap.Field{ + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + }, tokenStateFields(token)..., + )..., + ) case errors.Is(err, merrors.ErrNoData): + db.Logger.Debug("Verification consume no direct magic match", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + ) default: + db.Logger.Warn("Verification consume magic path lookup failed", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Error(err), + ) return nil, err } // If account is unknown, do not scan OTP candidates globally. if token == nil && !accountScoped { + db.Logger.Debug("Verification consume rejected unscoped OTP scan", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + ) return nil, verification.ErorrTokenNotFound() } // 2) OTP path (and fallback): load purpose/account scoped tokens and compare hash with per-token salt. if token == nil { + db.Logger.Debug("Verification consume OTP fallback lookup started", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Any("scope_filter", scopeFilter.BuildQuery()), + ) tokens, err := mutil.GetObjects[model.VerificationToken]( ctx, db.Logger, scopeFilter, nil, db.DBImp.Repository, ) if err != nil { if errors.Is(err, merrors.ErrNoData) { + db.Logger.Debug("Verification consume OTP fallback found no scoped tokens", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + ) return nil, verification.ErorrTokenNotFound() } + db.Logger.Warn("Verification consume OTP fallback query failed", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Error(err), + ) return nil, err } + db.Logger.Debug("Verification consume OTP fallback loaded candidates", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Int("candidate_count", len(tokens)), + ) for i := range tokens { t := &tokens[i] hash := hasherFor(t).Hash(rawToken, t) - if hash == t.VerifyTokenHash { + match := hash == t.VerifyTokenHash + db.Logger.Debug("Verification consume OTP candidate evaluated", + zap.Int("candidate_index", i), + zap.Int("candidate_total", len(tokens)), + zap.String("candidate_id", t.ID.Hex()), + zap.Bool("candidate_has_salt", t.Salt != nil), + zap.Bool("candidate_used", t.UsedAt != nil), + zap.Time("candidate_expires_at", t.ExpiresAt), + zap.Int("candidate_attempts", t.Attempts), + zap.Bool("candidate_has_max_retries", t.MaxRetries != nil), + zap.String("candidate_stored_hash_prefix", hashPreview(t.VerifyTokenHash)), + zap.String("candidate_computed_hash_prefix", hashPreview(hash)), + zap.Bool("candidate_match", match), + ) + if match { token = t break } @@ -78,47 +170,110 @@ func (db *verificationDB) Consume( if token == nil { // wrong code/token → increment attempts for active (not used, not expired) scoped tokens - activeFilter := scopeFilter.And( - repository.Filter("usedAt", nil), - repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), + activeFilter := repository.Query().And( + append( + scopeFilters(accountScoped, accountRef, purpose), + repository.Filter("usedAt", nil), + repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), + )..., + ) + db.Logger.Debug("Verification consume no OTP candidate matched, incrementing attempts for active scoped tokens", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Any("active_filter", activeFilter.BuildQuery()), ) - _, _ = db.DBImp.PatchMany( + incremented, patchErr := db.DBImp.PatchMany( ctx, activeFilter, repository.Patch().Inc(repository.Field("attempts"), 1), ) + if patchErr != nil { + db.Logger.Warn("Verification consume failed to increment attempts for unmatched token", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Error(patchErr), + ) + } else { + db.Logger.Debug("Verification consume attempts increment for unmatched token executed", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Int("updated_count", incremented), + zap.String("transaction_note", "this update occurs inside transaction and will roll back when not_found error is returned"), + ) + } return nil, verification.ErorrTokenNotFound() } } // 3) Static checks if token.UsedAt != nil { + db.Logger.Debug("Verification consume static check failed: token already used", + append([]zap.Field{ + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + }, tokenStateFields(token)..., + )..., + ) return nil, verification.ErorrTokenAlreadyUsed() } if !token.ExpiresAt.After(now) { + db.Logger.Debug("Verification consume static check failed: token expired", + append([]zap.Field{ + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Time("now_utc", now), + }, tokenStateFields(token)..., + )..., + ) return nil, verification.ErorrTokenExpired() } if token.MaxRetries != nil && token.Attempts >= *token.MaxRetries { + db.Logger.Debug("Verification consume static check failed: attempts exceeded", + append([]zap.Field{ + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + }, tokenStateFields(token)..., + )..., + ) return nil, verification.ErrorTokenAttemptsExceeded() } + db.Logger.Debug("Verification consume static checks passed", + append([]zap.Field{ + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + }, tokenStateFields(token)..., + )..., + ) // 4) Atomic consume - consumeFilter := repository.Query().And( + consumeFilters := []builder.Query{ repository.IDFilter(token.ID), repository.Filter("purpose", purpose), repository.Filter("usedAt", nil), repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now), - ) + } if accountScoped { - consumeFilter = consumeFilter.And(repository.Filter("accountRef", accountRef)) + consumeFilters = append(consumeFilters, repository.Filter("accountRef", accountRef)) } - if token.MaxRetries != nil { - consumeFilter = consumeFilter.And( - repository.Query().Comparison(repository.Field("attempts"), builder.Lt, *token.MaxRetries), - ) + consumeFilters = append(consumeFilters, repository.Query().Comparison(repository.Field("attempts"), builder.Lt, *token.MaxRetries)) } + consumeFilter := repository.Query().And(consumeFilters...) + db.Logger.Debug("Verification consume atomic update attempt", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + zap.Any("consume_filter", consumeFilter.BuildQuery()), + ) updated, err := db.DBImp.PatchMany( ctx, @@ -126,48 +281,217 @@ func (db *verificationDB) Consume( repository.Patch().Set(repository.Field("usedAt"), now), ) if err != nil { + db.Logger.Warn("Verification consume atomic update failed", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + zap.Error(err), + ) return nil, err } + db.Logger.Debug("Verification consume atomic update result", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + zap.Int("updated_count", updated), + ) if updated == 1 { token.UsedAt = &now + db.Logger.Debug("Verification consume succeeded", + append([]zap.Field{ + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + }, tokenStateFields(token)..., + )..., + ) return token, nil } // 5) Consume failed → increment attempts - _, _ = db.DBImp.PatchMany( + incremented, incrementErr := db.DBImp.PatchMany( ctx, repository.IDFilter(token.ID), repository.Patch().Inc(repository.Field("attempts"), 1), ) + if incrementErr != nil { + db.Logger.Warn("Verification consume failed to increment attempts after atomic update miss", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + zap.Error(incrementErr), + ) + } else { + db.Logger.Debug("Verification consume incremented attempts after atomic update miss", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + zap.Int("updated_count", incremented), + zap.String("transaction_note", "this update occurs inside transaction and may roll back depending on returned error"), + ) + } // 6) Re-check state var fresh model.VerificationToken if err := db.DBImp.FindOne(ctx, repository.IDFilter(token.ID), &fresh); err != nil { + db.Logger.Warn("Verification consume failed to re-check token state", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + zap.Error(err), + ) return nil, merrors.Internal("failed to re-check token state") } + db.Logger.Debug("Verification consume re-checked token state after atomic miss", + append([]zap.Field{ + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + }, tokenStateFields(&fresh)..., + )..., + ) if fresh.UsedAt != nil { + db.Logger.Debug("Verification consume final decision: already used after re-check", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + ) return nil, verification.ErorrTokenAlreadyUsed() } if !fresh.ExpiresAt.After(now) { + db.Logger.Debug("Verification consume final decision: expired after re-check", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + zap.Time("now_utc", now), + zap.Time("token_expires_at", fresh.ExpiresAt), + ) return nil, verification.ErorrTokenExpired() } if fresh.MaxRetries != nil && fresh.Attempts >= *fresh.MaxRetries { + db.Logger.Debug("Verification consume final decision: attempts exceeded after re-check", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + zap.Int("token_attempts", fresh.Attempts), + zap.Int("token_max_retries", *fresh.MaxRetries), + ) return nil, verification.ErrorTokenAttemptsExceeded() } + db.Logger.Debug("Verification consume final decision: token not found after re-check", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("token_id", token.ID.Hex()), + ) return nil, verification.ErorrTokenNotFound() }, ) if e != nil { + db.Logger.Debug("Verification consume finished with error", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.Error(e), + ) return nil, e } res, ok := t.(*model.VerificationToken) if !ok { + db.Logger.Warn("Verification consume returned unexpected result type", + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + zap.String("result_type", ""), + ) return nil, merrors.Internal("unexpected token type") } + db.Logger.Debug("Verification consume finished successfully", + append([]zap.Field{ + zap.String("purpose", string(purpose)), + zap.Bool("account_scoped", accountScoped), + zap.String("account_ref", accountRefHex), + }, tokenStateFields(res)..., + )..., + ) return res, nil } + +const hashPreviewChars = 12 + +func hashPreview(value string) string { + if value == "" { + return "" + } + if len(value) <= hashPreviewChars { + return value + } + return value[:hashPreviewChars] + "..." +} + +func tokenIsDigitsOnly(value string) bool { + if value == "" { + return false + } + for _, ch := range value { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func tokenStateFields(token *model.VerificationToken) []zap.Field { + fields := []zap.Field{ + zap.String("token_id", token.ID.Hex()), + zap.String("token_account_ref", token.AccountRef.Hex()), + zap.String("token_purpose", string(token.Purpose)), + zap.Bool("token_has_target", strings.TrimSpace(token.Target) != ""), + zap.Bool("token_has_idempotency_key", token.IdempotencyKey != nil), + zap.String("token_verify_hash_prefix", hashPreview(token.VerifyTokenHash)), + zap.Bool("token_has_salt", token.Salt != nil), + zap.Bool("token_used", token.UsedAt != nil), + zap.Time("token_expires_at", token.ExpiresAt), + zap.Int("token_attempts", token.Attempts), + zap.Bool("token_has_max_retries", token.MaxRetries != nil), + } + + if token.Salt != nil { + fields = append(fields, zap.String("token_salt_prefix", hashPreview(*token.Salt))) + } + if token.UsedAt != nil { + fields = append(fields, zap.Time("token_used_at", *token.UsedAt)) + } + if token.MaxRetries != nil { + fields = append(fields, zap.Int("token_max_retries", *token.MaxRetries)) + } + if token.IdempotencyKey != nil { + fields = append(fields, zap.String("token_idempotency_key_prefix", hashPreview(*token.IdempotencyKey))) + } + + return fields +} + +func scopeFilters(accountScoped bool, accountRef bson.ObjectID, purpose model.VerificationPurpose) []builder.Query { + filters := []builder.Query{ + repository.Filter("purpose", purpose), + } + if accountScoped { + filters = append(filters, repository.Filter("accountRef", accountRef)) + } + return filters +} diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index 47934bce..a27ace1b 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -351,8 +351,6 @@ message InitiateConversionResponse { } service PaymentOrchestrator { - rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); - rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse); rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse); rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse); rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse); @@ -363,3 +361,8 @@ service PaymentOrchestrator { 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/api/server/config.dev.yml b/api/server/config.dev.yml index 77a132bb..97a05aeb 100755 --- a/api/server/config.dev.yml +++ b/api/server/config.dev.yml @@ -97,6 +97,12 @@ api: dial_timeout_seconds: 5 call_timeout_seconds: 5 insecure: true + payment_quotation: + address: dev-payments-quotation:50064 + address_env: PAYMENTS_QUOTE_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true app: diff --git a/api/server/config.yml b/api/server/config.yml index 4ab970ae..1a424b75 100755 --- a/api/server/config.yml +++ b/api/server/config.yml @@ -97,6 +97,12 @@ api: dial_timeout_seconds: 5 call_timeout_seconds: 5 insecure: true + payment_quotation: + address: sendico_payments_quotation:50064 + address_env: PAYMENTS_QUOTE_ADDRESS + dial_timeout_seconds: 5 + call_timeout_seconds: 5 + insecure: true app: diff --git a/api/server/go.mod b/api/server/go.mod index cccd8839..e3b55754 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -8,6 +8,8 @@ replace github.com/tech/sendico/ledger => ../ledger replace github.com/tech/sendico/payments/orchestrator => ../payments/orchestrator +replace github.com/tech/sendico/payments/storage => ../payments/storage + replace github.com/tech/sendico/gateway/tron => ../gateway/tron require ( diff --git a/api/server/interface/api/config.go b/api/server/interface/api/config.go index 09a40a5a..a7c7f5bc 100644 --- a/api/server/interface/api/config.go +++ b/api/server/interface/api/config.go @@ -11,6 +11,7 @@ type Config struct { ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"` Ledger *LedgerConfig `yaml:"ledger"` PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"` + PaymentQuotation *PaymentOrchestratorConfig `yaml:"payment_quotation"` } type ChainGatewayConfig struct { diff --git a/api/server/internal/api/api.go b/api/server/internal/api/api.go index 6721856d..702429f4 100644 --- a/api/server/internal/api/api.go +++ b/api/server/internal/api/api.go @@ -144,6 +144,8 @@ func CreateAPI(logger mlogger.Logger, config *api.Config, db db.Factory, router } p.logger.Info("Middleware installed", zap.Bool("debug_mode", debug)) + p.resolveServiceAddressesFromDiscovery() + p.logger.Info("Installing microservices...") if err := p.installServices(); err != nil { p.logger.Error("Failed to install a microservice", zap.Error(err)) diff --git a/api/server/internal/api/discovery_resolver.go b/api/server/internal/api/discovery_resolver.go new file mode 100644 index 00000000..f9e4da72 --- /dev/null +++ b/api/server/internal/api/discovery_resolver.go @@ -0,0 +1,466 @@ +package apiimp + +import ( + "context" + "fmt" + "net" + "net/url" + "sort" + "strings" + "time" + + "github.com/tech/sendico/pkg/discovery" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mservice" + eapi "github.com/tech/sendico/server/interface/api" + "go.uber.org/zap" +) + +const ( + discoveryBootstrapTimeout = 3 * time.Second + discoveryBootstrapSender = "server_bootstrap" + discoveryGatewayRailCrypto = "CRYPTO" + defaultClientDialTimeoutSecs = 5 + defaultClientCallTimeoutSecs = 5 + paymentQuoteOperation = "payment.quote" + paymentInitiateOperation = "payment.initiate" + ledgerDebitOperation = "ledger.debit" + ledgerCreditOperation = "ledger.credit" + gatewayReadBalanceOperation = "balance.read" +) + +var ( + ledgerDiscoveryServiceNames = []string{ + "LEDGER", + string(mservice.Ledger), + } + paymentOrchestratorDiscoveryServiceNames = []string{ + "PAYMENTS_ORCHESTRATOR", + string(mservice.PaymentOrchestrator), + } + paymentQuotationDiscoveryServiceNames = []string{ + "PAYMENTS_QUOTATION", + "PAYMENTS_QUOTE", + "PAYMENT_QUOTATION", + "payment_quotation", + } +) + +type discoveryEndpoint struct { + address string + insecure bool + raw string +} + +type serviceSelection struct { + service discovery.ServiceSummary + endpoint discoveryEndpoint + opMatch bool + nameRank int +} + +type gatewaySelection struct { + gateway discovery.GatewaySummary + endpoint discoveryEndpoint + networkMatch bool + opMatch bool +} + +// resolveServiceAddressesFromDiscovery looks up downstream service addresses once +// during startup and applies them to the runtime config. +func (a *APIImp) resolveServiceAddressesFromDiscovery() { + if a == nil || a.config == nil || a.config.Mw == nil { + return + } + + msgCfg := a.config.Mw.Messaging + if msgCfg.Driver == "" { + return + } + + logger := a.logger.Named("discovery_bootstrap") + broker, err := msg.CreateMessagingBroker(logger.Named("bus"), &msgCfg) + if err != nil { + logger.Warn("Failed to create discovery bootstrap broker", zap.Error(err)) + return + } + + client, err := discovery.NewClient(logger, broker, nil, discoveryBootstrapSender) + if err != nil { + logger.Warn("Failed to create discovery bootstrap client", zap.Error(err)) + return + } + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), discoveryBootstrapTimeout) + defer cancel() + + lookup, err := client.Lookup(ctx) + if err != nil { + logger.Warn("Failed to fetch discovery registry during startup", zap.Error(err)) + return + } + + a.resolveChainGatewayAddress(lookup.Gateways) + orchestratorFound, orchestratorEndpoint := a.resolvePaymentOrchestratorAddress(lookup.Services) + a.resolveLedgerAddress(lookup.Services) + a.resolvePaymentQuotationAddress(lookup.Services, orchestratorFound, orchestratorEndpoint) +} + +func (a *APIImp) resolveChainGatewayAddress(gateways []discovery.GatewaySummary) { + cfg := a.config.ChainGateway + if cfg == nil { + return + } + + endpoint, selected, ok := selectGatewayEndpoint( + gateways, + cfg.DefaultAsset.Chain, + []string{gatewayReadBalanceOperation}, + ) + if !ok { + return + } + + cfg.Address = endpoint.address + cfg.Insecure = endpoint.insecure + ensureTimeoutsChainGateway(cfg) + + a.logger.Info("Resolved chain gateway address from discovery", + zap.String("rail", selected.Rail), + zap.String("gateway_id", selected.ID), + zap.String("network", selected.Network), + zap.String("invoke_uri", endpoint.raw), + zap.String("address", endpoint.address), + zap.Bool("insecure", endpoint.insecure)) +} + +func (a *APIImp) resolveLedgerAddress(services []discovery.ServiceSummary) { + endpoint, selected, ok := selectServiceEndpoint( + services, + ledgerDiscoveryServiceNames, + []string{ledgerDebitOperation, ledgerCreditOperation}, + ) + if !ok { + return + } + + cfg := ensureLedgerConfig(a.config) + cfg.Address = endpoint.address + cfg.Insecure = endpoint.insecure + ensureTimeoutsLedger(cfg) + + a.logger.Info("Resolved ledger address from discovery", + zap.String("service", selected.Service), + zap.String("service_id", selected.ID), + zap.String("instance_id", selected.InstanceID), + zap.String("invoke_uri", endpoint.raw), + zap.String("address", endpoint.address), + zap.Bool("insecure", endpoint.insecure)) +} + +func (a *APIImp) resolvePaymentOrchestratorAddress(services []discovery.ServiceSummary) (bool, discoveryEndpoint) { + endpoint, selected, ok := selectServiceEndpoint( + services, + paymentOrchestratorDiscoveryServiceNames, + []string{paymentInitiateOperation}, + ) + if !ok { + return false, discoveryEndpoint{} + } + + cfg := ensurePaymentOrchestratorConfig(a.config) + cfg.Address = endpoint.address + cfg.Insecure = endpoint.insecure + ensureTimeoutsPayment(cfg) + + a.logger.Info("Resolved payment orchestrator address from discovery", + zap.String("service", selected.Service), + zap.String("service_id", selected.ID), + zap.String("instance_id", selected.InstanceID), + zap.String("invoke_uri", endpoint.raw), + zap.String("address", endpoint.address), + zap.Bool("insecure", endpoint.insecure)) + + return true, endpoint +} + +func (a *APIImp) resolvePaymentQuotationAddress(services []discovery.ServiceSummary, orchestratorFound bool, orchestratorEndpoint discoveryEndpoint) { + endpoint, selected, ok := selectServiceEndpoint( + services, + paymentQuotationDiscoveryServiceNames, + []string{paymentQuoteOperation}, + ) + if !ok { + cfg := a.config.PaymentQuotation + if cfg != nil && strings.TrimSpace(cfg.Address) != "" { + return + } + if !orchestratorFound { + return + } + // Fall back to orchestrator endpoint when quotation service is not announced. + endpoint = orchestratorEndpoint + selected = discovery.ServiceSummary{Service: "PAYMENTS_ORCHESTRATOR"} + } + + cfg := ensurePaymentQuotationConfig(a.config) + cfg.Address = endpoint.address + cfg.Insecure = endpoint.insecure + ensureTimeoutsPayment(cfg) + + a.logger.Info("Resolved payment quotation address from discovery", + zap.String("service", selected.Service), + zap.String("service_id", selected.ID), + zap.String("instance_id", selected.InstanceID), + zap.String("invoke_uri", endpoint.raw), + zap.String("address", endpoint.address), + zap.Bool("insecure", endpoint.insecure)) +} + +func selectServiceEndpoint(services []discovery.ServiceSummary, serviceNames []string, requiredOps []string) (discoveryEndpoint, discovery.ServiceSummary, bool) { + selections := make([]serviceSelection, 0) + for _, svc := range services { + if !svc.Healthy { + continue + } + if strings.TrimSpace(svc.InvokeURI) == "" { + continue + } + nameRank, ok := serviceRank(svc.Service, serviceNames) + if !ok { + continue + } + endpoint, err := parseDiscoveryInvokeURI(svc.InvokeURI) + if err != nil { + continue + } + selections = append(selections, serviceSelection{ + service: svc, + endpoint: endpoint, + opMatch: hasAnyOperation(svc.Ops, requiredOps), + nameRank: nameRank, + }) + } + if len(selections) == 0 { + return discoveryEndpoint{}, discovery.ServiceSummary{}, false + } + + sort.Slice(selections, func(i, j int) bool { + if selections[i].opMatch != selections[j].opMatch { + return selections[i].opMatch + } + if selections[i].nameRank != selections[j].nameRank { + return selections[i].nameRank < selections[j].nameRank + } + if selections[i].service.ID != selections[j].service.ID { + return selections[i].service.ID < selections[j].service.ID + } + return selections[i].service.InstanceID < selections[j].service.InstanceID + }) + + selected := selections[0] + return selected.endpoint, selected.service, true +} + +func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork string, requiredOps []string) (discoveryEndpoint, discovery.GatewaySummary, bool) { + preferredNetwork = strings.TrimSpace(preferredNetwork) + selections := make([]gatewaySelection, 0) + + for _, gateway := range gateways { + if !gateway.Healthy { + continue + } + if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discoveryGatewayRailCrypto) { + continue + } + if strings.TrimSpace(gateway.InvokeURI) == "" { + continue + } + endpoint, err := parseDiscoveryInvokeURI(gateway.InvokeURI) + if err != nil { + continue + } + selections = append(selections, gatewaySelection{ + gateway: gateway, + endpoint: endpoint, + networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork), + opMatch: hasAnyOperation(gateway.Ops, requiredOps), + }) + } + if len(selections) == 0 { + return discoveryEndpoint{}, discovery.GatewaySummary{}, false + } + + sort.Slice(selections, func(i, j int) bool { + if selections[i].networkMatch != selections[j].networkMatch { + return selections[i].networkMatch + } + if selections[i].opMatch != selections[j].opMatch { + return selections[i].opMatch + } + if selections[i].gateway.RoutingPriority != selections[j].gateway.RoutingPriority { + return selections[i].gateway.RoutingPriority > selections[j].gateway.RoutingPriority + } + if selections[i].gateway.ID != selections[j].gateway.ID { + return selections[i].gateway.ID < selections[j].gateway.ID + } + return selections[i].gateway.InstanceID < selections[j].gateway.InstanceID + }) + + selected := selections[0] + return selected.endpoint, selected.gateway, true +} + +func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return discoveryEndpoint{}, fmt.Errorf("invoke uri is empty") + } + + // Without a scheme we expect a plain host:port target. + if !strings.Contains(raw, "://") { + if _, _, err := net.SplitHostPort(raw); err != nil { + return discoveryEndpoint{}, fmt.Errorf("invoke uri must include host:port: %w", err) + } + return discoveryEndpoint{ + address: raw, + insecure: true, + raw: raw, + }, nil + } + + parsed, err := url.Parse(raw) + if err != nil { + return discoveryEndpoint{}, err + } + + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { + case "grpc": + address := strings.TrimSpace(parsed.Host) + if _, _, splitErr := net.SplitHostPort(address); splitErr != nil { + return discoveryEndpoint{}, fmt.Errorf("grpc invoke uri must include host:port: %w", splitErr) + } + return discoveryEndpoint{ + address: address, + insecure: true, + raw: raw, + }, nil + case "grpcs": + address := strings.TrimSpace(parsed.Host) + if _, _, splitErr := net.SplitHostPort(address); splitErr != nil { + return discoveryEndpoint{}, fmt.Errorf("grpcs invoke uri must include host:port: %w", splitErr) + } + return discoveryEndpoint{ + address: address, + insecure: false, + raw: raw, + }, nil + case "dns", "passthrough": + // gRPC resolver targets such as dns:///service:port. + return discoveryEndpoint{ + address: raw, + insecure: true, + raw: raw, + }, nil + default: + return discoveryEndpoint{}, fmt.Errorf("unsupported invoke uri scheme: %s", parsed.Scheme) + } +} + +func serviceRank(service string, names []string) (int, bool) { + service = strings.TrimSpace(service) + if service == "" { + return 0, false + } + for i, name := range names { + if strings.EqualFold(service, strings.TrimSpace(name)) { + return i, true + } + } + return 0, false +} + +func hasAnyOperation(ops []string, required []string) bool { + if len(required) == 0 { + return true + } + for _, op := range ops { + normalized := strings.TrimSpace(op) + if normalized == "" { + continue + } + for _, target := range required { + if strings.EqualFold(normalized, strings.TrimSpace(target)) { + return true + } + } + } + return false +} + +func ensureLedgerConfig(cfg *eapi.Config) *eapi.LedgerConfig { + if cfg == nil { + return nil + } + if cfg.Ledger == nil { + cfg.Ledger = &eapi.LedgerConfig{} + } + return cfg.Ledger +} + +func ensurePaymentOrchestratorConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig { + if cfg == nil { + return nil + } + if cfg.PaymentOrchestrator == nil { + cfg.PaymentOrchestrator = &eapi.PaymentOrchestratorConfig{} + } + return cfg.PaymentOrchestrator +} + +func ensurePaymentQuotationConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig { + if cfg == nil { + return nil + } + if cfg.PaymentQuotation == nil { + cfg.PaymentQuotation = &eapi.PaymentOrchestratorConfig{} + } + return cfg.PaymentQuotation +} + +func ensureTimeoutsLedger(cfg *eapi.LedgerConfig) { + if cfg == nil { + return + } + if cfg.DialTimeoutSeconds <= 0 { + cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs + } + if cfg.CallTimeoutSeconds <= 0 { + cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs + } +} + +func ensureTimeoutsChainGateway(cfg *eapi.ChainGatewayConfig) { + if cfg == nil { + return + } + if cfg.DialTimeoutSeconds <= 0 { + cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs + } + if cfg.CallTimeoutSeconds <= 0 { + cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs + } +} + +func ensureTimeoutsPayment(cfg *eapi.PaymentOrchestratorConfig) { + if cfg == nil { + return + } + if cfg.DialTimeoutSeconds <= 0 { + cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs + } + if cfg.CallTimeoutSeconds <= 0 { + cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs + } +} diff --git a/api/server/internal/api/discovery_resolver_test.go b/api/server/internal/api/discovery_resolver_test.go new file mode 100644 index 00000000..c0b6c57e --- /dev/null +++ b/api/server/internal/api/discovery_resolver_test.go @@ -0,0 +1,140 @@ +package apiimp + +import ( + "testing" + + "github.com/tech/sendico/pkg/discovery" +) + +func TestParseDiscoveryInvokeURI(t *testing.T) { + testCases := []struct { + name string + raw string + address string + insecure bool + wantErr bool + }{ + { + name: "host_port", + raw: "ledger:50052", + address: "ledger:50052", + insecure: true, + }, + { + name: "grpc_scheme", + raw: "grpc://payments-orchestrator:50062", + address: "payments-orchestrator:50062", + insecure: true, + }, + { + name: "grpcs_scheme", + raw: "grpcs://payments-orchestrator:50062", + address: "payments-orchestrator:50062", + insecure: false, + }, + { + name: "dns_scheme", + raw: "dns:///ledger:50052", + address: "dns:///ledger:50052", + insecure: true, + }, + { + name: "invalid", + raw: "ledger", + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + endpoint, err := parseDiscoveryInvokeURI(tc.raw) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q", tc.raw) + } + return + } + if err != nil { + t.Fatalf("parseDiscoveryInvokeURI(%q) failed: %v", tc.raw, err) + } + if endpoint.address != tc.address { + t.Fatalf("expected address %q, got %q", tc.address, endpoint.address) + } + if endpoint.insecure != tc.insecure { + t.Fatalf("expected insecure %t, got %t", tc.insecure, endpoint.insecure) + } + }) + } +} + +func TestSelectServiceEndpointPrefersRequiredOperation(t *testing.T) { + services := []discovery.ServiceSummary{ + { + ID: "candidate-without-op", + Service: "LEDGER", + Healthy: true, + InvokeURI: "ledger-2:50052", + Ops: []string{"balance.read"}, + }, + { + ID: "candidate-with-op", + Service: "LEDGER", + Healthy: true, + InvokeURI: "ledger-1:50052", + Ops: []string{"ledger.debit"}, + }, + } + + endpoint, selected, ok := selectServiceEndpoint(services, []string{"LEDGER"}, []string{"ledger.debit"}) + if !ok { + t.Fatal("expected service endpoint to be selected") + } + if selected.ID != "candidate-with-op" { + t.Fatalf("expected candidate-with-op, got %s", selected.ID) + } + if endpoint.address != "ledger-1:50052" { + t.Fatalf("expected address ledger-1:50052, got %s", endpoint.address) + } +} + +func TestSelectGatewayEndpointPrefersNetworkAndOperation(t *testing.T) { + gateways := []discovery.GatewaySummary{ + { + ID: "high-priority-no-op", + Rail: "CRYPTO", + Network: "TRON_NILE", + Healthy: true, + InvokeURI: "gw-high:50053", + RoutingPriority: 10, + }, + { + ID: "low-priority-with-op", + Rail: "CRYPTO", + Network: "TRON_NILE", + Healthy: true, + InvokeURI: "gw-low:50053", + Ops: []string{"balance.read"}, + RoutingPriority: 1, + }, + { + ID: "different-network", + Rail: "CRYPTO", + Network: "ARBITRUM_ONE", + Healthy: true, + InvokeURI: "gw-other:50053", + Ops: []string{"balance.read"}, + RoutingPriority: 100, + }, + } + + endpoint, selected, ok := selectGatewayEndpoint(gateways, "TRON_NILE", []string{"balance.read"}) + if !ok { + t.Fatal("expected gateway endpoint to be selected") + } + if selected.ID != "low-priority-with-op" { + t.Fatalf("expected low-priority-with-op, got %s", selected.ID) + } + if endpoint.address != "gw-low:50053" { + t.Fatalf("expected address gw-low:50053, got %s", endpoint.address) + } +} diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index e602f8c9..de32fc7a 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -77,7 +77,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { } p.permissionRef = desc.ID - if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator); err != nil { + if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator, apiCtx.Config().PaymentQuotation); err != nil { p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err)) return nil, err } @@ -98,7 +98,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { return p, nil } -func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig) error { +func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig, quoteCfg *eapi.PaymentOrchestratorConfig) error { if cfg == nil { return merrors.InvalidArgument("payment orchestrator configuration is not provided") } @@ -111,11 +111,23 @@ func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig) erro return merrors.InvalidArgument(fmt.Sprintf("payment orchestrator address is not specified and address env %s is empty", cfg.AddressEnv)) } + quoteAddress := address + if quoteCfg != nil { + if addr := strings.TrimSpace(quoteCfg.Address); addr != "" { + quoteAddress = addr + } else if env := strings.TrimSpace(quoteCfg.AddressEnv); env != "" { + if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" { + quoteAddress = resolved + } + } + } + clientCfg := orchestratorclient.Config{ - Address: address, - DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second, - CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second, - Insecure: cfg.Insecure, + Address: address, + QuoteAddress: quoteAddress, + DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second, + CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second, + Insecure: cfg.Insecure, } client, err := orchestratorclient.New(context.Background(), clientCfg) diff --git a/api/server/internal/server/verificationimp/request.go b/api/server/internal/server/verificationimp/request.go index f47b0c4b..ca9cb69b 100644 --- a/api/server/internal/server/verificationimp/request.go +++ b/api/server/internal/server/verificationimp/request.go @@ -30,7 +30,7 @@ func (a *VerificationAPI) requestCode(r *http.Request, account *model.Account, t return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token") } - target := a.resolveTarget(req.Destination, account) + target := a.resolveTarget(req.Target, account) if target == "" { return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required") } @@ -51,7 +51,7 @@ func (a *VerificationAPI) requestCode(r *http.Request, account *model.Account, t return response.Accepted(a.logger, verificationResponse{ TTLSeconds: int(vReq.Ttl.Seconds()), CooldownSeconds: int(a.config.Cooldown.Seconds()), - Destination: mask.Email(target), + Target: mask.Email(target), IdempotencyKey: req.IdempotencyKey, }) } diff --git a/api/server/internal/server/verificationimp/store.go b/api/server/internal/server/verificationimp/store.go index 211480e1..2b985d45 100644 --- a/api/server/internal/server/verificationimp/store.go +++ b/api/server/internal/server/verificationimp/store.go @@ -21,11 +21,7 @@ func (s *ConfirmationStore) Create( ctx context.Context, request *verification.Request, ) (verificationCode string, err error) { - code, err := s.db.Create(ctx, request) - if err != nil { - return "", err - } - return code, nil + return s.db.Create(ctx, request) } func (s *ConfirmationStore) Verify( diff --git a/api/server/internal/server/verificationimp/types.go b/api/server/internal/server/verificationimp/types.go index 80306a7d..90c77ccc 100644 --- a/api/server/internal/server/verificationimp/types.go +++ b/api/server/internal/server/verificationimp/types.go @@ -6,7 +6,7 @@ import ( type verificationCodeRequest struct { Purpose string `json:"purpose"` - Destination string `json:"destination,omitempty"` + Target string `json:"target,omitempty"` IdempotencyKey string `json:"idempotencyKey"` } @@ -20,5 +20,5 @@ type verificationResponse struct { IdempotencyKey string `json:"idempotencyKey"` TTLSeconds int `json:"ttl_seconds"` CooldownSeconds int `json:"cooldown_seconds"` - Destination string `json:"destination"` + Target string `json:"target"` } diff --git a/api/server/internal/server/verificationimp/verify.go b/api/server/internal/server/verificationimp/verify.go index 196bf060..a8b90ff5 100644 --- a/api/server/internal/server/verificationimp/verify.go +++ b/api/server/internal/server/verificationimp/verify.go @@ -27,17 +27,20 @@ func (a *VerificationAPI) verifyCode(r *http.Request, account *model.Account, to return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error()) } - if strings.TrimSpace(req.Code) == "" { + code := strings.TrimSpace(req.Code) + if code == "" { return response.BadRequest(a.logger, a.Name(), "missing_code", "confirmation code is required") } - target := a.resolveTarget(req.Destination, account) + target := a.resolveTarget(req.Target, account) if target == "" { return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required") } - dst, err := a.store.Verify(r.Context(), account.ID, purpose, req.Code) + dst, err := a.store.Verify(r.Context(), account.ID, purpose, code) if err != nil { - a.logger.Debug("Code verification failed", zap.Error(err)) + a.logger.Debug("Code verification failed", zap.Error(err), + mzap.AccRef(account.ID), zap.String("purpose", req.Purpose), + ) return mutil.MapTokenErrorToResponse(a.logger, a.Name(), err) } if dst != target { diff --git a/ci/dev/bff.dockerfile b/ci/dev/bff.dockerfile index 61355c54..8fe4f6dd 100644 --- a/ci/dev/bff.dockerfile +++ b/ci/dev/bff.dockerfile @@ -17,6 +17,7 @@ RUN bash ci/scripts/proto/generate.sh # Copy service dependencies (needed for go.mod replace directives) COPY api/ledger ./api/ledger COPY api/payments/orchestrator ./api/payments/orchestrator +COPY api/payments/storage ./api/payments/storage COPY api/gateway/tron ./api/gateway/tron COPY api/billing/fees ./api/billing/fees COPY api/fx/oracle ./api/fx/oracle @@ -38,6 +39,7 @@ COPY --from=builder /src/api/pkg ./api/pkg # Copy service dependencies COPY --from=builder /src/api/ledger ./api/ledger COPY --from=builder /src/api/payments/orchestrator ./api/payments/orchestrator +COPY --from=builder /src/api/payments/storage ./api/payments/storage COPY --from=builder /src/api/gateway/tron ./api/gateway/tron COPY --from=builder /src/api/billing/fees ./api/billing/fees COPY --from=builder /src/api/fx/oracle ./api/fx/oracle diff --git a/ci/dev/payments-orchestrator.dockerfile b/ci/dev/payments-orchestrator.dockerfile index 94e27ed5..13b065af 100644 --- a/ci/dev/payments-orchestrator.dockerfile +++ b/ci/dev/payments-orchestrator.dockerfile @@ -15,6 +15,7 @@ COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh # Copy service dependencies (needed for go.mod replace directives) +COPY api/payments/storage ./api/payments/storage COPY api/ledger ./api/ledger COPY api/billing/fees ./api/billing/fees COPY api/fx/oracle ./api/fx/oracle @@ -35,6 +36,7 @@ COPY --from=builder /src/api/proto ./api/proto COPY --from=builder /src/api/pkg ./api/pkg # Copy service dependencies +COPY --from=builder /src/api/payments/storage ./api/payments/storage COPY --from=builder /src/api/ledger ./api/ledger COPY --from=builder /src/api/billing/fees ./api/billing/fees COPY --from=builder /src/api/fx/oracle ./api/fx/oracle diff --git a/ci/dev/payments-quotation.dockerfile b/ci/dev/payments-quotation.dockerfile new file mode 100644 index 00000000..c2cdc046 --- /dev/null +++ b/ci/dev/payments-quotation.dockerfile @@ -0,0 +1,52 @@ +# Development Dockerfile for Payments Quotation Service with Air hot reload + +FROM golang:alpine AS builder + +RUN apk add --no-cache bash git build-base protoc protobuf-dev && \ + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \ + go install github.com/air-verse/air@latest + +WORKDIR /src + +COPY api/proto ./api/proto +COPY api/pkg ./api/pkg +COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ +RUN bash ci/scripts/proto/generate.sh + +# Copy service dependencies (needed for go.mod replace directives) +COPY api/payments/storage ./api/payments/storage +COPY api/ledger ./api/ledger +COPY api/billing/fees ./api/billing/fees +COPY api/fx/oracle ./api/fx/oracle +COPY api/fx/storage ./api/fx/storage +COPY api/gateway/chain ./api/gateway/chain +COPY api/gateway/mntx ./api/gateway/mntx + +# Runtime stage for development with Air +FROM golang:alpine + +RUN apk add --no-cache bash git build-base && \ + go install github.com/air-verse/air@latest + +WORKDIR /src + +# Copy generated proto and pkg from builder +COPY --from=builder /src/api/proto ./api/proto +COPY --from=builder /src/api/pkg ./api/pkg + +# Copy service dependencies +COPY --from=builder /src/api/payments/storage ./api/payments/storage +COPY --from=builder /src/api/ledger ./api/ledger +COPY --from=builder /src/api/billing/fees ./api/billing/fees +COPY --from=builder /src/api/fx/oracle ./api/fx/oracle +COPY --from=builder /src/api/fx/storage ./api/fx/storage +COPY --from=builder /src/api/gateway/chain ./api/gateway/chain +COPY --from=builder /src/api/gateway/mntx ./api/gateway/mntx + +# Source code will be mounted at runtime +WORKDIR /src/api/payments/quotation + +EXPOSE 50064 9414 + +CMD ["air", "-c", ".air.toml", "--", "-config.file", "/app/config.yml", "-debug"] diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index 612f93e2..7a314669 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -141,6 +141,13 @@ PAYMENTS_SERVICE_NAME=sendico_payments_orchestrator PAYMENTS_GRPC_PORT=50062 PAYMENTS_METRICS_PORT=9403 +# Payments quotation stack +PAYMENTS_QUOTATION_DIR=payments_quotation +PAYMENTS_QUOTATION_COMPOSE_PROJECT=sendico-payments-quotation +PAYMENTS_QUOTATION_SERVICE_NAME=sendico_payments_quotation +PAYMENTS_QUOTATION_GRPC_PORT=50064 +PAYMENTS_QUOTATION_METRICS_PORT=9414 + # Payments orchestrator Mongo settings PAYMENTS_MONGO_HOST=sendico_db1 PAYMENTS_MONGO_PORT=27017 @@ -212,4 +219,4 @@ TRON_GATEWAY_MONGO_HOST=sendico_db1 TRON_GATEWAY_MONGO_PORT=27017 TRON_GATEWAY_MONGO_DATABASE=tron_gateway TRON_GATEWAY_MONGO_AUTH_SOURCE=admin -TRON_GATEWAY_MONGO_REPLICA_SET=sendico-rs \ No newline at end of file +TRON_GATEWAY_MONGO_REPLICA_SET=sendico-rs diff --git a/ci/prod/compose/bff.yml b/ci/prod/compose/bff.yml index 4bb1856f..39cb5bd4 100644 --- a/ci/prod/compose/bff.yml +++ b/ci/prod/compose/bff.yml @@ -32,6 +32,7 @@ services: CHAIN_GATEWAY_ADDRESS: ${CHAIN_GATEWAY_SERVICE_NAME}:${CHAIN_GATEWAY_GRPC_PORT} LEDGER_ADDRESS: ${LEDGER_SERVICE_NAME}:${LEDGER_GRPC_PORT} PAYMENTS_ADDRESS: ${PAYMENTS_SERVICE_NAME}:${PAYMENTS_GRPC_PORT} + PAYMENTS_QUOTE_ADDRESS: ${PAYMENTS_QUOTATION_SERVICE_NAME}:${PAYMENTS_QUOTATION_GRPC_PORT} MONGO_HOST: ${MONGO_HOST} MONGO_PORT: ${MONGO_PORT} MONGO_DATABASE: ${MONGO_DATABASE} diff --git a/ci/prod/compose/payments_quotation.dockerfile b/ci/prod/compose/payments_quotation.dockerfile new file mode 100644 index 00000000..5653703e --- /dev/null +++ b/ci/prod/compose/payments_quotation.dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.7 + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +FROM golang:alpine AS build +ARG APP_VERSION=dev +ARG GIT_REV=unknown +ARG BUILD_BRANCH=unknown +ARG BUILD_DATE=unknown +ARG BUILD_USER=ci +ENV GO111MODULE=on +ENV PATH="/go/bin:${PATH}" +WORKDIR /src +COPY . . +RUN apk add --no-cache bash git build-base protoc protobuf-dev \ + && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \ + && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest \ + && bash ci/scripts/proto/generate.sh +WORKDIR /src/api/payments/quotation +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "\ + -s -w \ + -X github.com/tech/sendico/payments/quotation/internal/appversion.Version=${APP_VERSION} \ + -X github.com/tech/sendico/payments/quotation/internal/appversion.Revision=${GIT_REV} \ + -X github.com/tech/sendico/payments/quotation/internal/appversion.Branch=${BUILD_BRANCH} \ + -X github.com/tech/sendico/payments/quotation/internal/appversion.BuildUser=${BUILD_USER} \ + -X github.com/tech/sendico/payments/quotation/internal/appversion.BuildDate=${BUILD_DATE}" \ + -o /out/payments-quotation . + +FROM alpine:latest AS runtime +RUN apk add --no-cache ca-certificates tzdata wget +WORKDIR /app +COPY api/payments/quotation/config.yml /app/config.yml +COPY --from=build /out/payments-quotation /app/payments-quotation +EXPOSE 50064 9414 +ENTRYPOINT ["/app/payments-quotation"] +CMD ["--config.file", "/app/config.yml"] diff --git a/ci/prod/compose/payments_quotation.yml b/ci/prod/compose/payments_quotation.yml new file mode 100644 index 00000000..a3b8d50c --- /dev/null +++ b/ci/prod/compose/payments_quotation.yml @@ -0,0 +1,47 @@ +# Compose v2 - Payments Quotation + +x-common-env: &common-env + env_file: + - ../env/.env.runtime + - ../env/.env.version + +networks: + sendico-net: + external: true + name: sendico-net + +services: + sendico_payments_quotation: + <<: *common-env + container_name: sendico-payments-quotation + restart: unless-stopped + image: ${REGISTRY_URL}/payments/quotation:${APP_V} + pull_policy: always + environment: + PAYMENTS_MONGO_HOST: ${PAYMENTS_MONGO_HOST} + PAYMENTS_MONGO_PORT: ${PAYMENTS_MONGO_PORT} + PAYMENTS_MONGO_DATABASE: ${PAYMENTS_MONGO_DATABASE} + PAYMENTS_MONGO_USER: ${PAYMENTS_MONGO_USER} + PAYMENTS_MONGO_PASSWORD: ${PAYMENTS_MONGO_PASSWORD} + PAYMENTS_MONGO_AUTH_SOURCE: ${PAYMENTS_MONGO_AUTH_SOURCE} + PAYMENTS_MONGO_REPLICA_SET: ${PAYMENTS_MONGO_REPLICA_SET} + NATS_URL: ${NATS_URL} + NATS_HOST: ${NATS_HOST} + NATS_PORT: ${NATS_PORT} + NATS_USER: ${NATS_USER} + NATS_PASSWORD: ${NATS_PASSWORD} + FEES_ADDRESS: ${FEES_SERVICE_NAME}:${FEES_GRPC_PORT} + ORACLE_ADDRESS: ${FX_ORACLE_SERVICE_NAME}:${FX_ORACLE_GRPC_PORT} + CHAIN_GATEWAY_ADDRESS: ${CHAIN_GATEWAY_SERVICE_NAME}:${CHAIN_GATEWAY_GRPC_PORT} + command: ["--config.file", "/app/config.yml"] + ports: + - "0.0.0.0:${PAYMENTS_QUOTATION_GRPC_PORT}:50064" + - "0.0.0.0:${PAYMENTS_QUOTATION_METRICS_PORT}:9414" + healthcheck: + test: ["CMD-SHELL","wget -qO- http://localhost:9414/health | grep -q '\"status\":\"ok\"'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - sendico-net diff --git a/ci/prod/scripts/deploy/payments_quotation.sh b/ci/prod/scripts/deploy/payments_quotation.sh new file mode 100755 index 00000000..f1bd3061 --- /dev/null +++ b/ci/prod/scripts/deploy/payments_quotation.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x +trap 'echo "[deploy-payments-quotation] error at line $LINENO" >&2' ERR + +: "${REMOTE_BASE:?missing REMOTE_BASE}" +: "${SSH_USER:?missing SSH_USER}" +: "${SSH_HOST:?missing SSH_HOST}" +: "${PAYMENTS_QUOTATION_DIR:?missing PAYMENTS_QUOTATION_DIR}" +: "${PAYMENTS_QUOTATION_COMPOSE_PROJECT:?missing PAYMENTS_QUOTATION_COMPOSE_PROJECT}" +: "${PAYMENTS_QUOTATION_SERVICE_NAME:?missing PAYMENTS_QUOTATION_SERVICE_NAME}" + +REMOTE_DIR="${REMOTE_BASE%/}/${PAYMENTS_QUOTATION_DIR}" +REMOTE_TARGET="${SSH_USER}@${SSH_HOST}" +COMPOSE_FILE="payments_quotation.yml" +SERVICE_NAMES="${PAYMENTS_QUOTATION_SERVICE_NAME}" + +REQUIRED_SECRETS=( + PAYMENTS_MONGO_USER + PAYMENTS_MONGO_PASSWORD +) + +for var in "${REQUIRED_SECRETS[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "missing required secret env: ${var}" >&2 + exit 65 + fi +done + +if [[ ! -s .env.version ]]; then + echo ".env.version is missing; run version step first" >&2 + exit 66 +fi + +b64enc() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +PAYMENTS_MONGO_USER_B64="$(b64enc "${PAYMENTS_MONGO_USER}")" +PAYMENTS_MONGO_PASSWORD_B64="$(b64enc "${PAYMENTS_MONGO_PASSWORD}")" + +SSH_OPTS=( + -i /root/.ssh/id_rsa + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o LogLevel=ERROR + -q +) +if [[ "${DEBUG_DEPLOY:-0}" = "1" ]]; then + SSH_OPTS=("${SSH_OPTS[@]/-q/}" -vv) +fi + +RSYNC_FLAGS=(-az --delete) +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && RSYNC_FLAGS=(-avz --delete) + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p ${REMOTE_DIR}/{compose,env}" + +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/compose/ "$REMOTE_TARGET:${REMOTE_DIR}/compose/" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/.env.runtime "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.runtime" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" .env.version "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.version" + +SERVICES_LINE="${SERVICE_NAMES}" + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \ + REMOTE_DIR="$REMOTE_DIR" \ + COMPOSE_FILE="$COMPOSE_FILE" \ + COMPOSE_PROJECT="$PAYMENTS_QUOTATION_COMPOSE_PROJECT" \ + SERVICES_LINE="$SERVICES_LINE" \ + PAYMENTS_MONGO_USER_B64="$PAYMENTS_MONGO_USER_B64" \ + PAYMENTS_MONGO_PASSWORD_B64="$PAYMENTS_MONGO_PASSWORD_B64" \ + bash -s <<'EOSSH' +set -euo pipefail +cd "${REMOTE_DIR}/compose" +set -a +. ../env/.env.runtime +load_kv_file() { + local file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then + local key="${line%%=*}" + local value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ -n "$key" ]]; then + export "$key=$value" + fi + fi + done <"$file" +} +load_kv_file ../env/.env.version +set +a + +if base64 -d >/dev/null 2>&1 <<<'AA=='; then + BASE64_DECODE_FLAG='-d' +else + BASE64_DECODE_FLAG='--decode' +fi + +decode_b64() { + val="$1" + if [[ -z "$val" ]]; then + printf '' + return + fi + printf '%s' "$val" | base64 "${BASE64_DECODE_FLAG}" +} + +PAYMENTS_MONGO_USER="$(decode_b64 "$PAYMENTS_MONGO_USER_B64")" +PAYMENTS_MONGO_PASSWORD="$(decode_b64 "$PAYMENTS_MONGO_PASSWORD_B64")" + +export PAYMENTS_MONGO_USER PAYMENTS_MONGO_PASSWORD +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" +export COMPOSE_PROJECT_NAME +read -r -a SERVICES <<<"${SERVICES_LINE}" + +pull_cmd=(docker compose -f "$COMPOSE_FILE" pull) +up_cmd=(docker compose -f "$COMPOSE_FILE" up -d --remove-orphans) +ps_cmd=(docker compose -f "$COMPOSE_FILE" ps) +if [[ "${#SERVICES[@]}" -gt 0 ]]; then + pull_cmd+=("${SERVICES[@]}") + up_cmd+=("${SERVICES[@]}") + ps_cmd+=("${SERVICES[@]}") +fi + +"${pull_cmd[@]}" +"${up_cmd[@]}" +"${ps_cmd[@]}" + +date -Is > .last_deploy +logger -t "deploy-${COMPOSE_PROJECT_NAME}" "${COMPOSE_PROJECT_NAME} deployed at $(date -Is) in ${REMOTE_DIR}" +EOSSH diff --git a/ci/scripts/payments_quotation/build-image.sh b/ci/scripts/payments_quotation/build-image.sh new file mode 100755 index 00000000..fd004439 --- /dev/null +++ b/ci/scripts/payments_quotation/build-image.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +PAYMENTS_QUOTATION_ENV_NAME="${PAYMENTS_QUOTATION_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${PAYMENTS_QUOTATION_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[payments-quotation-build] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +REGISTRY_URL="${REGISTRY_URL:?missing REGISTRY_URL}" +APP_V="${APP_V:?missing APP_V}" +PAYMENTS_QUOTATION_DOCKERFILE="${PAYMENTS_QUOTATION_DOCKERFILE:?missing PAYMENTS_QUOTATION_DOCKERFILE}" +PAYMENTS_QUOTATION_IMAGE_PATH="${PAYMENTS_QUOTATION_IMAGE_PATH:?missing PAYMENTS_QUOTATION_IMAGE_PATH}" + +REGISTRY_HOST="${REGISTRY_URL#http://}" +REGISTRY_HOST="${REGISTRY_HOST#https://}" +REGISTRY_USER="$(cat secrets/REGISTRY_USER)" +REGISTRY_PASSWORD="$(cat secrets/REGISTRY_PASSWORD)" +: "${REGISTRY_USER:?missing registry user}" +: "${REGISTRY_PASSWORD:?missing registry password}" + +mkdir -p /kaniko/.docker +AUTH_B64="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" +cat </kaniko/.docker/config.json +{ + "auths": { + "https://${REGISTRY_HOST}": { "auth": "${AUTH_B64}" } + } +} +EOC + +BUILD_CONTEXT="${PAYMENTS_QUOTATION_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}" +if [ ! -d "${BUILD_CONTEXT}" ]; then + BUILD_CONTEXT="/workspace" +fi + +/kaniko/executor \ + --context "${BUILD_CONTEXT}" \ + --dockerfile "${PAYMENTS_QUOTATION_DOCKERFILE}" \ + --destination "${REGISTRY_URL}/${PAYMENTS_QUOTATION_IMAGE_PATH}:${APP_V}" \ + --build-arg APP_VERSION="${APP_V}" \ + --build-arg GIT_REV="${GIT_REV}" \ + --build-arg BUILD_BRANCH="${BUILD_BRANCH}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg BUILD_USER="${BUILD_USER}" \ + --single-snapshot diff --git a/ci/scripts/payments_quotation/deploy.sh b/ci/scripts/payments_quotation/deploy.sh new file mode 100755 index 00000000..7a285328 --- /dev/null +++ b/ci/scripts/payments_quotation/deploy.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +PAYMENTS_QUOTATION_ENV_NAME="${PAYMENTS_QUOTATION_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${PAYMENTS_QUOTATION_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[payments-quotation-deploy] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +PAYMENTS_QUOTATION_MONGO_SECRET_PATH="${PAYMENTS_QUOTATION_MONGO_SECRET_PATH:?missing PAYMENTS_QUOTATION_MONGO_SECRET_PATH}" + +export PAYMENTS_MONGO_USER="$(./ci/vlt kv_get kv "${PAYMENTS_QUOTATION_MONGO_SECRET_PATH}" user)" +export PAYMENTS_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${PAYMENTS_QUOTATION_MONGO_SECRET_PATH}" password)" + +bash ci/prod/scripts/bootstrap/network.sh +bash ci/prod/scripts/deploy/payments_quotation.sh diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ba77277d..ee7e82f1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -451,6 +451,7 @@ services: dev-billing-fees: { condition: service_started } volumes: - ./api/payments/orchestrator:/src/api/payments/orchestrator + - ./api/payments/storage:/src/api/payments/storage - ./api/payments/orchestrator/config.dev.yml:/app/config.yml:ro ports: - "50062:50062" @@ -473,6 +474,50 @@ services: PAYMENTS_GRPC_PORT: 50062 PAYMENTS_METRICS_PORT: 9403 + # -------------------------------------------------------------------------- + # Payments Quotation Service + # -------------------------------------------------------------------------- + dev-payments-quotation: + <<: *common-env + build: + context: . + dockerfile: ci/dev/payments-quotation.dockerfile + image: sendico-dev/payments-quotation:latest + container_name: dev-payments-quotation + restart: unless-stopped + depends_on: + dev-mongo-init: { condition: service_completed_successfully } + dev-nats: { condition: service_started } + dev-discovery: { condition: service_started } + dev-fx-oracle: { condition: service_started } + dev-billing-fees: { condition: service_started } + dev-chain-gateway: { condition: service_started } + volumes: + - ./api/payments/quotation:/src/api/payments/quotation + - ./api/payments/storage:/src/api/payments/storage + - ./api/payments/quotation/config.dev.yml:/app/config.yml:ro + ports: + - "50064:50064" + - "9414:9414" + networks: + - sendico-dev + environment: + PAYMENTS_MONGO_HOST: dev-mongo-1 + PAYMENTS_MONGO_PORT: 27017 + PAYMENTS_MONGO_DATABASE: payments_orchestrator + PAYMENTS_MONGO_USER: ${MONGO_USER} + PAYMENTS_MONGO_PASSWORD: ${MONGO_PASSWORD} + PAYMENTS_MONGO_AUTH_SOURCE: admin + PAYMENTS_MONGO_REPLICA_SET: dev-rs + NATS_HOST: dev-nats + NATS_PORT: 4222 + NATS_USER: ${NATS_USER} + NATS_PASSWORD: ${NATS_PASSWORD} + NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222 + FEES_ADDRESS: dev-billing-fees:50060 + ORACLE_ADDRESS: dev-fx-oracle:50051 + CHAIN_GATEWAY_ADDRESS: dev-chain-gateway:50053 + # -------------------------------------------------------------------------- # Chain Gateway Vault Agent (sidecar for AppRole authentication) # -------------------------------------------------------------------------- @@ -763,6 +808,7 @@ services: dev-nats: { condition: service_started } dev-ledger: { condition: service_started } dev-payments-orchestrator: { condition: service_started } + dev-payments-quotation: { condition: service_started } dev-chain-gateway: { condition: service_started } volumes: - ./api/server:/src/api/server @@ -792,6 +838,7 @@ services: NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222 LEDGER_ADDRESS: dev-ledger:50052 PAYMENTS_ADDRESS: dev-payments-orchestrator:50062 + PAYMENTS_QUOTE_ADDRESS: dev-payments-quotation:50064 TRON_GATEWAY_ADDRESS: dev-tron-gateway:50070 BFF_HTTP_PORT: 8080 API_PROTOCOL: http diff --git a/frontend/pshared/lib/api/responses/verification/response.dart b/frontend/pshared/lib/api/responses/verification/response.dart index 55a37b64..dbacf86a 100644 --- a/frontend/pshared/lib/api/responses/verification/response.dart +++ b/frontend/pshared/lib/api/responses/verification/response.dart @@ -10,13 +10,13 @@ class VerificationResponse { @JsonKey(name: 'cooldown_seconds', defaultValue: 0) final int cooldownSeconds; @JsonKey(defaultValue: '') - final String destination; + final String target; final String idempotencyKey; const VerificationResponse({ required this.ttlSeconds, required this.cooldownSeconds, - required this.destination, + required this.target, required this.idempotencyKey, }); diff --git a/frontend/pshared/lib/provider/account.dart b/frontend/pshared/lib/provider/account.dart index eb147008..ce27fc8b 100644 --- a/frontend/pshared/lib/provider/account.dart +++ b/frontend/pshared/lib/provider/account.dart @@ -120,7 +120,7 @@ class AccountProvider extends ChangeNotifier { VerificationResponse confirmation, ) { final ttlSeconds = confirmation.ttlSeconds != 0 ? confirmation.ttlSeconds : pending.ttlSeconds; - final destination = confirmation.destination.isNotEmpty ? confirmation.destination : pending.destination; + final destination = confirmation.target.isNotEmpty ? confirmation.target : pending.destination; final cooldownSeconds = confirmation.cooldownSeconds; return pending.copyWith(