From 6444813f385b6e8f0b88016485b7778209a679aa Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 24 Feb 2026 13:01:35 +0100 Subject: [PATCH 01/23] payment quotation v2 + payment orchestration v2 draft --- Makefile | 6 +- api/billing/documents/go.mod | 40 +- api/billing/documents/go.sum | 80 +- api/billing/fees/go.mod | 4 +- api/billing/fees/go.sum | 8 +- .../service/fees/internal/resolver/impl.go | 5 +- api/discovery/go.mod | 4 +- api/discovery/go.sum | 8 +- api/fx/ingestor/go.mod | 4 +- api/fx/ingestor/go.sum | 8 +- .../ingestor/internal/market/cbr/connector.go | 2 +- .../internal/market/cbr/http_client.go | 5 +- api/fx/oracle/go.mod | 4 +- api/fx/oracle/go.sum | 8 +- api/gateway/chain/go.mod | 6 +- api/gateway/chain/go.sum | 12 +- .../chain/internal/service/gateway/service.go | 4 +- api/gateway/common/go.mod | 2 +- api/gateway/common/go.sum | 4 +- api/gateway/mntx/client/client.go | 3 +- api/gateway/mntx/client/config.go | 3 +- api/gateway/mntx/go.mod | 4 +- api/gateway/mntx/go.sum | 8 +- .../mntx/internal/service/gateway/service.go | 4 +- .../mntx/internal/service/monetix/sender.go | 6 +- api/gateway/tgsettle/go.mod | 4 +- api/gateway/tgsettle/go.sum | 8 +- .../internal/service/gateway/service.go | 9 +- api/gateway/tron/go.mod | 8 +- api/gateway/tron/go.sum | 16 +- .../tron/internal/service/gateway/service.go | 4 +- api/ledger/go.mod | 4 +- api/ledger/go.sum | 8 +- api/ledger/internal/service/ledger/service.go | 2 +- api/notification/go.mod | 4 +- api/notification/go.sum | 8 +- api/payments/methods/go.mod | 5 +- api/payments/methods/go.sum | 10 +- api/payments/orchestrator/client/client.go | 113 +-- api/payments/orchestrator/client/config.go | 9 +- api/payments/orchestrator/client/fake.go | 66 +- api/payments/orchestrator/go.mod | 6 +- api/payments/orchestrator/go.sum | 8 +- .../internal/server/internal/dependencies.go | 6 - .../server/internal/discovery_clients.go | 51 - .../server/internal/discovery_wrappers.go | 28 - .../internal/server/internal/serverimp.go | 2 +- .../service/orchestrationv2/agg/module.go | 15 +- .../service/orchestrationv2/agg/service.go | 38 +- .../service/orchestrationv2/erecon/errors.go | 9 + .../service/orchestrationv2/erecon/event.go | 271 +++++ .../orchestrationv2/erecon/matching.go | 97 ++ .../service/orchestrationv2/erecon/module.go | 141 +++ .../service/orchestrationv2/erecon/reduce.go | 119 +++ .../service/orchestrationv2/erecon/service.go | 259 +++++ .../orchestrationv2/erecon/service_test.go | 365 +++++++ .../orchestrationv2/idem/fake_store_test.go | 28 + .../orchestrationv2/idem/fingerprint.go | 23 +- .../orchestrationv2/idem/fingerprint_test.go | 100 ++ .../service/orchestrationv2/idem/module.go | 14 +- .../idem/{service_test.go => reuse_test.go} | 114 --- .../service/orchestrationv2/idem/service.go | 68 +- .../orchestrationv2/oobs/audit_store.go | 111 ++ .../service/orchestrationv2/oobs/events.go | 107 ++ .../service/orchestrationv2/oobs/helpers.go | 89 ++ .../service/orchestrationv2/oobs/metrics.go | 21 + .../service/orchestrationv2/oobs/module.go | 152 +++ .../service/orchestrationv2/oobs/service.go | 414 ++++++++ .../orchestrationv2/oobs/service_test.go | 296 ++++++ .../orchestrationv2/opagg/aggregate_test.go | 199 ++++ .../service/orchestrationv2/opagg/clone.go | 247 +++++ .../orchestrationv2/opagg/fixtures_test.go | 128 +++ .../service/orchestrationv2/opagg/helpers.go | 34 + .../service/orchestrationv2/opagg/keying.go | 218 ++++ .../orchestrationv2/opagg/merge_core.go | 137 +++ .../opagg/merge_quote_parts.go | 219 ++++ .../service/orchestrationv2/opagg/module.go | 49 + .../service/orchestrationv2/opagg/service.go | 192 ++++ .../orchestrationv2/ostate/aggregate_rules.go | 30 + .../orchestrationv2/ostate/aggregate_test.go | 129 +++ .../service/orchestrationv2/ostate/errors.go | 10 + .../service/orchestrationv2/ostate/module.go | 30 + .../service/orchestrationv2/ostate/service.go | 115 +++ .../orchestrationv2/ostate/step_rules.go | 37 + .../orchestrationv2/ostate/step_test.go | 130 +++ .../service/orchestrationv2/pquery/module.go | 50 + .../service/orchestrationv2/pquery/service.go | 425 ++++++++ .../service/orchestrationv2/prepo/document.go | 135 +++ .../service/orchestrationv2/prepo/errors.go | 9 + .../service/orchestrationv2/prepo/indexes.go | 40 + .../service/orchestrationv2/prepo/module.go | 63 ++ .../orchestrationv2/prepo/mongo_store.go | 212 ++++ .../service/orchestrationv2/prepo/service.go | 548 ++++++++++ .../orchestrationv2/prepo/service_test.go | 479 +++++++++ .../service/orchestrationv2/prmap/errors.go | 7 + .../service/orchestrationv2/prmap/helpers.go | 38 + .../orchestrationv2/prmap/intent_mapping.go | 203 ++++ .../orchestrationv2/prmap/invariants.go | 68 ++ .../service/orchestrationv2/prmap/module.go | 36 + .../orchestrationv2/prmap/quote_mapping.go | 324 ++++++ .../service/orchestrationv2/prmap/service.go | 82 ++ .../orchestrationv2/prmap/service_test.go | 373 +++++++ .../orchestrationv2/prmap/state_mapping.go | 120 +++ .../orchestrationv2/prmap/step_mapping.go | 94 ++ .../orchestrationv2/psvc/aggregate_state.go | 132 +++ .../orchestrationv2/psvc/default_executors.go | 53 + .../service/orchestrationv2/psvc/execute.go | 284 ++++++ .../service/orchestrationv2/psvc/external.go | 209 ++++ .../service/orchestrationv2/psvc/module.go | 72 ++ .../service/orchestrationv2/psvc/query.go | 166 +++ .../orchestrationv2/psvc/request_helpers.go | 128 +++ .../service/orchestrationv2/psvc/runtime.go | 422 ++++++++ .../service/orchestrationv2/psvc/service.go | 197 ++++ .../orchestrationv2/psvc/service_e2e_test.go | 670 ++++++++++++ .../orchestrationv2/qsnap/fake_store_test.go | 20 + .../service/orchestrationv2/qsnap/module.go | 20 +- .../qsnap/resolve_errors_test.go | 189 ++++ ...service_test.go => resolve_shapes_test.go} | 189 ---- .../service/orchestrationv2/qsnap/service.go | 73 +- .../service/orchestrationv2/reqval/module.go | 18 +- .../orchestrationv2/reqval/validator.go | 56 +- .../service/orchestrationv2/sexec/errors.go | 8 + .../service/orchestrationv2/sexec/module.go | 77 ++ .../service/orchestrationv2/sexec/routes.go | 112 ++ .../service/orchestrationv2/sexec/service.go | 145 +++ .../orchestrationv2/sexec/service_test.go | 269 +++++ .../service/orchestrationv2/ssched/input.go | 271 +++++ .../service/orchestrationv2/ssched/module.go | 91 ++ .../service/orchestrationv2/ssched/service.go | 324 ++++++ .../orchestrationv2/ssched/service_test.go | 327 ++++++ .../service/orchestrationv2/xerr/wrap.go | 37 + .../{service_test.go => compile_flow_test.go} | 260 ----- .../xplan/compile_policy_test.go | 219 ++++ .../orchestrationv2/xplan/expansion.go | 117 +++ .../service/orchestrationv2/xplan/helpers.go | 121 +++ .../service/orchestrationv2/xplan/module.go | 16 +- .../service/orchestrationv2/xplan/route.go | 182 ++++ .../service/orchestrationv2/xplan/service.go | 927 +---------------- .../xplan/service_boundaries.go | 260 +++++ .../orchestrationv2/xplan/service_policy.go | 254 +++++ .../xplan/test_helpers_test.go | 57 ++ .../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/card_payout_test.go | 399 -------- .../service/orchestrator/command_factory.go | 77 -- .../composite_gateway_registry.go | 65 -- .../internal/service/orchestrator/convert.go | 959 ------------------ .../service/orchestrator/convert_card_test.go | 71 -- .../orchestrator/convert_types_test.go | 124 --- .../discovery_gateway_registry.go | 212 ---- .../discovery_gateway_registry_test.go | 62 -- .../service/orchestrator/execution_compat.go | 103 -- .../gateway_execution_consumer.go | 295 ------ .../gateway_execution_consumer_test.go | 105 -- .../service/orchestrator/gateway_registry.go | 117 --- .../gateway_registry_identity_test.go | 72 -- .../orchestrator/gateway_resolution.go | 142 --- .../service/orchestrator/handlers_commands.go | 405 -------- .../service/orchestrator/handlers_events.go | 318 ------ .../service/orchestrator/handlers_queries.go | 81 -- .../internal/service/orchestrator/helpers.go | 299 ------ .../service/orchestrator/helpers_test.go | 107 -- .../service/orchestrator/internal_helpers.go | 68 -- .../orchestrator/internal_helpers_test.go | 53 - .../internal/service/orchestrator/metrics.go | 65 -- .../service/orchestrator/model_money.go | 13 - .../internal/service/orchestrator/options.go | 549 +++------- .../orchestrator/options_rail_gateway_test.go | 145 --- .../service/orchestrator/payment_executor.go | 173 ---- .../service/orchestrator/payment_plan_card.go | 196 ---- .../orchestrator/payment_plan_chain.go | 116 --- .../orchestrator/payment_plan_executor.go | 200 ---- .../payment_plan_executor_test.go | 217 ---- .../orchestrator/payment_plan_ledger.go | 596 ----------- .../orchestrator/payment_plan_ledger_test.go | 114 --- .../orchestrator/payment_plan_release.go | 50 - .../orchestrator/payment_plan_release_test.go | 84 -- .../orchestrator/payment_plan_steps.go | 427 -------- .../orchestrator/payment_plan_storage.go | 112 -- .../service/orchestrator/plan_builder.go | 7 - .../orchestrator/provider_settlement.go | 132 --- .../provider_settlement_gateway.go | 180 ---- .../service/orchestrator/rail_endpoints.go | 99 -- .../orchestrator/rail_gateway_fake_test.go | 57 -- .../internal/service/orchestrator/service.go | 198 +--- .../service/orchestrator/service_helpers.go | 269 ----- .../orchestrator/service_helpers_test.go | 610 ----------- .../orchestrator/service_registration_test.go | 74 ++ .../service/orchestrator/service_test.go | 646 ------------ .../service/orchestrator/service_v2.go | 112 ++ .../internal/service/plan_builder/gateways.go | 153 +-- api/payments/quotation/go.mod | 6 +- api/payments/quotation/go.sum | 8 +- .../internal/server/internal/serverimp.go | 2 +- .../internal/service/plan/builder.go | 2 +- .../service/plan/plan_builder_gateways.go | 153 +-- .../batch_quote_processor_v2/service.go | 11 +- .../service/quotation/command_factory.go | 71 -- .../service/quotation/compat_helpers.go | 6 - .../quotation/composite_gateway_registry.go | 65 -- .../internal/service/quotation/convert.go | 292 ------ .../quotation/discovery_gateway_registry.go | 212 ---- .../discovery_gateway_registry_test.go | 62 -- .../quotation/gateway_execution_consumer.go | 12 - .../funding_profile_resolver_static.go | 3 - .../funding_profile_resolver_static_test.go | 3 +- .../service/quotation/gateway_registry.go | 117 --- .../gateway_registry_identity_test.go | 72 -- .../service/quotation/gateway_resolution.go | 20 +- .../service/quotation/handlers_commands.go | 640 ------------ .../quotation/handlers_commands_test.go | 195 ---- .../internal/service/quotation/helpers.go | 18 - .../service/quotation/internal_helpers.go | 16 - .../internal/service/quotation/metrics.go | 65 -- .../internal/service/quotation/model_money.go | 13 - .../internal/service/quotation/options.go | 315 +++--- .../service/quotation/payment_plan_factory.go | 161 --- .../service/quotation/plan_builder.go | 28 - .../quotation/plan_builder_adapters.go | 22 +- .../service/quotation/plan_builder_compat.go | 4 - .../quotation/provider_settlement_gateway.go | 180 ---- .../service/quotation/quotation_app.go | 17 +- .../service/quotation/quotation_app_test.go | 56 + .../service/quotation/quotation_service.go | 24 - .../quotation/quotation_service_v2/logging.go | 143 +++ .../quotation_service_v2/process_batch.go | 95 +- .../quotation_service_v2/process_single.go | 108 +- .../quotation/quotation_service_v2/service.go | 13 +- .../quotation_service_v2/service_e2e_test.go | 7 +- .../quotation_service_v2/single_processor.go | 35 +- .../service/quotation/quotation_v2_wiring.go | 186 ++++ .../internal/service/quotation/quote_batch.go | 145 --- .../quote_computation_service/compute.go | 56 +- .../quote_computation_service/economics.go | 13 - .../quote_computation_service/error_wrap.go | 16 + .../gateway_selector.go | 144 ++- .../quote_computation_service/helpers.go | 2 - .../quote_computation_service/planner.go | 177 +++- .../planner_path_finding.go | 105 ++ .../quote_binding_validation.go | 4 - .../quote_computation_service/service.go | 12 + .../service/quotation/quote_engine.go | 36 +- .../internal/service/quotation/service.go | 63 +- .../service/quotation/service_helpers.go | 191 ---- .../transfer_intent_hydrator/hydrator.go | 11 +- api/payments/storage/go.mod | 1 + api/payments/storage/go.sum | 2 + .../model}/gateway_eligibility.go | 150 ++- .../storage/model/gateway_eligibility_test.go | 49 + api/payments/storage/model/payment.go | 25 +- api/payments/storage/model/rail_operations.go | 93 ++ .../storage/model/rail_operations_test.go | 65 ++ api/payments/storage/mongo/repository.go | 16 +- api/pkg/discovery/rail_vocab.go | 142 +++ api/pkg/discovery/rail_vocab_test.go | 49 + api/pkg/discovery/registry.go | 4 +- api/pkg/go.mod | 4 +- api/pkg/go.sum | 8 +- api/pkg/messaging/internal/natsb/broker.go | 4 +- api/pkg/server/grpcapp/app.go | 9 +- .../orchestration/v1/orchestration.proto | 155 --- .../payments/quotation/v1/quotation.proto | 47 - api/server/go.mod | 42 +- api/server/go.sum | 84 +- api/server/interface/api/srequest/payment.go | 21 +- .../api/srequest/payment_validate_test.go | 29 + api/server/interface/api/sresponse/payment.go | 98 +- .../internal/server/paymentapiimp/list.go | 83 +- .../internal/server/paymentapiimp/mapper.go | 351 +++---- .../internal/server/paymentapiimp/pay.go | 67 +- .../internal/server/paymentapiimp/paybatch.go | 23 +- .../internal/server/paymentapiimp/quote.go | 24 +- .../internal/server/paymentapiimp/service.go | 21 +- ci/scripts/proto/generate.sh | 10 +- .../lib/data/mapper/payment/enums.dart | 10 +- .../data/mapper/payment/payment_response.dart | 39 +- .../pshared/lib/models/payment/payment.dart | 11 +- .../pshared/lib/models/payment/state.dart | 81 ++ frontend/pshared/lib/models/storable.dart | 17 +- .../lib/provider/payment/payments.dart | 118 +-- .../lib/service/authorization/service.dart | 138 ++- frontend/pshared/lib/service/device_id.dart | 5 +- .../pshared/lib/service/payment/service.dart | 41 +- .../payment/payment_state_model_test.dart | 122 +++ .../test/payment/request_dto_format_test.dart | 113 +++ frontend/pweb/test/widget_test.dart | 47 +- 289 files changed, 17005 insertions(+), 16065 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/erecon/errors.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/erecon/matching.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/idem/fake_store_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint_test.go rename api/payments/orchestrator/internal/service/orchestrationv2/idem/{service_test.go => reuse_test.go} (62%) create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/oobs/audit_store.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/oobs/events.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/oobs/helpers.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/oobs/metrics.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/oobs/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/oobs/service_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/aggregate_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/clone.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/helpers.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/keying.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_core.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_quote_parts.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/opagg/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_rules.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ostate/errors.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ostate/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_rules.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/pquery/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prepo/errors.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/errors.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/helpers.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/external.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/query.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/request_helpers.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/qsnap/fake_store_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go rename api/payments/orchestrator/internal/service/orchestrationv2/qsnap/{service_test.go => resolve_shapes_test.go} (58%) create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/sexec/errors.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xerr/wrap.go rename api/payments/orchestrator/internal/service/orchestrationv2/xplan/{service_test.go => compile_flow_test.go} (56%) create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/helpers.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/test_helpers_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/command_factory.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/convert.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/execution_compat.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/handlers_events.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/helpers.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/helpers_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/metrics.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/model_money.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_executor.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/plan_builder.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/service_helpers.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go delete mode 100644 api/payments/orchestrator/internal/service/orchestrator/service_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/service_v2.go delete mode 100644 api/payments/quotation/internal/service/quotation/command_factory.go delete mode 100644 api/payments/quotation/internal/service/quotation/compat_helpers.go delete mode 100644 api/payments/quotation/internal/service/quotation/composite_gateway_registry.go delete mode 100644 api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go delete mode 100644 api/payments/quotation/internal/service/quotation/discovery_gateway_registry_test.go delete mode 100644 api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go delete mode 100644 api/payments/quotation/internal/service/quotation/gateway_registry.go delete mode 100644 api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go delete mode 100644 api/payments/quotation/internal/service/quotation/handlers_commands.go delete mode 100644 api/payments/quotation/internal/service/quotation/handlers_commands_test.go delete mode 100644 api/payments/quotation/internal/service/quotation/metrics.go delete mode 100644 api/payments/quotation/internal/service/quotation/model_money.go delete mode 100644 api/payments/quotation/internal/service/quotation/payment_plan_factory.go delete mode 100644 api/payments/quotation/internal/service/quotation/plan_builder.go delete mode 100644 api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go create mode 100644 api/payments/quotation/internal/service/quotation/quotation_app_test.go delete mode 100644 api/payments/quotation/internal/service/quotation/quotation_service.go create mode 100644 api/payments/quotation/internal/service/quotation/quotation_service_v2/logging.go create mode 100644 api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go delete mode 100644 api/payments/quotation/internal/service/quotation/quote_batch.go create mode 100644 api/payments/quotation/internal/service/quotation/quote_computation_service/error_wrap.go delete mode 100644 api/payments/quotation/internal/service/quotation/service_helpers.go rename api/payments/{orchestrator/internal/service/orchestrator => storage/model}/gateway_eligibility.go (52%) create mode 100644 api/payments/storage/model/gateway_eligibility_test.go create mode 100644 api/payments/storage/model/rail_operations.go create mode 100644 api/payments/storage/model/rail_operations_test.go create mode 100644 api/pkg/discovery/rail_vocab.go create mode 100644 api/pkg/discovery/rail_vocab_test.go delete mode 100644 api/proto/payments/orchestration/v1/orchestration.proto delete mode 100644 api/proto/payments/quotation/v1/quotation.proto create mode 100644 api/server/interface/api/srequest/payment_validate_test.go create mode 100644 frontend/pshared/lib/models/payment/state.dart create mode 100644 frontend/pshared/test/payment/payment_state_model_test.dart create mode 100644 frontend/pshared/test/payment/request_dto_format_test.dart diff --git a/Makefile b/Makefile index 845aaca6..9841257c 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,7 @@ init: @echo "$(GREEN)Verifying .env.dev...$(NC)" @cat .env.dev | grep -q "MONGO_USER=" || (echo "$(YELLOW)Error: .env.dev is incomplete$(NC)" && exit 1) @echo "$(GREEN)Running proto generation...$(NC)" - @./generate_protos.sh + @./ci/scripts/proto/generate.sh @echo "$(GREEN)Building Docker images...$(NC)" @$(COMPOSE) build @echo "$(GREEN)✅ Initialization complete!$(NC)" @@ -97,7 +97,7 @@ init: # Build all images build: @echo "$(GREEN)Building all service images...$(NC)" - @./generate_protos.sh + @./ci/scripts/proto/generate.sh @$(COMPOSE) build # Start all services @@ -154,7 +154,7 @@ generate: generate-api generate-frontend # Generate protobuf code generate-api: @echo "$(GREEN)Generating protobuf code...$(NC)" - @./generate_protos.sh + @./ci/scripts/proto/generate.sh @echo "$(GREEN)✅ Protobuf generation complete$(NC)" # Generate Flutter code (json_serializable, etc.) diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index e201776c..18d3f1da 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -5,10 +5,10 @@ go 1.25.7 replace github.com/tech/sendico/pkg => ../../pkg require ( - github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.9 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 + github.com/aws/aws-sdk-go-v2 v1.41.2 + github.com/aws/aws-sdk-go-v2/config v1.32.10 + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 github.com/jung-kurt/gofpdf v1.16.2 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 @@ -20,21 +20,21 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect - github.com/aws/smithy-go v1.24.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect + github.com/aws/smithy-go v1.24.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/casbin/casbin/v2 v2.135.0 // indirect @@ -48,7 +48,7 @@ require ( 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/nats.go v1.49.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 @@ -65,6 +65,6 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index 25283cb1..0fd9262f 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -4,44 +4,44 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 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/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= -github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= -github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= +github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= +github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= +github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 h1:IJRzQTvdpjHRPItx9gzNcz7Y1F+xqAR+xiy9rr5ZYl8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 h1:giB30dEeoar5bgDnkE0q+z7cFjcHaCjulpmPVmuKR84= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= +github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 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= @@ -134,8 +134,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -258,8 +258,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index c76eaffa..5239fa89 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -32,7 +32,7 @@ require ( 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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/prometheus/client_golang v1.23.2 @@ -50,6 +50,6 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect google.golang.org/protobuf v1.36.11 ) diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index a7714150..d26defcf 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -208,8 +208,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/billing/fees/internal/service/fees/internal/resolver/impl.go b/api/billing/fees/internal/service/fees/internal/resolver/impl.go index 881ecd2d..d490a5f3 100644 --- a/api/billing/fees/internal/service/fees/internal/resolver/impl.go +++ b/api/billing/fees/internal/service/fees/internal/resolver/impl.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage/model" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" @@ -22,10 +23,10 @@ type planFinder interface { type feeResolver struct { plans storage.PlansStore finder planFinder - logger *zap.Logger + logger mlogger.Logger } -func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver { +func New(plans storage.PlansStore, logger mlogger.Logger) *feeResolver { var finder planFinder if pf, ok := plans.(planFinder); ok { finder = pf diff --git a/api/discovery/go.mod b/api/discovery/go.mod index 9fd4a53a..92250bfc 100644 --- a/api/discovery/go.mod +++ b/api/discovery/go.mod @@ -25,7 +25,7 @@ require ( 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/nats.go v1.49.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 @@ -43,7 +43,7 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/discovery/go.sum b/api/discovery/go.sum index a7714150..d26defcf 100644 --- a/api/discovery/go.sum +++ b/api/discovery/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -208,8 +208,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index 1e894f9a..9cf1a260 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -30,7 +30,7 @@ require ( 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/nats.go v1.49.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 @@ -47,7 +47,7 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index a7714150..d26defcf 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -208,8 +208,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/ingestor/internal/market/cbr/connector.go b/api/fx/ingestor/internal/market/cbr/connector.go index 565164c0..f3d90c70 100644 --- a/api/fx/ingestor/internal/market/cbr/connector.go +++ b/api/fx/ingestor/internal/market/cbr/connector.go @@ -415,7 +415,7 @@ type valuteMapping struct { byID map[string]valuteInfo } -func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:cyclop,gocognit,nestif +func buildValuteMapping(logger mlogger.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:cyclop,gocognit,nestif byISO := make(map[string]valuteInfo, len(items)) byID := make(map[string]valuteInfo, len(items)) byNum := make(map[string]string, len(items)) diff --git a/api/fx/ingestor/internal/market/cbr/http_client.go b/api/fx/ingestor/internal/market/cbr/http_client.go index 1f5deecd..c6878b7e 100644 --- a/api/fx/ingestor/internal/market/cbr/http_client.go +++ b/api/fx/ingestor/internal/market/cbr/http_client.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) @@ -17,7 +18,7 @@ const ( type httpClient struct { client *http.Client headers http.Header - logger *zap.Logger + logger mlogger.Logger } type httpClientOptions struct { @@ -26,7 +27,7 @@ type httpClientOptions struct { referer string } -func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptions) *httpClient { +func newHTTPClient(logger mlogger.Logger, client *http.Client, opts httpClientOptions) *httpClient { userAgent := opts.userAgent if strings.TrimSpace(userAgent) == "" { userAgent = defaultUserAgent diff --git a/api/fx/oracle/go.mod b/api/fx/oracle/go.mod index ce9b235e..c61aa700 100644 --- a/api/fx/oracle/go.mod +++ b/api/fx/oracle/go.mod @@ -31,7 +31,7 @@ require ( 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/nats.go v1.49.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 @@ -48,5 +48,5 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/fx/oracle/go.sum b/api/fx/oracle/go.sum index a7714150..d26defcf 100644 --- a/api/fx/oracle/go.sum +++ b/api/fx/oracle/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -208,8 +208,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 6b2dc4b4..e3848599 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -7,7 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg replace github.com/tech/sendico/gateway/common => ../common require ( - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/ethereum/go-ethereum v1.17.0 github.com/hashicorp/vault/api v1.22.0 github.com/mitchellh/mapstructure v1.5.0 @@ -61,7 +61,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -91,5 +91,5 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 18c45618..47cbf45d 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -60,8 +60,8 @@ github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+Zlfu github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 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= @@ -204,8 +204,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -355,8 +355,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index 9e49112f..ad157adf 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -224,8 +224,8 @@ func (s *Service) startDiscoveryAnnouncers() { announce := discovery.Announcement{ ID: discovery.StableCryptoRailGatewayID(string(network.Name)), Service: "CRYPTO_RAIL_GATEWAY", - Rail: "CRYPTO", - Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"}, + Rail: discovery.RailCrypto, + Operations: discovery.CryptoRailGatewayOperations(), Currencies: currencies, InvokeURI: s.invokeURI, Version: version, diff --git a/api/gateway/common/go.mod b/api/gateway/common/go.mod index 8be7a4a6..de3ff413 100644 --- a/api/gateway/common/go.mod +++ b/api/gateway/common/go.mod @@ -14,7 +14,7 @@ require ( 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/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/api/gateway/common/go.sum b/api/gateway/common/go.sum index 61889025..7b41251b 100644 --- a/api/gateway/common/go.sum +++ b/api/gateway/common/go.sum @@ -60,8 +60,8 @@ 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/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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go index 4837d793..8bf36817 100644 --- a/api/gateway/mntx/client/client.go +++ b/api/gateway/mntx/client/client.go @@ -7,6 +7,7 @@ import ( "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model/account_role" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" @@ -35,7 +36,7 @@ type gatewayClient struct { conn *grpc.ClientConn client grpcConnectorClient cfg Config - logger *zap.Logger + logger mlogger.Logger } // New dials the Monetix gateway. diff --git a/api/gateway/mntx/client/config.go b/api/gateway/mntx/client/config.go index 0701d8ab..1d2a1103 100644 --- a/api/gateway/mntx/client/config.go +++ b/api/gateway/mntx/client/config.go @@ -3,6 +3,7 @@ package client import ( "time" + "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) @@ -11,7 +12,7 @@ type Config struct { Address string DialTimeout time.Duration CallTimeout time.Duration - Logger *zap.Logger + Logger mlogger.Logger } func (c *Config) setDefaults() { diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index d2a80454..9bff5860 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -32,7 +32,7 @@ require ( 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/nats.go v1.49.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 @@ -51,5 +51,5 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index ffcdff38..7ad1510d 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -210,8 +210,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index 3205e7ed..27abd0ee 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -152,8 +152,8 @@ func (s *Service) startDiscoveryAnnouncer() { } announce := discovery.Announcement{ Service: "CARD_PAYOUT_RAIL_GATEWAY", - Rail: "CARD_PAYOUT", - Operations: []string{"payout.card", "observe.confirm"}, + Rail: discovery.RailCardPayout, + Operations: discovery.CardPayoutRailGatewayOperations(), InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } diff --git a/api/gateway/mntx/internal/service/monetix/sender.go b/api/gateway/mntx/internal/service/monetix/sender.go index f5682e7a..35f87995 100644 --- a/api/gateway/mntx/internal/service/monetix/sender.go +++ b/api/gateway/mntx/internal/service/monetix/sender.go @@ -10,6 +10,7 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) @@ -312,10 +313,7 @@ func clearSignature(req any) (func(string), error) { } } -func logRequestDeadline(logger *zap.Logger, ctx context.Context, url string) { - if logger == nil { - return - } +func logRequestDeadline(logger mlogger.Logger, ctx context.Context, url string) { if ctx == nil { logger.Info("Monetix request context is nil", zap.String("url", url)) return diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod index f9affff6..ac37d177 100644 --- a/api/gateway/tgsettle/go.mod +++ b/api/gateway/tgsettle/go.mod @@ -30,7 +30,7 @@ require ( 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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -48,5 +48,5 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum index a7714150..d26defcf 100644 --- a/api/gateway/tgsettle/go.sum +++ b/api/gateway/tgsettle/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -208,8 +208,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index 5df91b26..ba58185f 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -526,14 +526,11 @@ func (s *Service) startAnnouncer() { if s == nil || s.producer == nil { return } - caps := []string{"telegram_confirmation", "money_persistence", "observe.confirm", "payout.fiat"} - if s.rail != "" { - caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail)) - } + caps := discovery.CardPayoutRailGatewayOperations() announce := discovery.Announcement{ - ID: discovery.StablePaymentGatewayID(s.rail), + ID: discovery.StablePaymentGatewayID(discovery.NormalizeRail(s.rail)), Service: string(mservice.PaymentGateway), - Rail: s.rail, + Rail: discovery.NormalizeRail(s.rail), Operations: caps, InvokeURI: s.invokeURI, } diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index 092810f6..859ab524 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -7,7 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg replace github.com/tech/sendico/gateway/common => ../common require ( - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/ethereum/go-ethereum v1.17.0 github.com/fbsobreira/gotron-sdk v0.24.1 github.com/hashicorp/vault/api v1.22.0 @@ -65,7 +65,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pborman/uuid v1.2.1 // indirect @@ -99,6 +99,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index c1136877..7a144168 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -64,8 +64,8 @@ github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+Zlfu github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 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= @@ -211,8 +211,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -374,10 +374,10 @@ 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/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU= +google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tron/internal/service/gateway/service.go b/api/gateway/tron/internal/service/gateway/service.go index 35c7b30f..650680cf 100644 --- a/api/gateway/tron/internal/service/gateway/service.go +++ b/api/gateway/tron/internal/service/gateway/service.go @@ -229,8 +229,8 @@ func (s *Service) startDiscoveryAnnouncers() { announce := discovery.Announcement{ ID: discovery.StableCryptoRailGatewayID(network.Name.String()), Service: "CRYPTO_RAIL_GATEWAY", - Rail: "CRYPTO", - Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"}, + Rail: discovery.RailCrypto, + Operations: discovery.CryptoRailGatewayOperations(), Currencies: currencies, InvokeURI: s.invokeURI, Version: version, diff --git a/api/ledger/go.mod b/api/ledger/go.mod index 2767736a..dc77f0a6 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -31,7 +31,7 @@ require ( 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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -49,5 +49,5 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/ledger/go.sum b/api/ledger/go.sum index 8025ed84..4c6fc8f0 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -210,8 +210,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index 2773fe29..a46eca22 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -365,7 +365,7 @@ func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryReq return responder(ctx) } -func (s *Service) logLedgerOperation(op string, logger *zap.Logger, resp *ledgerv1.PostResponse, err error) { +func (s *Service) logLedgerOperation(op string, logger mlogger.Logger, resp *ledgerv1.PostResponse, err error) { if logger == nil { return } diff --git a/api/notification/go.mod b/api/notification/go.mod index eda25add..de5a8c30 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -31,7 +31,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // 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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/prometheus/client_golang v1.23.2 // indirect @@ -50,7 +50,7 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/notification/go.sum b/api/notification/go.sum index 400caa47..2a3943ad 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -95,8 +95,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -225,8 +225,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/methods/go.mod b/api/payments/methods/go.mod index 0be5e3b3..be3b733e 100644 --- a/api/payments/methods/go.mod +++ b/api/payments/methods/go.mod @@ -30,13 +30,14 @@ require ( 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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/prometheus/client_golang v1.23.2 // 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/shopspring/decimal v1.4.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 @@ -48,5 +49,5 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/payments/methods/go.sum b/api/payments/methods/go.sum index a7714150..4c6fc8f0 100644 --- a/api/payments/methods/go.sum +++ b/api/payments/methods/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -121,6 +121,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i 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= @@ -208,8 +210,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/orchestrator/client/client.go b/api/payments/orchestrator/client/client.go index 86f67755..f0536176 100644 --- a/api/payments/orchestrator/client/client.go +++ b/api/payments/orchestrator/client/client.go @@ -8,8 +8,7 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" - orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" @@ -17,33 +16,22 @@ import ( // Client exposes typed helpers around the payment orchestration and quotation gRPC APIs. type Client interface { - InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) - InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) - CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) - GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) - ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) - InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) - ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) - ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) + ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) + ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) Close() error } type grpcOrchestratorClient interface { - 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) - GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error) - ListPayments(ctx context.Context, in *orchestratorv1.ListPaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.ListPaymentsResponse, error) - InitiateConversion(ctx context.Context, in *orchestratorv1.InitiateConversionRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiateConversionResponse, error) - ProcessTransferUpdate(ctx context.Context, in *orchestratorv1.ProcessTransferUpdateRequest, opts ...grpc.CallOption) (*orchestratorv1.ProcessTransferUpdateResponse, error) - ProcessDepositObserved(ctx context.Context, in *orchestratorv1.ProcessDepositObservedRequest, opts ...grpc.CallOption) (*orchestratorv1.ProcessDepositObservedResponse, error) + ExecutePayment(ctx context.Context, in *orchestrationv2.ExecutePaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.ExecutePaymentResponse, error) + GetPayment(ctx context.Context, in *orchestrationv2.GetPaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.GetPaymentResponse, error) + ListPayments(ctx context.Context, in *orchestrationv2.ListPaymentsRequest, opts ...grpc.CallOption) (*orchestrationv2.ListPaymentsResponse, error) } type orchestratorClient struct { - cfg Config - conn *grpc.ClientConn - quoteConn *grpc.ClientConn - client grpcOrchestratorClient + cfg Config + conn *grpc.ClientConn + client grpcOrchestratorClient } // New dials the payment orchestrator endpoint and returns a ready client. @@ -52,29 +40,16 @@ 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: orchestrationv1.NewPaymentExecutionServiceClient(conn), + cfg: cfg, + conn: conn, + client: orchestrationv2.NewPaymentOrchestratorServiceClient(conn), }, nil } @@ -99,11 +74,6 @@ func dial(ctx context.Context, cfg Config, address string, opts ...grpc.DialOpti // NewWithClient injects a pre-built orchestrator client (useful for tests). func NewWithClient(cfg Config, oc grpcOrchestratorClient) Client { - return NewWithClients(cfg, oc) -} - -// NewWithClients injects pre-built orchestrator and quotation clients (useful for tests). -func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client { cfg.setDefaults() return &orchestratorClient{ cfg: cfg, @@ -111,69 +81,36 @@ func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client { } } +// NewWithClients injects pre-built orchestrator and quotation clients (useful for tests). +func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client { + return NewWithClient(cfg, oc) +} + func (c *orchestratorClient) Close() error { - var firstErr error - if c.quoteConn != nil && c.quoteConn != c.conn { - if err := c.quoteConn.Close(); err != nil { - firstErr = err - } + if c == nil || c.conn == nil { + return nil } - if c.conn != nil { - if err := c.conn.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr + return c.conn.Close() } -func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { +func (c *orchestratorClient) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.InitiatePayments(ctx, req) + return c.client.ExecutePayment(ctx, req) } -func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { - ctx, cancel := c.callContext(ctx) - defer cancel() - return c.client.InitiatePayment(ctx, req) -} - -func (c *orchestratorClient) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { - ctx, cancel := c.callContext(ctx) - defer cancel() - return c.client.CancelPayment(ctx, req) -} - -func (c *orchestratorClient) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) { +func (c *orchestratorClient) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() return c.client.GetPayment(ctx, req) } -func (c *orchestratorClient) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) { +func (c *orchestratorClient) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() return c.client.ListPayments(ctx, req) } -func (c *orchestratorClient) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) { - ctx, cancel := c.callContext(ctx) - defer cancel() - return c.client.InitiateConversion(ctx, req) -} - -func (c *orchestratorClient) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) { - ctx, cancel := c.callContext(ctx) - defer cancel() - return c.client.ProcessTransferUpdate(ctx, req) -} - -func (c *orchestratorClient) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) { - ctx, cancel := c.callContext(ctx) - defer cancel() - return c.client.ProcessDepositObserved(ctx, req) -} - func (c *orchestratorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { timeout := c.cfg.CallTimeout if timeout <= 0 { diff --git a/api/payments/orchestrator/client/config.go b/api/payments/orchestrator/client/config.go index d818ccdf..9255d80f 100644 --- a/api/payments/orchestrator/client/config.go +++ b/api/payments/orchestrator/client/config.go @@ -4,11 +4,10 @@ import "time" // Config captures connection settings for the payment orchestrator gRPC service. type Config struct { - Address string - QuoteAddress string - DialTimeout time.Duration - CallTimeout time.Duration - Insecure bool + Address string + DialTimeout time.Duration + CallTimeout time.Duration + Insecure bool } func (c *Config) setDefaults() { diff --git a/api/payments/orchestrator/client/fake.go b/api/payments/orchestrator/client/fake.go index 8f9fb12d..9edde998 100644 --- a/api/payments/orchestrator/client/fake.go +++ b/api/payments/orchestrator/client/fake.go @@ -3,76 +3,36 @@ package client import ( "context" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" ) // Fake implements Client for tests. type Fake struct { - InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) - InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) - CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) - GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) - ListPaymentsFn func(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) - InitiateConversionFn func(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) - ProcessTransferUpdateFn func(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) - ProcessDepositObservedFn func(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) - CloseFn func() error + ExecutePaymentFn func(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + GetPaymentFn func(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) + ListPaymentsFn func(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) + CloseFn func() error } -func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { - if f.InitiatePaymentsFn != nil { - return f.InitiatePaymentsFn(ctx, req) +func (f *Fake) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + if f.ExecutePaymentFn != nil { + return f.ExecutePaymentFn(ctx, req) } - return &orchestratorv1.InitiatePaymentsResponse{}, nil + return &orchestrationv2.ExecutePaymentResponse{}, nil } -func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { - if f.InitiatePaymentFn != nil { - return f.InitiatePaymentFn(ctx, req) - } - return &orchestratorv1.InitiatePaymentResponse{}, nil -} - -func (f *Fake) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { - if f.CancelPaymentFn != nil { - return f.CancelPaymentFn(ctx, req) - } - return &orchestratorv1.CancelPaymentResponse{}, nil -} - -func (f *Fake) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) { +func (f *Fake) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { if f.GetPaymentFn != nil { return f.GetPaymentFn(ctx, req) } - return &orchestratorv1.GetPaymentResponse{}, nil + return &orchestrationv2.GetPaymentResponse{}, nil } -func (f *Fake) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) { +func (f *Fake) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { if f.ListPaymentsFn != nil { return f.ListPaymentsFn(ctx, req) } - return &orchestratorv1.ListPaymentsResponse{}, nil -} - -func (f *Fake) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) { - if f.InitiateConversionFn != nil { - return f.InitiateConversionFn(ctx, req) - } - return &orchestratorv1.InitiateConversionResponse{}, nil -} - -func (f *Fake) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) { - if f.ProcessTransferUpdateFn != nil { - return f.ProcessTransferUpdateFn(ctx, req) - } - return &orchestratorv1.ProcessTransferUpdateResponse{}, nil -} - -func (f *Fake) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) { - if f.ProcessDepositObservedFn != nil { - return f.ProcessDepositObservedFn(ctx, req) - } - return &orchestratorv1.ProcessDepositObservedResponse{}, nil + return &orchestrationv2.ListPaymentsResponse{}, nil } func (f *Fake) Close() error { diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index 4cc03bef..be14e998 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -18,7 +18,6 @@ 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 @@ -46,9 +45,10 @@ require ( 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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/prometheus/client_golang v1.23.2 // 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 @@ -64,5 +64,5 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index cfb95db4..e564a3ee 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -211,8 +211,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/orchestrator/internal/server/internal/dependencies.go b/api/payments/orchestrator/internal/server/internal/dependencies.go index ad919c0c..ba0b905c 100644 --- a/api/payments/orchestrator/internal/server/internal/dependencies.go +++ b/api/payments/orchestrator/internal/server/internal/dependencies.go @@ -6,7 +6,6 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" ) type orchestratorDeps struct { @@ -14,7 +13,6 @@ type orchestratorDeps struct { ledgerClient ledgerclient.Client mntxClient mntxclient.Client oracleClient oracleclient.Client - quotationClient quotationv1.QuotationServiceClient gatewayInvokeResolver orchestrator.GatewayInvokeResolver } @@ -32,7 +30,6 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps { deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients} deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients} deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients} - deps.quotationClient = &discoveryQuotationClient{resolver: i.discoveryClients} deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients} return deps } @@ -52,9 +49,6 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient)) } - if deps.quotationClient != nil { - opts = append(opts, orchestrator.WithQuotationService(deps.quotationClient)) - } opts = append(opts, orchestrator.WithMaxFXQuoteTTLMillis(cfg.maxFXQuoteTTLMillis())) if deps.gatewayInvokeResolver != nil { opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver)) diff --git a/api/payments/orchestrator/internal/server/internal/discovery_clients.go b/api/payments/orchestrator/internal/server/internal/discovery_clients.go index 0f90ae60..78e88e82 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_clients.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_clients.go @@ -19,7 +19,6 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "go.uber.org/zap" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -33,7 +32,6 @@ var ( ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)} - quoteServiceNames = []string{"PAYMENT_QUOTATION", "payment_quotation"} ) type discoveryEndpoint struct { @@ -55,9 +53,6 @@ type discoveryClientResolver struct { feesConn *grpc.ClientConn feesEndpoint discoveryEndpoint - quoteConn *grpc.ClientConn - quoteEndpoint discoveryEndpoint - ledgerClient ledgerclient.Client ledgerEndpoint discoveryEndpoint @@ -93,10 +88,6 @@ func (r *discoveryClientResolver) Close() { _ = r.feesConn.Close() r.feesConn = nil } - if r.quoteConn != nil { - _ = r.quoteConn.Close() - r.quoteConn = nil - } if r.ledgerClient != nil { _ = r.ledgerClient.Close() r.ledgerClient = nil @@ -137,11 +128,6 @@ func (r *discoveryClientResolver) MntxAvailable() bool { return ok } -func (r *discoveryClientResolver) QuotationAvailable() bool { - _, ok := r.findEntry("quotation", quoteServiceNames, "", "") - return ok -} - func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) { entry, ok := r.findEntry("fees", feesServiceNames, "", "") if !ok { @@ -173,37 +159,6 @@ func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEng return feesv1.NewFeeEngineClient(r.feesConn), nil } -func (r *discoveryClientResolver) QuotationClient(ctx context.Context) (quotationv1.QuotationServiceClient, error) { - entry, ok := r.findEntry("quotation", quoteServiceNames, "", "") - if !ok { - return nil, merrors.NoData("discovery: quotation service unavailable") - } - endpoint, err := parseDiscoveryEndpoint(entry.InvokeURI) - if err != nil { - r.logMissing("quotation", "invalid quotation invoke uri", entry.InvokeURI, err) - return nil, err - } - - r.mu.Lock() - defer r.mu.Unlock() - - if r.quoteConn == nil || r.quoteEndpoint.key() != endpoint.key() || r.quoteEndpoint.address != endpoint.address { - if r.quoteConn != nil { - _ = r.quoteConn.Close() - r.quoteConn = nil - } - conn, dialErr := dialGrpc(ctx, endpoint) - if dialErr != nil { - r.logMissing("quotation", "failed to dial quotation service", endpoint.raw, dialErr) - return nil, dialErr - } - r.quoteConn = conn - r.quoteEndpoint = endpoint - } - - return quotationv1.NewQuotationServiceClient(r.quoteConn), nil -} - func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) { entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "") if !ok { @@ -404,9 +359,6 @@ func (r *discoveryClientResolver) findEntry(key string, services []string, rail } func (r *discoveryClientResolver) logSelection(key, entryKey string, entry discovery.RegistryEntry) { - if r.logger == nil { - return - } r.mu.Lock() last := r.lastSelection[key] if last == entryKey { @@ -426,9 +378,6 @@ func (r *discoveryClientResolver) logSelection(key, entryKey string, entry disco } func (r *discoveryClientResolver) logMissing(key, message, invokeURI string, err error) { - if r.logger == nil { - return - } now := time.Now() r.mu.Lock() last := r.lastMissing[key] diff --git a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go index 904b519b..625c1b85 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go @@ -13,7 +13,6 @@ import ( chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "google.golang.org/grpc" ) @@ -52,33 +51,6 @@ func (c *discoveryFeeClient) ValidateFeeToken(ctx context.Context, req *feesv1.V return client.ValidateFeeToken(ctx, req, opts...) } -type discoveryQuotationClient struct { - resolver *discoveryClientResolver -} - -func (c *discoveryQuotationClient) Available() bool { - if c == nil || c.resolver == nil { - return false - } - return c.resolver.QuotationAvailable() -} - -func (c *discoveryQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { - client, err := c.resolver.QuotationClient(ctx) - if err != nil { - return nil, err - } - return client.QuotePayment(ctx, req, opts...) -} - -func (c *discoveryQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) { - client, err := c.resolver.QuotationClient(ctx) - if err != nil { - return nil, err - } - return client.QuotePayments(ctx, req, opts...) -} - type discoveryLedgerClient struct { resolver *discoveryClientResolver } diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 02916535..6dc93764 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -63,7 +63,7 @@ func (i *Imp) Start() error { return svc, nil } - app, err := grpcapp.NewApp(i.logger, "payments_orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory) + app, err := grpcapp.NewApp(i.logger, "payments.orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory) if err != nil { return err } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go index 84928662..4bae38aa 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mlogger" pm "github.com/tech/sendico/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -91,9 +92,19 @@ type Payment struct { StepExecutions []StepExecution } -func New() Factory { +// Dependencies configures aggregate factory integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) Factory { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } return &svc{ - now: func() time.Time { return time.Now().UTC() }, + logger: dep.Logger.Named("agg"), + now: func() time.Time { return time.Now().UTC() }, newID: func() bson.ObjectID { return bson.NewObjectID() }, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go index 0a9b8c66..2011cab0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go @@ -7,18 +7,45 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" pm "github.com/tech/sendico/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) const initialVersion uint64 = 1 type svc struct { - now func() time.Time - newID func() bson.ObjectID + logger mlogger.Logger + now func() time.Time + newID func() bson.ObjectID } -func (s *svc) Create(in Input) (*Payment, error) { +func (s *svc) Create(in Input) (payment *Payment, err error) { + logger := s.logger + logger.Debug("Starting Create", + zap.String("organization_ref", in.OrganizationRef.Hex()), + zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), + zap.Int("steps_count", len(in.Steps)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if err != nil { + logger.Warn("Failed to create", append(fields, zap.Error(err))...) + return + } + if payment == nil { + logger.Debug("Completed Create", append(fields, zap.Bool("payment_nil", true))...) + return + } + fields = append(fields, + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + logger.Debug("Completed Create", fields...) + }(time.Now()) + if in.OrganizationRef.IsZero() { return nil, merrors.InvalidArgument("organization_id is required") } @@ -67,7 +94,7 @@ func (s *svc) Create(in Input) (*Payment, error) { now := s.now().UTC() id := s.newID() - return &Payment{ + payment = &Payment{ Base: storable.Base{ ID: id, CreatedAt: now, @@ -85,7 +112,8 @@ func (s *svc) Create(in Input) (*Payment, error) { State: StateCreated, Version: initialVersion, StepExecutions: stepExecutions, - }, nil + } + return payment, nil } func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/errors.go new file mode 100644 index 00000000..4e595335 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/errors.go @@ -0,0 +1,9 @@ +package erecon + +import "errors" + +var ( + ErrStepNotFound = errors.New("step execution not found") + ErrAmbiguousStepMatch = errors.New("ambiguous step execution match") + ErrStepTransitionInvalid = errors.New("step transition invalid") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go new file mode 100644 index 00000000..9ca22800 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go @@ -0,0 +1,271 @@ +package erecon + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/merrors" +) + +type normalizedEvent struct { + stepRef string + matchRefs []agg.ExternalRef + appendRefs []agg.ExternalRef + targetState agg.StepState + failureCode string + failureMsg string + occurredAt *time.Time + forceAggregateFailed bool + forceAggregateNeedsAttention bool +} + +func normalizeEvent(event Event) (*normalizedEvent, error) { + if countPayloads(event) != 1 { + return nil, merrors.InvalidArgument("exactly one event payload is required") + } + + if event.Gateway != nil { + return normalizeGatewayEvent(*event.Gateway) + } + if event.Ledger != nil { + return normalizeLedgerEvent(*event.Ledger) + } + return normalizeCardEvent(*event.Card) +} + +func countPayloads(event Event) int { + count := 0 + if event.Gateway != nil { + count++ + } + if event.Ledger != nil { + count++ + } + if event.Card != nil { + count++ + } + return count +} + +func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) { + status, ok := normalizeGatewayStatus(src.Status) + if !ok { + return nil, merrors.InvalidArgument("gateway status is invalid") + } + + target, needsAttention := mapFailureTarget(status, src.Retryable) + ev := &normalizedEvent{ + stepRef: strings.TrimSpace(src.StepRef), + targetState: target, + failureCode: strings.TrimSpace(src.FailureCode), + failureMsg: strings.TrimSpace(src.FailureMsg), + occurredAt: normalizeTimePtr(src.OccurredAt), + forceAggregateFailed: src.TerminalFailure, + forceAggregateNeedsAttention: needsAttention, + } + ev.matchRefs = normalizeRefList([]agg.ExternalRef{ + { + GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID), + Kind: ExternalRefKindOperation, + Ref: strings.TrimSpace(src.OperationRef), + }, + { + GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID), + Kind: ExternalRefKindTransfer, + Ref: strings.TrimSpace(src.TransferRef), + }, + }) + ev.appendRefs = cloneRefs(ev.matchRefs) + + if ev.stepRef == "" && len(ev.matchRefs) == 0 { + return nil, merrors.InvalidArgument("gateway event must include step_ref or operation/transfer reference") + } + if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" { + ev.failureMsg = "gateway operation failed" + } + return ev, nil +} + +func normalizeLedgerEvent(src LedgerEvent) (*normalizedEvent, error) { + status, ok := normalizeLedgerStatus(src.Status) + if !ok { + return nil, merrors.InvalidArgument("ledger status is invalid") + } + + target, needsAttention := mapFailureTarget(status, src.Retryable) + ev := &normalizedEvent{ + stepRef: strings.TrimSpace(src.StepRef), + targetState: target, + failureCode: strings.TrimSpace(src.FailureCode), + failureMsg: strings.TrimSpace(src.FailureMsg), + occurredAt: normalizeTimePtr(src.OccurredAt), + forceAggregateFailed: src.TerminalFailure, + forceAggregateNeedsAttention: needsAttention, + } + ev.matchRefs = normalizeRefList([]agg.ExternalRef{ + { + Kind: ExternalRefKindLedger, + Ref: strings.TrimSpace(src.EntryRef), + }, + }) + ev.appendRefs = cloneRefs(ev.matchRefs) + + if ev.stepRef == "" && len(ev.matchRefs) == 0 { + return nil, merrors.InvalidArgument("ledger event must include step_ref or entry_ref") + } + if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" { + ev.failureMsg = "ledger operation failed" + } + return ev, nil +} + +func normalizeCardEvent(src CardEvent) (*normalizedEvent, error) { + status, ok := normalizeCardStatus(src.Status) + if !ok { + return nil, merrors.InvalidArgument("card status is invalid") + } + + target, needsAttention := mapFailureTarget(status, src.Retryable) + ev := &normalizedEvent{ + stepRef: strings.TrimSpace(src.StepRef), + targetState: target, + failureCode: strings.TrimSpace(src.FailureCode), + failureMsg: strings.TrimSpace(src.FailureMsg), + occurredAt: normalizeTimePtr(src.OccurredAt), + forceAggregateFailed: src.TerminalFailure, + forceAggregateNeedsAttention: needsAttention, + } + ev.matchRefs = normalizeRefList([]agg.ExternalRef{ + { + GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID), + Kind: ExternalRefKindCardPayout, + Ref: strings.TrimSpace(src.PayoutRef), + }, + }) + ev.appendRefs = cloneRefs(ev.matchRefs) + + if ev.stepRef == "" && len(ev.matchRefs) == 0 { + return nil, merrors.InvalidArgument("card event must include step_ref or payout_ref") + } + if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" { + ev.failureMsg = "card payout failed" + } + return ev, nil +} + +func normalizeGatewayStatus(status GatewayStatus) (GatewayStatus, bool) { + switch strings.ToLower(strings.TrimSpace(string(status))) { + case string(GatewayStatusCreated): + return GatewayStatusCreated, true + case string(GatewayStatusProcessing): + return GatewayStatusProcessing, true + case string(GatewayStatusWaiting): + return GatewayStatusWaiting, true + case string(GatewayStatusSuccess): + return GatewayStatusSuccess, true + case string(GatewayStatusFailed): + return GatewayStatusFailed, true + case string(GatewayStatusCancelled): + return GatewayStatusCancelled, true + default: + return GatewayStatusUnspecified, false + } +} + +func normalizeLedgerStatus(status LedgerStatus) (LedgerStatus, bool) { + switch strings.ToLower(strings.TrimSpace(string(status))) { + case string(LedgerStatusPending): + return LedgerStatusPending, true + case string(LedgerStatusProcessing): + return LedgerStatusProcessing, true + case string(LedgerStatusPosted): + return LedgerStatusPosted, true + case string(LedgerStatusFailed): + return LedgerStatusFailed, true + case string(LedgerStatusCancelled): + return LedgerStatusCancelled, true + default: + return LedgerStatusUnspecified, false + } +} + +func normalizeCardStatus(status CardStatus) (CardStatus, bool) { + switch strings.ToLower(strings.TrimSpace(string(status))) { + case string(CardStatusCreated): + return CardStatusCreated, true + case string(CardStatusProcessing): + return CardStatusProcessing, true + case string(CardStatusWaiting): + return CardStatusWaiting, true + case string(CardStatusSuccess): + return CardStatusSuccess, true + case string(CardStatusFailed): + return CardStatusFailed, true + case string(CardStatusCancelled): + return CardStatusCancelled, true + default: + return CardStatusUnspecified, false + } +} + +func mapFailureTarget(status any, retryable *bool) (agg.StepState, bool) { + switch status { + case GatewayStatusCreated, GatewayStatusProcessing, GatewayStatusWaiting: + return agg.StepStateRunning, false + case LedgerStatusPending, LedgerStatusProcessing: + return agg.StepStateRunning, false + case CardStatusCreated, CardStatusProcessing, CardStatusWaiting: + return agg.StepStateRunning, false + case GatewayStatusSuccess, LedgerStatusPosted, CardStatusSuccess: + return agg.StepStateCompleted, false + case GatewayStatusFailed, GatewayStatusCancelled, LedgerStatusFailed, LedgerStatusCancelled, CardStatusFailed, CardStatusCancelled: + if retryable != nil && !*retryable { + return agg.StepStateNeedsAttention, true + } + return agg.StepStateFailed, false + default: + return agg.StepStateUnspecified, false + } +} + +func normalizeTimePtr(ts *time.Time) *time.Time { + if ts == nil { + return nil + } + val := ts.UTC() + return &val +} + +func normalizeRefList(refs []agg.ExternalRef) []agg.ExternalRef { + if len(refs) == 0 { + return nil + } + out := make([]agg.ExternalRef, 0, len(refs)) + seen := map[string]struct{}{} + for i := range refs { + ref := refs[i] + ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID) + ref.Kind = strings.TrimSpace(ref.Kind) + ref.Ref = strings.TrimSpace(ref.Ref) + if ref.Kind == "" || ref.Ref == "" { + continue + } + key := ref.GatewayInstanceID + "\x1f" + strings.ToLower(ref.Kind) + "\x1f" + strings.ToLower(ref.Ref) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, ref) + } + return out +} + +func cloneRefs(refs []agg.ExternalRef) []agg.ExternalRef { + if len(refs) == 0 { + return nil + } + out := make([]agg.ExternalRef, 0, len(refs)) + out = append(out, refs...) + return out +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/matching.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/matching.go new file mode 100644 index 00000000..e5805c46 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/matching.go @@ -0,0 +1,97 @@ +package erecon + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr" +) + +func findStepIndex(payment *agg.Payment, event *normalizedEvent) (int, error) { + if payment == nil { + return -1, ErrStepNotFound + } + if event == nil { + return -1, ErrStepNotFound + } + + if stepRef := strings.TrimSpace(event.stepRef); stepRef != "" { + for i := range payment.StepExecutions { + if strings.EqualFold(strings.TrimSpace(payment.StepExecutions[i].StepRef), stepRef) { + return i, nil + } + } + return -1, xerr.Wrapf(ErrStepNotFound, "step_ref=%s", stepRef) + } + + matches := make([]int, 0, 1) + for i := range payment.StepExecutions { + if stepMatchesAnyRef(payment.StepExecutions[i], event.matchRefs) { + matches = append(matches, i) + } + } + switch len(matches) { + case 0: + return -1, ErrStepNotFound + case 1: + return matches[0], nil + default: + return -1, ErrAmbiguousStepMatch + } +} + +func stepMatchesAnyRef(step agg.StepExecution, refs []agg.ExternalRef) bool { + if len(refs) == 0 || len(step.ExternalRefs) == 0 { + return false + } + for i := range refs { + if hasExternalRef(step.ExternalRefs, refs[i]) { + return true + } + } + return false +} + +func hasExternalRef(existing []agg.ExternalRef, ref agg.ExternalRef) bool { + kind := strings.TrimSpace(ref.Kind) + value := strings.TrimSpace(ref.Ref) + gatewayID := strings.TrimSpace(ref.GatewayInstanceID) + if kind == "" || value == "" { + return false + } + for i := range existing { + candidate := existing[i] + if !strings.EqualFold(strings.TrimSpace(candidate.Kind), kind) { + continue + } + if !strings.EqualFold(strings.TrimSpace(candidate.Ref), value) { + continue + } + if gatewayID != "" && strings.TrimSpace(candidate.GatewayInstanceID) != "" && !strings.EqualFold(strings.TrimSpace(candidate.GatewayInstanceID), gatewayID) { + continue + } + return true + } + return false +} + +func mergeExternalRefs(existing []agg.ExternalRef, additions []agg.ExternalRef) ([]agg.ExternalRef, bool) { + if len(additions) == 0 { + return cloneRefs(existing), false + } + out := cloneRefs(existing) + changed := false + for i := range additions { + ref := additions[i] + if hasExternalRef(out, ref) { + continue + } + out = append(out, agg.ExternalRef{ + GatewayInstanceID: strings.TrimSpace(ref.GatewayInstanceID), + Kind: strings.TrimSpace(ref.Kind), + Ref: strings.TrimSpace(ref.Ref), + }) + changed = true + } + return out, changed +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go new file mode 100644 index 00000000..06944f35 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go @@ -0,0 +1,141 @@ +package erecon + +import ( + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/pkg/mlogger" +) + +// Reconciler applies external async events to payment runtime state. +type Reconciler interface { + Reconcile(in Input) (*Output, error) +} + +// Input is the reconciliation payload. +type Input struct { + Payment *agg.Payment + Event Event +} + +// Output is the reconciliation result. +type Output struct { + Payment *agg.Payment + MatchedStepRef string + StepChanged bool + AggregateChanged bool +} + +// Event is one transport-agnostic external event envelope. +// Exactly one payload must be set. +type Event struct { + Gateway *GatewayEvent + Ledger *LedgerEvent + Card *CardEvent +} + +// GatewayStatus is gateway operation lifecycle status. +type GatewayStatus string + +const ( + GatewayStatusUnspecified GatewayStatus = "unspecified" + GatewayStatusCreated GatewayStatus = "created" + GatewayStatusProcessing GatewayStatus = "processing" + GatewayStatusWaiting GatewayStatus = "waiting" + GatewayStatusSuccess GatewayStatus = "success" + GatewayStatusFailed GatewayStatus = "failed" + GatewayStatusCancelled GatewayStatus = "cancelled" +) + +// LedgerStatus is ledger operation lifecycle status. +type LedgerStatus string + +const ( + LedgerStatusUnspecified LedgerStatus = "unspecified" + LedgerStatusPending LedgerStatus = "pending" + LedgerStatusProcessing LedgerStatus = "processing" + LedgerStatusPosted LedgerStatus = "posted" + LedgerStatusFailed LedgerStatus = "failed" + LedgerStatusCancelled LedgerStatus = "cancelled" +) + +// CardStatus is card payout lifecycle status. +type CardStatus string + +const ( + CardStatusUnspecified CardStatus = "unspecified" + CardStatusCreated CardStatus = "created" + CardStatusProcessing CardStatus = "processing" + CardStatusWaiting CardStatus = "waiting" + CardStatusSuccess CardStatus = "success" + CardStatusFailed CardStatus = "failed" + CardStatusCancelled CardStatus = "cancelled" +) + +// GatewayEvent is one async event from gateway execution flow. +type GatewayEvent struct { + StepRef string + OperationRef string + TransferRef string + GatewayInstanceID string + Status GatewayStatus + FailureCode string + FailureMsg string + Retryable *bool + TerminalFailure bool + OccurredAt *time.Time +} + +// LedgerEvent is one async event from ledger flow. +type LedgerEvent struct { + StepRef string + EntryRef string + Status LedgerStatus + FailureCode string + FailureMsg string + Retryable *bool + TerminalFailure bool + OccurredAt *time.Time +} + +// CardEvent is one async event from card payout flow. +type CardEvent struct { + StepRef string + PayoutRef string + GatewayInstanceID string + Status CardStatus + FailureCode string + FailureMsg string + Retryable *bool + TerminalFailure bool + OccurredAt *time.Time +} + +const ( + ExternalRefKindOperation = "operation_ref" + ExternalRefKindTransfer = "transfer_ref" + ExternalRefKindLedger = "ledger_entry_ref" + ExternalRefKindCardPayout = "card_payout_ref" +) + +// Dependencies configures reconciliation service integrations. +type Dependencies struct { + Logger mlogger.Logger + Now func() time.Time +} + +func New(deps ...Dependencies) Reconciler { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + now := dep.Now + if now == nil { + now = defaultNow + } + return &svc{ + logger: dep.Logger.Named("erecon"), + now: now, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go new file mode 100644 index 00000000..c4834eab --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go @@ -0,0 +1,119 @@ +package erecon + +import ( + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" +) + +func reduceAggregateState(payment *agg.Payment, event *normalizedEvent, sm ostate.StateMachine) (bool, error) { + if payment == nil || sm == nil { + return false, nil + } + + target := deriveAggregateTarget(payment, event, sm) + return applyAggregateTarget(payment, target, sm) +} + +func deriveAggregateTarget(payment *agg.Payment, event *normalizedEvent, sm ostate.StateMachine) agg.State { + if payment == nil { + return agg.StateUnspecified + } + if event != nil && event.forceAggregateFailed { + return agg.StateFailed + } + + hasNeedsAttention := false + hasWork := false + allTerminalSuccessOrSkipped := len(payment.StepExecutions) > 0 + + for i := range payment.StepExecutions { + state := payment.StepExecutions[i].State + switch state { + case agg.StepStateCompleted, agg.StepStateSkipped: + hasWork = true + case agg.StepStatePending, agg.StepStateUnspecified: + allTerminalSuccessOrSkipped = false + case agg.StepStateNeedsAttention: + hasWork = true + hasNeedsAttention = true + allTerminalSuccessOrSkipped = false + case agg.StepStateRunning, agg.StepStateFailed: + hasWork = true + allTerminalSuccessOrSkipped = false + default: + allTerminalSuccessOrSkipped = false + } + } + + if allTerminalSuccessOrSkipped { + return agg.StateSettled + } + if hasNeedsAttention || (event != nil && event.forceAggregateNeedsAttention) { + return agg.StateNeedsAttention + } + if hasWork { + return agg.StateExecuting + } + if sm.IsAggregateTerminal(payment.State) { + return payment.State + } + return agg.StateCreated +} + +func applyAggregateTarget(payment *agg.Payment, target agg.State, sm ostate.StateMachine) (bool, error) { + if payment == nil || sm == nil { + return false, nil + } + current := payment.State + if current == target { + return false, nil + } + if sm.IsAggregateTerminal(current) { + return false, nil + } + + original := current + for i := 0; i < 6 && current != target; i++ { + if sm.EnsureAggregateTransition(current, target) == nil { + current = target + break + } + next, ok := nextAggregateHop(current, target) + if !ok { + break + } + if sm.EnsureAggregateTransition(current, next) != nil { + break + } + current = next + } + if current != target { + return false, nil + } + payment.State = current + return payment.State != original, nil +} + +func nextAggregateHop(current, target agg.State) (agg.State, bool) { + switch current { + case agg.StateUnspecified: + return agg.StateCreated, true + case agg.StateCreated: + if target == agg.StateFailed { + return agg.StateFailed, true + } + if target == agg.StateExecuting { + return agg.StateExecuting, true + } + return agg.StateExecuting, true + case agg.StateExecuting: + return target, true + case agg.StateNeedsAttention: + if target == agg.StateCreated { + return agg.StateExecuting, true + } + return target, true + default: + return agg.StateUnspecified, false + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go new file mode 100644 index 00000000..db8086b1 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go @@ -0,0 +1,259 @@ +package erecon + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger + now func() time.Time +} + +func defaultNow() time.Time { + return time.Now().UTC() +} + +func (s *svc) Reconcile(in Input) (out *Output, err error) { + logger := s.logger + paymentRef := "" + if in.Payment != nil { + paymentRef = strings.TrimSpace(in.Payment.PaymentRef) + } + logger.Debug("Starting Reconcile", + zap.String("payment_ref", paymentRef), + zap.String("event_source", eventSource(in.Event)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.String("matched_step_ref", strings.TrimSpace(out.MatchedStepRef)), + zap.Bool("step_changed", out.StepChanged), + zap.Bool("aggregate_changed", out.AggregateChanged), + ) + if out.Payment != nil { + fields = append(fields, + zap.String("payment_state", string(out.Payment.State)), + zap.Uint64("version", out.Payment.Version), + ) + } + } + if err != nil { + logger.Warn("Failed to reconcile", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Reconcile", fields...) + }(time.Now()) + + if in.Payment == nil { + return nil, merrors.InvalidArgument("payment is required") + } + if len(in.Payment.StepExecutions) == 0 { + return nil, merrors.InvalidArgument("payment.step_executions are required") + } + + event, err := normalizeEvent(in.Event) + if err != nil { + return nil, err + } + + payment, err := clonePayment(in.Payment) + if err != nil { + return nil, err + } + + idx, err := findStepIndex(payment, event) + if err != nil { + return nil, err + } + + sm := ostate.New(ostate.Dependencies{Logger: logger.Named("ostate")}) + stepChanged, err := s.applyStepEvent(&payment.StepExecutions[idx], event, sm) + if err != nil { + return nil, err + } + + aggregateChanged, err := reduceAggregateState(payment, event, sm) + if err != nil { + return nil, err + } + + if stepChanged || aggregateChanged { + payment.Version++ + payment.UpdatedAt = s.now().UTC() + } + + out = &Output{ + Payment: payment, + MatchedStepRef: payment.StepExecutions[idx].StepRef, + StepChanged: stepChanged, + AggregateChanged: aggregateChanged, + } + return out, nil +} + +func (s *svc) applyStepEvent(step *agg.StepExecution, event *normalizedEvent, sm ostate.StateMachine) (bool, error) { + if step == nil || event == nil { + return false, nil + } + + changed := false + out := *step + + refs, refsChanged := mergeExternalRefs(out.ExternalRefs, event.appendRefs) + if refsChanged { + out.ExternalRefs = refs + changed = true + } + + target := event.targetState + if target == agg.StepStateUnspecified { + *step = out + return changed, nil + } + + if out.State == target { + changed = s.applyStepDiagnostics(&out, event) || changed + *step = out + return changed, nil + } + + if sm.IsStepTerminal(out.State) { + *step = out + return changed, nil + } + + next, transitionChanged, err := transitionStepState(out, target, sm) + if err != nil { + return false, err + } + out = next + changed = changed || transitionChanged + changed = s.applyStepDiagnostics(&out, event) || changed + + *step = out + return changed, nil +} + +func transitionStepState(step agg.StepExecution, target agg.StepState, sm ostate.StateMachine) (agg.StepExecution, bool, error) { + if step.State == target { + return step, false, nil + } + + if sm.EnsureStepTransition(step.State, target) == nil { + step.State = target + return step, true, nil + } + + original := step.State + bridge := []agg.StepState{agg.StepStateRunning, target} + for i := range bridge { + next := bridge[i] + if step.State == next { + continue + } + if sm.EnsureStepTransition(step.State, next) != nil { + return step, false, xerr.Wrapf(ErrStepTransitionInvalid, "%s -> %s", original, target) + } + step.State = next + } + return step, step.State != original, nil +} + +func (s *svc) applyStepDiagnostics(step *agg.StepExecution, event *normalizedEvent) bool { + if step == nil || event == nil { + return false + } + + now := s.now().UTC() + at := now + if event.occurredAt != nil { + at = event.occurredAt.UTC() + } + + changed := false + switch step.State { + case agg.StepStateRunning: + if step.StartedAt == nil { + step.StartedAt = &at + changed = true + } + if step.CompletedAt != nil { + step.CompletedAt = nil + changed = true + } + if step.FailureCode != "" || step.FailureMsg != "" { + step.FailureCode = "" + step.FailureMsg = "" + changed = true + } + + case agg.StepStateCompleted: + if step.StartedAt == nil { + step.StartedAt = &at + changed = true + } + if step.CompletedAt == nil || !step.CompletedAt.Equal(at) { + step.CompletedAt = &at + changed = true + } + if step.FailureCode != "" || step.FailureMsg != "" { + step.FailureCode = "" + step.FailureMsg = "" + changed = true + } + + case agg.StepStateFailed, agg.StepStateNeedsAttention: + if step.StartedAt == nil { + step.StartedAt = &at + changed = true + } + if step.CompletedAt == nil || !step.CompletedAt.Equal(at) { + step.CompletedAt = &at + changed = true + } + fc := strings.TrimSpace(event.failureCode) + fm := strings.TrimSpace(event.failureMsg) + if step.FailureCode != fc || step.FailureMsg != fm { + step.FailureCode = fc + step.FailureMsg = fm + changed = true + } + } + return changed +} + +func clonePayment(payment *agg.Payment) (*agg.Payment, error) { + data, err := bson.Marshal(payment) + if err != nil { + return nil, merrors.Internal("payment clone failed") + } + var out agg.Payment + if err := bson.Unmarshal(data, &out); err != nil { + return nil, merrors.Internal("payment clone failed") + } + return &out, nil +} + +func eventSource(event Event) string { + switch { + case event.Gateway != nil: + return "gateway" + case event.Ledger != nil: + return "ledger" + case event.Card != nil: + return "card" + default: + return "unknown" + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go new file mode 100644 index 00000000..f9ba4c26 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go @@ -0,0 +1,365 @@ +package erecon + +import ( + "errors" + "testing" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/merrors" +) + +func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) { + now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) + reconciler := &svc{now: func() time.Time { return now }} + + in := &agg.Payment{ + PaymentRef: "p1", + State: agg.StateCreated, + Version: 7, + StepExecutions: []agg.StepExecution{ + {StepRef: "s1", StepCode: "send", State: agg.StepStatePending, Attempt: 1}, + }, + } + + out, err := reconciler.Reconcile(Input{ + Payment: in, + Event: Event{ + Gateway: &GatewayEvent{ + StepRef: "s1", + OperationRef: "op-1", + TransferRef: "tx-1", + GatewayInstanceID: "gw-1", + Status: GatewayStatusWaiting, + }, + }, + }) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if !out.StepChanged { + t.Fatal("expected step_changed") + } + if !out.AggregateChanged { + t.Fatal("expected aggregate_changed") + } + + got := out.Payment.StepExecutions[0] + if got.State != agg.StepStateRunning { + t.Fatalf("step state mismatch: got=%q want=%q", got.State, agg.StepStateRunning) + } + if got.StartedAt == nil || !got.StartedAt.Equal(now) { + t.Fatalf("started_at mismatch: got=%v want=%v", got.StartedAt, now) + } + if got.CompletedAt != nil { + t.Fatalf("expected nil completed_at, got %v", got.CompletedAt) + } + if !hasRef(got.ExternalRefs, agg.ExternalRef{GatewayInstanceID: "gw-1", Kind: ExternalRefKindOperation, Ref: "op-1"}) { + t.Fatalf("expected operation_ref external reference") + } + if !hasRef(got.ExternalRefs, agg.ExternalRef{GatewayInstanceID: "gw-1", Kind: ExternalRefKindTransfer, Ref: "tx-1"}) { + t.Fatalf("expected transfer_ref external reference") + } + if out.Payment.State != agg.StateExecuting { + t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateExecuting) + } + if out.Payment.Version != 8 { + t.Fatalf("version mismatch: got=%d want=%d", out.Payment.Version, 8) + } + + if in.StepExecutions[0].State != agg.StepStatePending { + t.Fatalf("input payment was mutated") + } +} + +func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) { + now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) + reconciler := &svc{now: func() time.Time { return now }} + + out, err := reconciler.Reconcile(Input{ + Payment: &agg.Payment{ + PaymentRef: "p1", + State: agg.StateCreated, + Version: 1, + StepExecutions: []agg.StepExecution{ + {StepRef: "s1", StepCode: "observe", State: agg.StepStatePending, Attempt: 1}, + }, + }, + Event: Event{ + Gateway: &GatewayEvent{ + StepRef: "s1", + Status: GatewayStatusSuccess, + OperationRef: "op-1", + }, + }, + }) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + step := out.Payment.StepExecutions[0] + if step.State != agg.StepStateCompleted { + t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateCompleted) + } + if step.CompletedAt == nil || !step.CompletedAt.Equal(now) { + t.Fatalf("completed_at mismatch: got=%v want=%v", step.CompletedAt, now) + } + if out.Payment.State != agg.StateSettled { + t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateSettled) + } +} + +func TestReconcile_GatewayFailureMapping(t *testing.T) { + now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) + reconciler := &svc{now: func() time.Time { return now }} + + retryable := true + out, err := reconciler.Reconcile(Input{ + Payment: &agg.Payment{ + PaymentRef: "p1", + State: agg.StateExecuting, + StepExecutions: []agg.StepExecution{ + {StepRef: "s1", StepCode: "observe", State: agg.StepStateRunning, Attempt: 1}, + }, + }, + Event: Event{ + Gateway: &GatewayEvent{ + StepRef: "s1", + Status: GatewayStatusFailed, + Retryable: &retryable, + FailureCode: "gw_timeout", + FailureMsg: "timeout", + }, + }, + }) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + step := out.Payment.StepExecutions[0] + if step.State != agg.StepStateFailed { + t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateFailed) + } + if step.FailureCode != "gw_timeout" || step.FailureMsg != "timeout" { + t.Fatalf("failure details mismatch: code=%q msg=%q", step.FailureCode, step.FailureMsg) + } + if out.Payment.State != agg.StateExecuting { + t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateExecuting) + } + + nonRetryable := false + out, err = reconciler.Reconcile(Input{ + Payment: &agg.Payment{ + PaymentRef: "p2", + State: agg.StateExecuting, + StepExecutions: []agg.StepExecution{ + {StepRef: "s1", StepCode: "observe", State: agg.StepStateRunning, Attempt: 1}, + }, + }, + Event: Event{ + Gateway: &GatewayEvent{ + StepRef: "s1", + Status: GatewayStatusFailed, + Retryable: &nonRetryable, + FailureCode: "gw_rejected", + FailureMsg: "rejected", + }, + }, + }) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + step = out.Payment.StepExecutions[0] + if step.State != agg.StepStateNeedsAttention { + t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateNeedsAttention) + } + if out.Payment.State != agg.StateNeedsAttention { + t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateNeedsAttention) + } +} + +func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) { + now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) + reconciler := &svc{now: func() time.Time { return now }} + + out, err := reconciler.Reconcile(Input{ + Payment: &agg.Payment{ + PaymentRef: "p1", + State: agg.StateExecuting, + StepExecutions: []agg.StepExecution{ + { + StepRef: "s1", StepCode: "ledger.debit", State: agg.StepStateRunning, Attempt: 1, + ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindLedger, Ref: "entry-1"}}, + }, + }, + }, + Event: Event{ + Ledger: &LedgerEvent{ + EntryRef: "entry-1", + Status: LedgerStatusFailed, + FailureCode: "ledger_declined", + TerminalFailure: true, + }, + }, + }) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if out.Payment.StepExecutions[0].State != agg.StepStateFailed { + t.Fatalf("step state mismatch: got=%q want=%q", out.Payment.StepExecutions[0].State, agg.StepStateFailed) + } + if out.Payment.State != agg.StateFailed { + t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateFailed) + } +} + +func TestReconcile_CardMatchByExternalRef(t *testing.T) { + reconciler := &svc{now: defaultNow} + + out, err := reconciler.Reconcile(Input{ + Payment: &agg.Payment{ + PaymentRef: "p1", + State: agg.StateExecuting, + StepExecutions: []agg.StepExecution{ + { + StepRef: "s1", StepCode: "card.observe", State: agg.StepStateRunning, Attempt: 1, + ExternalRefs: []agg.ExternalRef{ + {Kind: ExternalRefKindCardPayout, Ref: "payout-1"}, + }, + }, + }, + }, + Event: Event{ + Card: &CardEvent{ + PayoutRef: "payout-1", + Status: CardStatusSuccess, + }, + }, + }) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if out.MatchedStepRef != "s1" { + t.Fatalf("matched step mismatch: got=%q want=%q", out.MatchedStepRef, "s1") + } + if out.Payment.StepExecutions[0].State != agg.StepStateCompleted { + t.Fatalf("step state mismatch: got=%q want=%q", out.Payment.StepExecutions[0].State, agg.StepStateCompleted) + } +} + +func TestReconcile_MatchingErrors(t *testing.T) { + reconciler := &svc{now: defaultNow} + + _, err := reconciler.Reconcile(Input{ + Payment: &agg.Payment{ + PaymentRef: "p1", + State: agg.StateExecuting, + StepExecutions: []agg.StepExecution{ + { + StepRef: "s1", StepCode: "a", State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindTransfer, Ref: "tx-1"}}, + }, + { + StepRef: "s2", StepCode: "b", State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindTransfer, Ref: "tx-1"}}, + }, + }, + }, + Event: Event{ + Gateway: &GatewayEvent{ + TransferRef: "tx-1", + Status: GatewayStatusSuccess, + }, + }, + }) + if !errors.Is(err, ErrAmbiguousStepMatch) { + t.Fatalf("expected ErrAmbiguousStepMatch, got %v", err) + } + + _, err = reconciler.Reconcile(Input{ + Payment: &agg.Payment{ + PaymentRef: "p1", + State: agg.StateExecuting, + StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStateRunning}}, + }, + Event: Event{ + Gateway: &GatewayEvent{ + TransferRef: "missing", + Status: GatewayStatusSuccess, + }, + }, + }) + if !errors.Is(err, ErrStepNotFound) { + t.Fatalf("expected ErrStepNotFound, got %v", err) + } +} + +func TestReconcile_ValidationErrors(t *testing.T) { + reconciler := &svc{now: defaultNow} + + tests := []struct { + name string + in Input + }{ + { + name: "missing payment", + in: Input{ + Event: Event{Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess}}, + }, + }, + { + name: "missing step executions", + in: Input{ + Payment: &agg.Payment{}, + Event: Event{Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess}}, + }, + }, + { + name: "multiple payloads", + in: Input{ + Payment: &agg.Payment{ + StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStatePending}}, + }, + Event: Event{ + Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess}, + Ledger: &LedgerEvent{StepRef: "s1", Status: LedgerStatusPosted}, + }, + }, + }, + { + name: "invalid status", + in: Input{ + Payment: &agg.Payment{ + StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStatePending}}, + }, + Event: Event{ + Card: &CardEvent{StepRef: "s1", Status: CardStatus("bad")}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := reconciler.Reconcile(tt.in) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +func hasRef(refs []agg.ExternalRef, wanted agg.ExternalRef) bool { + for i := range refs { + ref := refs[i] + if ref.Kind != wanted.Kind { + continue + } + if ref.Ref != wanted.Ref { + continue + } + if ref.GatewayInstanceID != wanted.GatewayInstanceID { + continue + } + return true + } + return false +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fake_store_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fake_store_test.go new file mode 100644 index 00000000..85ab0086 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fake_store_test.go @@ -0,0 +1,28 @@ +package idem + +import ( + "context" + + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/payments/storage/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type fakeStore struct { + createFn func(ctx context.Context, payment *model.Payment) error + getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) +} + +func (f *fakeStore) Create(ctx context.Context, payment *model.Payment) error { + if f.createFn == nil { + return nil + } + return f.createFn(ctx, payment) +} + +func (f *fakeStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) { + if f.getByIdempotencyKeyFn == nil { + return nil, storage.ErrPaymentNotFound + } + return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go index a12c76c6..ba7fca35 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go @@ -4,13 +4,31 @@ import ( "crypto/sha256" "encoding/hex" "strings" + "time" "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" ) const hashSep = "\x1f" -func (s *svc) Fingerprint(in FPInput) (string, error) { +func (s *svc) Fingerprint(in FPInput) (fingerprint string, err error) { + logger := s.logger + logger.Debug("Starting Fingerprint", + zap.String("organization_ref", strings.ToLower(strings.TrimSpace(in.OrganizationRef))), + zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), + zap.String("intent_ref", strings.TrimSpace(in.IntentRef)), + zap.Bool("has_client_payment_ref", strings.TrimSpace(in.ClientPaymentRef) != ""), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if err != nil { + logger.Warn("Failed to fingerprint", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Fingerprint", append(fields, zap.Bool("generated", strings.TrimSpace(fingerprint) != ""))...) + }(time.Now()) + orgRef := strings.ToLower(strings.TrimSpace(in.OrganizationRef)) if orgRef == "" { return "", merrors.InvalidArgument("organization_ref is required") @@ -29,7 +47,8 @@ func (s *svc) Fingerprint(in FPInput) (string, error) { "client=" + clientPaymentRef, }, hashSep) - return hashBytes([]byte(payload)), nil + fingerprint = hashBytes([]byte(payload)) + return fingerprint, nil } func hashBytes(data []byte) string { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint_test.go new file mode 100644 index 00000000..842208ac --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint_test.go @@ -0,0 +1,100 @@ +package idem + +import ( + "testing" +) + +func TestFingerprint_StableAndTrimmed(t *testing.T) { + svc := New() + + a, err := svc.Fingerprint(FPInput{ + OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ", + QuotationRef: " quote-1 ", + IntentRef: " intent-1 ", + ClientPaymentRef: " client-1 ", + }) + if err != nil { + t.Fatalf("Fingerprint returned error: %v", err) + } + b, err := svc.Fingerprint(FPInput{ + OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4", + QuotationRef: "quote-1", + IntentRef: "intent-1", + ClientPaymentRef: "client-1", + }) + if err != nil { + t.Fatalf("Fingerprint returned error: %v", err) + } + if a != b { + t.Fatalf("expected deterministic fingerprint, got %q vs %q", a, b) + } +} + +func TestFingerprint_ChangesOnPayload(t *testing.T) { + svc := New() + + base, err := svc.Fingerprint(FPInput{ + OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", + QuotationRef: "quote-1", + IntentRef: "intent-1", + ClientPaymentRef: "client-1", + }) + if err != nil { + t.Fatalf("Fingerprint returned error: %v", err) + } + + diffQuote, err := svc.Fingerprint(FPInput{ + OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", + QuotationRef: "quote-2", + IntentRef: "intent-1", + ClientPaymentRef: "client-1", + }) + if err != nil { + t.Fatalf("Fingerprint returned error: %v", err) + } + if base == diffQuote { + t.Fatalf("expected different fingerprint for different quotation_ref") + } + + diffClient, err := svc.Fingerprint(FPInput{ + OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", + QuotationRef: "quote-1", + IntentRef: "intent-1", + ClientPaymentRef: "client-2", + }) + if err != nil { + t.Fatalf("Fingerprint returned error: %v", err) + } + if base == diffClient { + t.Fatalf("expected different fingerprint for different client_payment_ref") + } + + diffIntent, err := svc.Fingerprint(FPInput{ + OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", + QuotationRef: "quote-1", + IntentRef: "intent-2", + ClientPaymentRef: "client-1", + }) + if err != nil { + t.Fatalf("Fingerprint returned error: %v", err) + } + if base == diffIntent { + t.Fatalf("expected different fingerprint for different intent_ref") + } +} + +func TestFingerprint_RequiresBusinessFields(t *testing.T) { + svc := New() + + if _, err := svc.Fingerprint(FPInput{ + QuotationRef: "quote-1", + }); err == nil { + t.Fatal("expected error for empty organization_ref") + } + + if _, err := svc.Fingerprint(FPInput{ + OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", + }); err == nil { + t.Fatal("expected error for empty quotation_ref") + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go index f34057c0..fd924ebb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go @@ -4,6 +4,7 @@ import ( "context" "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -41,6 +42,15 @@ type CreateInput struct { Reuse ReuseInput } -func New() Service { - return &svc{} +// Dependencies configures idempotency service integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) Service { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return &svc{logger: dep.Logger.Named("idem")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/reuse_test.go similarity index 62% rename from api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go rename to api/payments/orchestrator/internal/service/orchestrationv2/idem/reuse_test.go index 0ba589ed..b3d30cd6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/reuse_test.go @@ -10,101 +10,6 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -func TestFingerprint_StableAndTrimmed(t *testing.T) { - svc := New() - - a, err := svc.Fingerprint(FPInput{ - OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ", - QuotationRef: " quote-1 ", - IntentRef: " intent-1 ", - ClientPaymentRef: " client-1 ", - }) - if err != nil { - t.Fatalf("Fingerprint returned error: %v", err) - } - b, err := svc.Fingerprint(FPInput{ - OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4", - QuotationRef: "quote-1", - IntentRef: "intent-1", - ClientPaymentRef: "client-1", - }) - if err != nil { - t.Fatalf("Fingerprint returned error: %v", err) - } - if a != b { - t.Fatalf("expected deterministic fingerprint, got %q vs %q", a, b) - } -} - -func TestFingerprint_ChangesOnPayload(t *testing.T) { - svc := New() - - base, err := svc.Fingerprint(FPInput{ - OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", - QuotationRef: "quote-1", - IntentRef: "intent-1", - ClientPaymentRef: "client-1", - }) - if err != nil { - t.Fatalf("Fingerprint returned error: %v", err) - } - - diffQuote, err := svc.Fingerprint(FPInput{ - OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", - QuotationRef: "quote-2", - IntentRef: "intent-1", - ClientPaymentRef: "client-1", - }) - if err != nil { - t.Fatalf("Fingerprint returned error: %v", err) - } - if base == diffQuote { - t.Fatalf("expected different fingerprint for different quotation_ref") - } - - diffClient, err := svc.Fingerprint(FPInput{ - OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", - QuotationRef: "quote-1", - IntentRef: "intent-1", - ClientPaymentRef: "client-2", - }) - if err != nil { - t.Fatalf("Fingerprint returned error: %v", err) - } - if base == diffClient { - t.Fatalf("expected different fingerprint for different client_payment_ref") - } - - diffIntent, err := svc.Fingerprint(FPInput{ - OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", - QuotationRef: "quote-1", - IntentRef: "intent-2", - ClientPaymentRef: "client-1", - }) - if err != nil { - t.Fatalf("Fingerprint returned error: %v", err) - } - if base == diffIntent { - t.Fatalf("expected different fingerprint for different intent_ref") - } -} - -func TestFingerprint_RequiresBusinessFields(t *testing.T) { - svc := New() - - if _, err := svc.Fingerprint(FPInput{ - QuotationRef: "quote-1", - }); err == nil { - t.Fatal("expected error for empty organization_ref") - } - - if _, err := svc.Fingerprint(FPInput{ - OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4", - }); err == nil { - t.Fatal("expected error for empty quotation_ref") - } -} - func TestTryReuse_NotFound(t *testing.T) { svc := New() store := &fakeStore{ @@ -294,22 +199,3 @@ func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing t.Fatalf("expected ErrDuplicatePayment, got %v", err) } } - -type fakeStore struct { - createFn func(ctx context.Context, payment *model.Payment) error - getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) -} - -func (f *fakeStore) Create(ctx context.Context, payment *model.Payment) error { - if f.createFn == nil { - return nil - } - return f.createFn(ctx, payment) -} - -func (f *fakeStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) { - if f.getByIdempotencyKeyFn == nil { - return nil, storage.ErrPaymentNotFound - } - return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey) -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/service.go index da6135a2..8d6b0d1d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/service.go @@ -4,21 +4,46 @@ import ( "context" "errors" "strings" + "time" "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" + "go.uber.org/zap" ) const reqHashMetaKey = "_orchestrator_v2_req_hash" -type svc struct{} +type svc struct { + logger mlogger.Logger +} func (s *svc) TryReuse( ctx context.Context, store Store, in ReuseInput, -) (*model.Payment, bool, error) { +) (payment *model.Payment, reused bool, err error) { + logger := s.logger + logger.Debug("Starting Try reuse", + zap.String("organization_ref", in.OrganizationID.Hex()), + zap.Bool("has_idempotency_key", strings.TrimSpace(in.IdempotencyKey) != ""), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.Bool("reused", reused), + } + if payment != nil { + fields = append(fields, zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef))) + } + if err != nil { + logger.Warn("Failed to try reuse", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Try reuse", fields...) + }(time.Now()) + if store == nil { return nil, false, merrors.InvalidArgument("payments store is required") } @@ -28,7 +53,7 @@ func (s *svc) TryReuse( return nil, false, err } - payment, err := store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey) + payment, err = store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey) if err != nil { if errors.Is(err, storage.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) { return nil, false, nil @@ -50,7 +75,28 @@ func (s *svc) CreateOrReuse( ctx context.Context, store Store, in CreateInput, -) (*model.Payment, bool, error) { +) (payment *model.Payment, reused bool, err error) { + logger := s.logger + logger.Debug("Starting Create or reuse", + zap.String("organization_ref", in.Reuse.OrganizationID.Hex()), + zap.Bool("has_payment", in.Payment != nil), + zap.Bool("has_idempotency_key", strings.TrimSpace(in.Reuse.IdempotencyKey) != ""), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.Bool("reused", reused), + } + if payment != nil { + fields = append(fields, zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef))) + } + if err != nil { + logger.Warn("Failed to create or reuse", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Create or reuse", fields...) + }(time.Now()) + if store == nil { return nil, false, merrors.InvalidArgument("payments store is required") } @@ -64,19 +110,19 @@ func (s *svc) CreateOrReuse( } setPaymentReqHash(in.Payment, fingerprint) - if err := store.Create(ctx, in.Payment); err != nil { - if !errors.Is(err, storage.ErrDuplicatePayment) { - return nil, false, err + if createErr := store.Create(ctx, in.Payment); createErr != nil { + if !errors.Is(createErr, storage.ErrDuplicatePayment) { + return nil, false, createErr } - payment, reused, reuseErr := s.TryReuse(ctx, store, in.Reuse) - if reuseErr != nil { - return nil, false, reuseErr + payment, reused, err = s.TryReuse(ctx, store, in.Reuse) + if err != nil { + return nil, false, err } if reused { return payment, true, nil } - return nil, false, err + return nil, false, createErr } return in.Payment, false, nil diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/audit_store.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/audit_store.go new file mode 100644 index 00000000..a88ede5f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/audit_store.go @@ -0,0 +1,111 @@ +package oobs + +import ( + "strings" + "sync" + + "github.com/tech/sendico/pkg/merrors" +) + +type memoryAuditStore struct { + mu sync.RWMutex + byPayment map[string][]TimelineEntry + byStep map[string][]TimelineEntry +} + +func newMemoryAuditStore() AuditStore { + return &memoryAuditStore{ + byPayment: map[string][]TimelineEntry{}, + byStep: map[string][]TimelineEntry{}, + } +} + +func (s *memoryAuditStore) Append(entry TimelineEntry) error { + paymentRef := strings.TrimSpace(entry.PaymentRef) + if paymentRef == "" { + return merrors.InvalidArgument("timeline.payment_ref is required") + } + stepRef := strings.TrimSpace(entry.StepRef) + entry.PaymentRef = paymentRef + entry.StepRef = stepRef + entry.StepCode = strings.TrimSpace(entry.StepCode) + entry.Event = strings.TrimSpace(entry.Event) + entry.State = strings.TrimSpace(entry.State) + entry.Message = strings.TrimSpace(entry.Message) + entry.Fields = trimStringMap(entry.Fields) + + s.mu.Lock() + defer s.mu.Unlock() + + s.byPayment[paymentRef] = append(s.byPayment[paymentRef], cloneTimelineEntry(entry)) + if stepRef == "" { + return nil + } + key := stepAttemptKey(paymentRef, stepRef, entry.Attempt) + s.byStep[key] = append(s.byStep[key], cloneTimelineEntry(entry)) + return nil +} + +func (s *memoryAuditStore) ListByPayment(paymentRef string, limit int32, desc bool) ([]TimelineEntry, error) { + ref := strings.TrimSpace(paymentRef) + if ref == "" { + return nil, merrors.InvalidArgument("payment_ref is required") + } + limit = normalizeLimit(limit) + + s.mu.RLock() + items := append([]TimelineEntry(nil), s.byPayment[ref]...) + s.mu.RUnlock() + + return paginateEntries(items, limit, desc), nil +} + +func (s *memoryAuditStore) ListByStepAttempt( + paymentRef string, + stepRef string, + attempt uint32, + limit int32, + desc bool, +) ([]TimelineEntry, error) { + ref := strings.TrimSpace(paymentRef) + if ref == "" { + return nil, merrors.InvalidArgument("payment_ref is required") + } + step := strings.TrimSpace(stepRef) + if step == "" { + return nil, merrors.InvalidArgument("step_ref is required") + } + if attempt == 0 { + return nil, merrors.InvalidArgument("attempt is required") + } + limit = normalizeLimit(limit) + + key := stepAttemptKey(ref, step, attempt) + s.mu.RLock() + items := append([]TimelineEntry(nil), s.byStep[key]...) + s.mu.RUnlock() + + return paginateEntries(items, limit, desc), nil +} + +func paginateEntries(items []TimelineEntry, limit int32, desc bool) []TimelineEntry { + if desc { + items = reverseEntries(items) + } + if len(items) == 0 { + return nil + } + if int32(len(items)) > limit { + items = items[:limit] + } + out := make([]TimelineEntry, 0, len(items)) + for i := range items { + out = append(out, cloneTimelineEntry(items[i])) + } + return out +} + +func cloneTimelineEntry(entry TimelineEntry) TimelineEntry { + entry.Fields = cloneStringMap(entry.Fields) + return entry +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/events.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/events.go new file mode 100644 index 00000000..13ba6c24 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/events.go @@ -0,0 +1,107 @@ +package oobs + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" +) + +const ( + defaultTimelineLimit int32 = 100 + maxTimelineLimit int32 = 1000 +) + +func normalizePaymentEvent(event PaymentEvent) (PaymentEvent, bool) { + switch strings.ToLower(strings.TrimSpace(string(event))) { + case string(PaymentEventCreated): + return PaymentEventCreated, true + case string(PaymentEventStateChanged): + return PaymentEventStateChanged, true + case string(PaymentEventNeedsAttention): + return PaymentEventNeedsAttention, true + case string(PaymentEventSettled): + return PaymentEventSettled, true + case string(PaymentEventFailed): + return PaymentEventFailed, true + default: + return "", false + } +} + +func normalizeStepEvent(event StepEvent) (StepEvent, bool) { + switch strings.ToLower(strings.TrimSpace(string(event))) { + case string(StepEventScheduled): + return StepEventScheduled, true + case string(StepEventStarted): + return StepEventStarted, true + case string(StepEventCompleted): + return StepEventCompleted, true + case string(StepEventFailed): + return StepEventFailed, true + case string(StepEventSkipped): + return StepEventSkipped, true + case string(StepEventBlocked): + return StepEventBlocked, true + default: + return "", false + } +} + +func normalizeExternalSource(source ExternalSource) (ExternalSource, bool) { + switch strings.ToLower(strings.TrimSpace(string(source))) { + case string(ExternalSourceGateway): + return ExternalSourceGateway, true + case string(ExternalSourceLedger): + return ExternalSourceLedger, true + case string(ExternalSourceCard): + return ExternalSourceCard, true + default: + return "", false + } +} + +func normalizeAggregateState(state agg.State) (agg.State, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case string(agg.StateCreated): + return agg.StateCreated, true + case string(agg.StateExecuting): + return agg.StateExecuting, true + case string(agg.StateNeedsAttention): + return agg.StateNeedsAttention, true + case string(agg.StateSettled): + return agg.StateSettled, true + case string(agg.StateFailed): + return agg.StateFailed, true + default: + return agg.StateUnspecified, false + } +} + +func normalizeStepState(state agg.StepState) (agg.StepState, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case string(agg.StepStatePending): + return agg.StepStatePending, true + case string(agg.StepStateRunning): + return agg.StepStateRunning, true + case string(agg.StepStateCompleted): + return agg.StepStateCompleted, true + case string(agg.StepStateFailed): + return agg.StepStateFailed, true + case string(agg.StepStateNeedsAttention): + return agg.StepStateNeedsAttention, true + case string(agg.StepStateSkipped): + return agg.StepStateSkipped, true + default: + return agg.StepStateUnspecified, false + } +} + +func normalizeLimit(limit int32) int32 { + if limit <= 0 { + return defaultTimelineLimit + } + if limit > maxTimelineLimit { + return maxTimelineLimit + } + return limit +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/helpers.go new file mode 100644 index 00000000..8ea73510 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/helpers.go @@ -0,0 +1,89 @@ +package oobs + +import ( + "strings" + "time" +) + +func nowUTC(nowFn func() time.Time) time.Time { + if nowFn == nil { + return time.Now().UTC() + } + return nowFn().UTC() +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + out := make(map[string]string, len(src)) + for key, value := range src { + out[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return out +} + +func trimStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + out := make(map[string]string, len(src)) + for key, value := range src { + k := strings.TrimSpace(key) + v := strings.TrimSpace(value) + if k == "" && v == "" { + continue + } + out[k] = v + } + if len(out) == 0 { + return nil + } + return out +} + +func mergeMaps(left map[string]string, right map[string]string) map[string]string { + if len(left) == 0 && len(right) == 0 { + return nil + } + out := make(map[string]string, len(left)+len(right)) + for key, value := range left { + out[key] = value + } + for key, value := range right { + out[key] = value + } + if len(out) == 0 { + return nil + } + return out +} + +func stepAttemptKey(paymentRef string, stepRef string, attempt uint32) string { + return paymentRef + "|" + stepRef + "|" + uint32String(attempt) +} + +func uint32String(v uint32) string { + if v == 0 { + return "0" + } + var buf [10]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} + +func reverseEntries(items []TimelineEntry) []TimelineEntry { + if len(items) <= 1 { + return items + } + out := make([]TimelineEntry, 0, len(items)) + for i := len(items) - 1; i >= 0; i-- { + out = append(out, items[i]) + } + return out +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/metrics.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/metrics.go new file mode 100644 index 00000000..97015450 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/metrics.go @@ -0,0 +1,21 @@ +package oobs + +import ( + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" +) + +type noopMetrics struct{} + +func newNoopMetrics() Metrics { + return noopMetrics{} +} + +func (noopMetrics) IncPaymentEvent(_ PaymentEvent, _ agg.State) {} + +func (noopMetrics) IncStepEvent(_ StepEvent, _ string, _ agg.StepState) {} + +func (noopMetrics) IncExternalEvent(_ ExternalSource, _ string) {} + +func (noopMetrics) ObserveStepDuration(_ string, _ agg.StepState, _ time.Duration) {} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/module.go new file mode 100644 index 00000000..9cdcdfde --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/module.go @@ -0,0 +1,152 @@ +package oobs + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/mlogger" +) + +// Observer records orchestration-v2 telemetry and exposes audit timelines. +type Observer interface { + RecordPayment(ctx context.Context, in RecordPaymentInput) error + RecordStep(ctx context.Context, in RecordStepInput) error + RecordExternal(ctx context.Context, in RecordExternalInput) error + + PaymentTimeline(ctx context.Context, in PaymentTimelineInput) (*TimelineOutput, error) + StepTimeline(ctx context.Context, in StepTimelineInput) (*TimelineOutput, error) +} + +// PaymentEvent classifies aggregate-level observability events. +type PaymentEvent string + +const ( + PaymentEventCreated PaymentEvent = "created" + PaymentEventStateChanged PaymentEvent = "state_changed" + PaymentEventNeedsAttention PaymentEvent = "needs_attention" + PaymentEventSettled PaymentEvent = "settled" + PaymentEventFailed PaymentEvent = "failed" +) + +// StepEvent classifies step-attempt-level observability events. +type StepEvent string + +const ( + StepEventScheduled StepEvent = "scheduled" + StepEventStarted StepEvent = "started" + StepEventCompleted StepEvent = "completed" + StepEventFailed StepEvent = "failed" + StepEventSkipped StepEvent = "skipped" + StepEventBlocked StepEvent = "blocked" +) + +// ExternalSource identifies asynchronous event origin. +type ExternalSource string + +const ( + ExternalSourceGateway ExternalSource = "gateway" + ExternalSourceLedger ExternalSource = "ledger" + ExternalSourceCard ExternalSource = "card" +) + +// TimelineScope classifies timeline item scope. +type TimelineScope string + +const ( + ScopePayment TimelineScope = "payment" + ScopeStep TimelineScope = "step" +) + +// TimelineEntry is one immutable audit timeline item. +type TimelineEntry struct { + OccurredAt time.Time + Scope TimelineScope + PaymentRef string + StepRef string + StepCode string + Attempt uint32 + Event string + State string + Message string + Fields map[string]string +} + +// TimelineOutput is one timeline query result page. +type TimelineOutput struct { + Items []TimelineEntry +} + +// RecordPaymentInput is aggregate-level telemetry payload. +type RecordPaymentInput struct { + Payment *agg.Payment + Event PaymentEvent + Message string + Fields map[string]string +} + +// RecordStepInput is step-attempt-level telemetry payload. +type RecordStepInput struct { + PaymentRef string + Step agg.StepExecution + Event StepEvent + Message string + Duration time.Duration + Fields map[string]string +} + +// RecordExternalInput is external-event telemetry payload. +type RecordExternalInput struct { + PaymentRef string + StepRef string + Attempt uint32 + Source ExternalSource + Status string + RefKind string + Ref string + Message string + Fields map[string]string +} + +// PaymentTimelineInput scopes payment-level timeline lookup. +type PaymentTimelineInput struct { + PaymentRef string + Limit int32 + Desc bool +} + +// StepTimelineInput scopes step-attempt timeline lookup. +type StepTimelineInput struct { + PaymentRef string + StepRef string + Attempt uint32 + Limit int32 + Desc bool +} + +// Metrics captures counters and durations for orchestration telemetry. +type Metrics interface { + IncPaymentEvent(event PaymentEvent, state agg.State) + IncStepEvent(event StepEvent, stepCode string, state agg.StepState) + IncExternalEvent(source ExternalSource, status string) + ObserveStepDuration(stepCode string, state agg.StepState, duration time.Duration) +} + +// AuditStore persists timeline events and serves timeline lookups. +type AuditStore interface { + Append(entry TimelineEntry) error + ListByPayment(paymentRef string, limit int32, desc bool) ([]TimelineEntry, error) + ListByStepAttempt(paymentRef string, stepRef string, attempt uint32, limit int32, desc bool) ([]TimelineEntry, error) +} + +// Dependencies configures observer integrations. +type Dependencies struct { + Logger mlogger.Logger + Metrics Metrics + Store AuditStore + Now func() time.Time +} + +func New(deps Dependencies) (Observer, error) { + return newService(deps) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go new file mode 100644 index 00000000..9a89e9b3 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go @@ -0,0 +1,414 @@ +package oobs + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger + metrics Metrics + store AuditStore + now func() time.Time +} + +func newService(deps Dependencies) (Observer, error) { + store := deps.Store + if store == nil { + store = newMemoryAuditStore() + } + + logger := deps.Logger.Named("oobs") + + metrics := deps.Metrics + if metrics == nil { + metrics = newNoopMetrics() + } + + return &svc{ + logger: logger, + metrics: metrics, + store: store, + now: deps.Now, + }, nil +} + +func (s *svc) RecordPayment(_ context.Context, in RecordPaymentInput) (err error) { + logger := s.logger + paymentRef := "" + if in.Payment != nil { + paymentRef = strings.TrimSpace(in.Payment.PaymentRef) + } + logger.Debug("Starting Record payment", + zap.String("payment_ref", paymentRef), + zap.String("event", string(in.Event)), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("payment_ref", paymentRef), + zap.String("event", string(in.Event)), + } + if err != nil { + logger.Warn("Failed to record payment", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Record payment", fields...) + }(time.Now()) + + entry, payment, event, err := buildPaymentEntry(nowUTC(s.now), in) + if err != nil { + return err + } + if err := s.store.Append(entry); err != nil { + return err + } + + s.metrics.IncPaymentEvent(event, payment.State) + s.logPayment(entry, payment.State, payment.Version) + return nil +} + +func (s *svc) RecordStep(_ context.Context, in RecordStepInput) (err error) { + logger := s.logger + logger.Debug("Starting Record step", + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)), + zap.Uint32("attempt", in.Step.Attempt), + zap.String("event", string(in.Event)), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)), + zap.Uint32("attempt", in.Step.Attempt), + zap.String("event", string(in.Event)), + } + if err != nil { + logger.Warn("Failed to record step", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Record step", fields...) + }(time.Now()) + + entry, step, event, duration, err := buildStepEntry(nowUTC(s.now), in) + if err != nil { + return err + } + if err := s.store.Append(entry); err != nil { + return err + } + + s.metrics.IncStepEvent(event, step.StepCode, step.State) + if duration > 0 { + s.metrics.ObserveStepDuration(step.StepCode, step.State, duration) + } + s.logStep(entry, step.State, duration) + return nil +} + +func (s *svc) RecordExternal(_ context.Context, in RecordExternalInput) (err error) { + logger := s.logger + logger.Debug("Starting Record external", + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(in.StepRef)), + zap.Uint32("attempt", in.Attempt), + zap.String("source", string(in.Source)), + zap.String("status", strings.TrimSpace(in.Status)), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(in.StepRef)), + zap.Uint32("attempt", in.Attempt), + zap.String("source", string(in.Source)), + zap.String("status", strings.TrimSpace(in.Status)), + } + if err != nil { + logger.Warn("Failed to record external", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Record external", fields...) + }(time.Now()) + + entry, source, status, err := buildExternalEntry(nowUTC(s.now), in) + if err != nil { + return err + } + if err := s.store.Append(entry); err != nil { + return err + } + + s.metrics.IncExternalEvent(source, status) + s.logExternal(entry, source, status) + return nil +} + +func (s *svc) PaymentTimeline(_ context.Context, in PaymentTimelineInput) (out *TimelineOutput, err error) { + logger := s.logger + logger.Debug("Starting Payment timeline", + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + zap.Int32("limit", in.Limit), + zap.Bool("desc", in.Desc), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, zap.Int("items_count", len(out.Items))) + } + if err != nil { + logger.Warn("Failed to payment timeline", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Payment timeline", fields...) + }(time.Now()) + + paymentRef := strings.TrimSpace(in.PaymentRef) + if paymentRef == "" { + return nil, merrors.InvalidArgument("payment_ref is required") + } + items, err := s.store.ListByPayment(paymentRef, in.Limit, in.Desc) + if err != nil { + return nil, err + } + out = &TimelineOutput{Items: items} + return out, nil +} + +func (s *svc) StepTimeline(_ context.Context, in StepTimelineInput) (out *TimelineOutput, err error) { + logger := s.logger + logger.Debug("Starting Step timeline", + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(in.StepRef)), + zap.Uint32("attempt", in.Attempt), + zap.Int32("limit", in.Limit), + zap.Bool("desc", in.Desc), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, zap.Int("items_count", len(out.Items))) + } + if err != nil { + logger.Warn("Failed to step timeline", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Step timeline", fields...) + }(time.Now()) + + paymentRef := strings.TrimSpace(in.PaymentRef) + if paymentRef == "" { + return nil, merrors.InvalidArgument("payment_ref is required") + } + stepRef := strings.TrimSpace(in.StepRef) + if stepRef == "" { + return nil, merrors.InvalidArgument("step_ref is required") + } + if in.Attempt == 0 { + return nil, merrors.InvalidArgument("attempt is required") + } + + items, err := s.store.ListByStepAttempt(paymentRef, stepRef, in.Attempt, in.Limit, in.Desc) + if err != nil { + return nil, err + } + out = &TimelineOutput{Items: items} + return out, nil +} + +func buildPaymentEntry(now time.Time, in RecordPaymentInput) (TimelineEntry, *agg.Payment, PaymentEvent, error) { + payment := in.Payment + if payment == nil { + return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment is required") + } + event, ok := normalizePaymentEvent(in.Event) + if !ok { + return TimelineEntry{}, nil, "", merrors.InvalidArgument("event is invalid") + } + state, ok := normalizeAggregateState(payment.State) + if !ok { + return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment.state is invalid") + } + paymentRef := strings.TrimSpace(payment.PaymentRef) + if paymentRef == "" { + return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment.payment_ref is required") + } + + entry := TimelineEntry{ + OccurredAt: now, + Scope: ScopePayment, + PaymentRef: paymentRef, + Event: string(event), + State: string(state), + Message: strings.TrimSpace(in.Message), + Fields: trimStringMap(in.Fields), + } + return entry, payment, event, nil +} + +func buildStepEntry(now time.Time, in RecordStepInput) (TimelineEntry, agg.StepExecution, StepEvent, time.Duration, error) { + event, ok := normalizeStepEvent(in.Event) + if !ok { + return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("event is invalid") + } + paymentRef := strings.TrimSpace(in.PaymentRef) + if paymentRef == "" { + return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("payment_ref is required") + } + stepRef := strings.TrimSpace(in.Step.StepRef) + if stepRef == "" { + return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("step.step_ref is required") + } + stepState, ok := normalizeStepState(in.Step.State) + if !ok { + return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("step.state is invalid") + } + + attempt := in.Step.Attempt + if attempt == 0 { + attempt = 1 + } + + entry := TimelineEntry{ + OccurredAt: now, + Scope: ScopeStep, + PaymentRef: paymentRef, + StepRef: stepRef, + StepCode: strings.TrimSpace(in.Step.StepCode), + Attempt: attempt, + Event: string(event), + State: string(stepState), + Message: strings.TrimSpace(in.Message), + Fields: trimStringMap(in.Fields), + } + + step := in.Step + step.State = stepState + step.StepRef = stepRef + step.StepCode = strings.TrimSpace(step.StepCode) + step.Attempt = attempt + + return entry, step, event, in.Duration, nil +} + +func buildExternalEntry(now time.Time, in RecordExternalInput) (TimelineEntry, ExternalSource, string, error) { + source, ok := normalizeExternalSource(in.Source) + if !ok { + return TimelineEntry{}, "", "", merrors.InvalidArgument("source is invalid") + } + paymentRef := strings.TrimSpace(in.PaymentRef) + if paymentRef == "" { + return TimelineEntry{}, "", "", merrors.InvalidArgument("payment_ref is required") + } + stepRef := strings.TrimSpace(in.StepRef) + if stepRef == "" { + return TimelineEntry{}, "", "", merrors.InvalidArgument("step_ref is required") + } + if in.Attempt == 0 { + return TimelineEntry{}, "", "", merrors.InvalidArgument("attempt is required") + } + + status := strings.ToLower(strings.TrimSpace(in.Status)) + if status == "" { + status = "received" + } + fields := mergeMaps(trimStringMap(in.Fields), map[string]string{ + "source": string(source), + "status": status, + "refKind": strings.TrimSpace(in.RefKind), + "ref": strings.TrimSpace(in.Ref), + }) + entry := TimelineEntry{ + OccurredAt: now, + Scope: ScopeStep, + PaymentRef: paymentRef, + StepRef: stepRef, + Attempt: in.Attempt, + Event: "external." + string(source) + "." + status, + State: status, + Message: strings.TrimSpace(in.Message), + Fields: trimStringMap(fields), + } + return entry, source, status, nil +} + +func (s *svc) logPayment(entry TimelineEntry, state agg.State, version uint64) { + logger := s.logger.With( + zap.String("payment_ref", entry.PaymentRef), + zap.String("event", entry.Event), + zap.String("state", string(state)), + zap.Uint64("version", version), + zap.String("scope", string(entry.Scope)), + ) + if entry.Message != "" { + logger = logger.With(zap.String("message", entry.Message)) + } + if len(entry.Fields) > 0 { + logger = logger.With(zap.Any("fields", entry.Fields)) + } + switch state { + case agg.StateFailed, agg.StateNeedsAttention: + logger.Warn("Orchestration payment event") + default: + logger.Info("Orchestration payment event") + } +} + +func (s *svc) logStep(entry TimelineEntry, state agg.StepState, duration time.Duration) { + logger := s.logger.With( + zap.String("payment_ref", entry.PaymentRef), + zap.String("step_ref", entry.StepRef), + zap.String("step_code", entry.StepCode), + zap.Uint32("attempt", entry.Attempt), + zap.String("event", entry.Event), + zap.String("state", string(state)), + zap.String("scope", string(entry.Scope)), + ) + if duration > 0 { + logger = logger.With(zap.Int64("duration_ms", duration.Milliseconds())) + } + if entry.Message != "" { + logger = logger.With(zap.String("message", entry.Message)) + } + if len(entry.Fields) > 0 { + logger = logger.With(zap.Any("fields", entry.Fields)) + } + switch state { + case agg.StepStateFailed, agg.StepStateNeedsAttention: + logger.Warn("Orchestration step event") + default: + logger.Info("Orchestration step event") + } +} + +func (s *svc) logExternal(entry TimelineEntry, source ExternalSource, status string) { + logger := s.logger.With( + zap.String("payment_ref", entry.PaymentRef), + zap.String("step_ref", entry.StepRef), + zap.Uint32("attempt", entry.Attempt), + zap.String("source", string(source)), + zap.String("status", status), + zap.String("event", entry.Event), + zap.String("scope", string(entry.Scope)), + ) + if entry.Message != "" { + logger = logger.With(zap.String("message", entry.Message)) + } + if len(entry.Fields) > 0 { + logger = logger.With(zap.Any("fields", entry.Fields)) + } + if strings.Contains(status, "failed") || strings.Contains(status, "cancel") { + logger.Warn("Orchestration external event") + return + } + logger.Info("Orchestration external event") +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service_test.go new file mode 100644 index 00000000..07dbaf6e --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service_test.go @@ -0,0 +1,296 @@ +package oobs + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" +) + +func TestRecordAndTimelineQueries(t *testing.T) { + now := time.Date(2026, time.February, 21, 18, 0, 0, 0, time.UTC) + metrics := &fakeMetrics{} + observer := mustObserver(t, Dependencies{ + Logger: zap.NewNop(), + Metrics: metrics, + Now: func() time.Time { return now }, + }) + + payment := &agg.Payment{ + PaymentRef: "pay-1", + State: agg.StateExecuting, + Version: 4, + } + if err := observer.RecordPayment(context.Background(), RecordPaymentInput{ + Payment: payment, + Event: PaymentEventStateChanged, + Message: "execution started", + Fields: map[string]string{ + "quotationRef": "quote-1", + }, + }); err != nil { + t.Fatalf("RecordPayment returned error: %v", err) + } + + step := agg.StepExecution{ + StepRef: "s1", + StepCode: "hop.10.crypto.send", + State: agg.StepStateRunning, + Attempt: 1, + } + if err := observer.RecordStep(context.Background(), RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: step, + Event: StepEventStarted, + Duration: 1800 * time.Millisecond, + }); err != nil { + t.Fatalf("RecordStep(started) returned error: %v", err) + } + + if err := observer.RecordExternal(context.Background(), RecordExternalInput{ + PaymentRef: payment.PaymentRef, + StepRef: step.StepRef, + Attempt: 1, + Source: ExternalSourceGateway, + Status: "success", + RefKind: "transfer_ref", + Ref: "tr-1", + }); err != nil { + t.Fatalf("RecordExternal returned error: %v", err) + } + + step.State = agg.StepStateFailed + if err := observer.RecordStep(context.Background(), RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: step, + Event: StepEventFailed, + Message: "terminal failure", + }); err != nil { + t.Fatalf("RecordStep(failed) returned error: %v", err) + } + + paymentTimeline, err := observer.PaymentTimeline(context.Background(), PaymentTimelineInput{ + PaymentRef: payment.PaymentRef, + }) + if err != nil { + t.Fatalf("PaymentTimeline returned error: %v", err) + } + if len(paymentTimeline.Items) != 4 { + t.Fatalf("payment timeline size mismatch: got=%d want=4", len(paymentTimeline.Items)) + } + assertEventOrder(t, paymentTimeline.Items, []string{ + "state_changed", + "started", + "external.gateway.success", + "failed", + }) + + stepTimeline, err := observer.StepTimeline(context.Background(), StepTimelineInput{ + PaymentRef: payment.PaymentRef, + StepRef: step.StepRef, + Attempt: 1, + }) + if err != nil { + t.Fatalf("StepTimeline returned error: %v", err) + } + if len(stepTimeline.Items) != 3 { + t.Fatalf("step timeline size mismatch: got=%d want=3", len(stepTimeline.Items)) + } + assertEventOrder(t, stepTimeline.Items, []string{ + "started", + "external.gateway.success", + "failed", + }) + + descTimeline, err := observer.PaymentTimeline(context.Background(), PaymentTimelineInput{ + PaymentRef: payment.PaymentRef, + Limit: 2, + Desc: true, + }) + if err != nil { + t.Fatalf("PaymentTimeline(desc) returned error: %v", err) + } + if len(descTimeline.Items) != 2 { + t.Fatalf("desc timeline size mismatch: got=%d want=2", len(descTimeline.Items)) + } + assertEventOrder(t, descTimeline.Items, []string{"failed", "external.gateway.success"}) + + if metrics.paymentEvents != 1 { + t.Fatalf("payment metric mismatch: got=%d want=1", metrics.paymentEvents) + } + if metrics.stepEvents != 2 { + t.Fatalf("step metric mismatch: got=%d want=2", metrics.stepEvents) + } + if metrics.externalEvents != 1 { + t.Fatalf("external metric mismatch: got=%d want=1", metrics.externalEvents) + } + if metrics.stepDurations != 1 { + t.Fatalf("duration metric mismatch: got=%d want=1", metrics.stepDurations) + } +} + +func TestStepTimeline_AttemptIsolation(t *testing.T) { + observer := mustObserver(t, Dependencies{Logger: zap.NewNop()}) + ctx := context.Background() + + for _, attempt := range []uint32{1, 2} { + err := observer.RecordStep(ctx, RecordStepInput{ + PaymentRef: "pay-1", + Step: agg.StepExecution{ + StepRef: "s1", + StepCode: "hop.10.crypto.send", + State: agg.StepStateCompleted, + Attempt: attempt, + }, + Event: StepEventCompleted, + }) + if err != nil { + t.Fatalf("RecordStep attempt=%d returned error: %v", attempt, err) + } + } + + a1, err := observer.StepTimeline(ctx, StepTimelineInput{ + PaymentRef: "pay-1", + StepRef: "s1", + Attempt: 1, + }) + if err != nil { + t.Fatalf("StepTimeline(attempt=1) returned error: %v", err) + } + if len(a1.Items) != 1 { + t.Fatalf("attempt=1 timeline size mismatch: got=%d want=1", len(a1.Items)) + } + if got, want := a1.Items[0].Attempt, uint32(1); got != want { + t.Fatalf("attempt mismatch: got=%d want=%d", got, want) + } +} + +func TestValidationErrors(t *testing.T) { + observer := mustObserver(t, Dependencies{Logger: zap.NewNop()}) + ctx := context.Background() + + tests := []struct { + name string + run func() error + }{ + { + name: "record payment missing payment", + run: func() error { + return observer.RecordPayment(ctx, RecordPaymentInput{ + Event: PaymentEventCreated, + }) + }, + }, + { + name: "record payment invalid event", + run: func() error { + return observer.RecordPayment(ctx, RecordPaymentInput{ + Payment: &agg.Payment{PaymentRef: "pay-1", State: agg.StateCreated}, + Event: PaymentEvent("bad"), + }) + }, + }, + { + name: "record step missing payment ref", + run: func() error { + return observer.RecordStep(ctx, RecordStepInput{ + Step: agg.StepExecution{ + StepRef: "s1", + State: agg.StepStatePending, + }, + Event: StepEventScheduled, + }) + }, + }, + { + name: "record step invalid state", + run: func() error { + return observer.RecordStep(ctx, RecordStepInput{ + PaymentRef: "pay-1", + Step: agg.StepExecution{ + StepRef: "s1", + State: agg.StepStateUnspecified, + }, + Event: StepEventScheduled, + }) + }, + }, + { + name: "record external invalid source", + run: func() error { + return observer.RecordExternal(ctx, RecordExternalInput{ + PaymentRef: "pay-1", + StepRef: "s1", + Attempt: 1, + Source: ExternalSource("bad"), + }) + }, + }, + { + name: "query step timeline missing attempt", + run: func() error { + _, err := observer.StepTimeline(ctx, StepTimelineInput{ + PaymentRef: "pay-1", + StepRef: "s1", + }) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.run(); !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +func assertEventOrder(t *testing.T, items []TimelineEntry, expected []string) { + t.Helper() + if len(items) != len(expected) { + t.Fatalf("event count mismatch: got=%d want=%d", len(items), len(expected)) + } + for i := range expected { + if got, want := items[i].Event, expected[i]; got != want { + t.Fatalf("event[%d] mismatch: got=%q want=%q", i, got, want) + } + } +} + +func mustObserver(t *testing.T, deps Dependencies) Observer { + t.Helper() + out, err := New(deps) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + return out +} + +type fakeMetrics struct { + paymentEvents int + stepEvents int + externalEvents int + stepDurations int +} + +func (f *fakeMetrics) IncPaymentEvent(_ PaymentEvent, _ agg.State) { + f.paymentEvents++ +} + +func (f *fakeMetrics) IncStepEvent(_ StepEvent, _ string, _ agg.StepState) { + f.stepEvents++ +} + +func (f *fakeMetrics) IncExternalEvent(_ ExternalSource, _ string) { + f.externalEvents++ +} + +func (f *fakeMetrics) ObserveStepDuration(_ string, _ agg.StepState, _ time.Duration) { + f.stepDurations++ +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/aggregate_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/aggregate_test.go new file mode 100644 index 00000000..553a23aa --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/aggregate_test.go @@ -0,0 +1,199 @@ +package opagg + +import ( + "testing" +) + +func TestAggregate_GroupsCompatibleItemsByRecipient(t *testing.T) { + aggregator := New() + + in := Input{ + Items: []Item{ + { + IntentSnapshot: sampleIntent("intent-a", "card-1", "100"), + QuoteSnapshot: sampleQuote("quote-batch", "100", "9150", "1.8"), + }, + { + IntentSnapshot: sampleIntent("intent-b", "card-1", "125"), + QuoteSnapshot: sampleQuote("quote-batch", "125", "11437.5", "1.8"), + }, + }, + } + + out, err := aggregator.Aggregate(in) + if err != nil { + t.Fatalf("Aggregate returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := len(out.Groups), 1; got != want { + t.Fatalf("groups count mismatch: got=%d want=%d", got, want) + } + + group := out.Groups[0] + if got, want := len(group.IntentRefs), 2; got != want { + t.Fatalf("intent_refs count mismatch: got=%d want=%d", got, want) + } + if got, want := group.IntentRefs[0], "intent-a"; got != want { + t.Fatalf("intent_refs[0] mismatch: got=%q want=%q", got, want) + } + if got, want := group.IntentRefs[1], "intent-b"; got != want { + t.Fatalf("intent_refs[1] mismatch: got=%q want=%q", got, want) + } + if got, want := group.IntentSnapshot.Amount.Amount, "225"; got != want { + t.Fatalf("intent amount mismatch: got=%q want=%q", got, want) + } + if group.QuoteSnapshot == nil { + t.Fatal("expected quote snapshot") + } + if got, want := group.QuoteSnapshot.DebitAmount.Amount, "225"; got != want { + t.Fatalf("debit amount mismatch: got=%q want=%q", got, want) + } + if got, want := group.QuoteSnapshot.ExpectedSettlementAmount.Amount, "20587.5"; got != want { + t.Fatalf("settlement amount mismatch: got=%q want=%q", got, want) + } + if got, want := group.QuoteSnapshot.ExpectedFeeTotal.Amount, "3.6"; got != want { + t.Fatalf("fee total mismatch: got=%q want=%q", got, want) + } + if got, want := group.QuoteSnapshot.TotalCost.Amount, "228.6"; got != want { + t.Fatalf("total cost mismatch: got=%q want=%q", got, want) + } + if got, want := len(group.QuoteSnapshot.FeeLines), 2; got != want { + t.Fatalf("fee lines mismatch: got=%d want=%d", got, want) + } + if got, want := group.QuoteSnapshot.FeeLines[0].Money.Amount, "3"; got != want { + t.Fatalf("platform fee mismatch: got=%q want=%q", got, want) + } + if got, want := group.QuoteSnapshot.FeeLines[1].Money.Amount, "0.6"; got != want { + t.Fatalf("tax fee mismatch: got=%q want=%q", got, want) + } + if group.IntentSnapshot.Attributes[attrAggregatedByRecipient] != "true" { + t.Fatalf("expected aggregated attribute %q=true", attrAggregatedByRecipient) + } + if got, want := group.IntentSnapshot.Attributes[attrAggregatedItems], "2"; got != want { + t.Fatalf("aggregated items mismatch: got=%q want=%q", got, want) + } +} + +func TestAggregate_DoesNotMergeDifferentRecipients(t *testing.T) { + aggregator := New() + + in := Input{ + Items: []Item{ + { + IntentSnapshot: sampleIntent("intent-a", "card-1", "100"), + QuoteSnapshot: sampleQuote("quote-batch", "100", "9150", "1.8"), + }, + { + IntentSnapshot: sampleIntent("intent-b", "card-2", "125"), + QuoteSnapshot: sampleQuote("quote-batch", "125", "11437.5", "1.8"), + }, + }, + } + + out, err := aggregator.Aggregate(in) + if err != nil { + t.Fatalf("Aggregate returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := len(out.Groups), 2; got != want { + t.Fatalf("groups count mismatch: got=%d want=%d", got, want) + } +} + +func TestAggregate_DoesNotMergeWhenBatchingIneligible(t *testing.T) { + aggregator := New() + + first := sampleQuote("quote-batch", "100", "9150", "1.8") + first.ExecutionConditions.BatchingEligible = false + second := sampleQuote("quote-batch", "125", "11437.5", "1.8") + second.ExecutionConditions.BatchingEligible = false + + in := Input{ + Items: []Item{ + { + IntentSnapshot: sampleIntent("intent-a", "card-1", "100"), + QuoteSnapshot: first, + }, + { + IntentSnapshot: sampleIntent("intent-b", "card-1", "125"), + QuoteSnapshot: second, + }, + }, + } + + out, err := aggregator.Aggregate(in) + if err != nil { + t.Fatalf("Aggregate returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := len(out.Groups), 2; got != want { + t.Fatalf("groups count mismatch: got=%d want=%d", got, want) + } +} + +func TestAggregate_UserBatchQuoteSampleCompactsToSingleRecipientOperation(t *testing.T) { + aggregator := New() + + in := Input{ + Items: []Item{ + { + IntentSnapshot: sampleIntent("q-intent-1771599670962253000", "card-1", "100"), + QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "100", "9150", "1.8"), + }, + { + IntentSnapshot: sampleIntent("q-intent-1771599670962255000", "card-1", "125"), + QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "125", "11437.5", "1.8"), + }, + { + IntentSnapshot: sampleIntent("q-intent-1771599670962256000", "card-1", "80"), + QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "80", "7320", "1.8"), + }, + }, + } + + out, err := aggregator.Aggregate(in) + if err != nil { + t.Fatalf("Aggregate returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := len(out.Groups), 1; got != want { + t.Fatalf("groups count mismatch: got=%d want=%d", got, want) + } + + group := out.Groups[0] + if got, want := len(group.IntentRefs), 3; got != want { + t.Fatalf("intent_refs count mismatch: got=%d want=%d", got, want) + } + if got, want := group.IntentSnapshot.Amount.Amount, "305"; got != want { + t.Fatalf("intent amount mismatch: got=%q want=%q", got, want) + } + if group.QuoteSnapshot == nil { + t.Fatal("expected quote snapshot") + } + if got, want := group.QuoteSnapshot.DebitAmount.Amount, "305"; got != want { + t.Fatalf("debit amount mismatch: got=%q want=%q", got, want) + } + if got, want := group.QuoteSnapshot.ExpectedSettlementAmount.Amount, "27907.5"; got != want { + t.Fatalf("settlement amount mismatch: got=%q want=%q", got, want) + } + if got, want := group.QuoteSnapshot.ExpectedFeeTotal.Amount, "5.4"; got != want { + t.Fatalf("fee total mismatch: got=%q want=%q", got, want) + } + if got, want := group.QuoteSnapshot.TotalCost.Amount, "310.4"; got != want { + t.Fatalf("total cost mismatch: got=%q want=%q", got, want) + } + if group.IntentSnapshot.Attributes[attrAggregatedByRecipient] != "true" { + t.Fatalf("expected aggregated attribute %q=true", attrAggregatedByRecipient) + } + if got, want := group.IntentSnapshot.Attributes[attrAggregatedItems], "3"; got != want { + t.Fatalf("aggregated items mismatch: got=%q want=%q", got, want) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/clone.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/clone.go new file mode 100644 index 00000000..dee1ed58 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/clone.go @@ -0,0 +1,247 @@ +package opagg + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func cloneMoney(src *paymenttypes.Money) *paymenttypes.Money { + if src == nil { + return nil + } + return &paymenttypes.Money{ + Amount: strings.TrimSpace(src.Amount), + Currency: normalizeCurrency(src.Currency), + } +} + +func cloneNetworkFee(src *paymenttypes.NetworkFeeEstimate) *paymenttypes.NetworkFeeEstimate { + if src == nil { + return nil + } + return &paymenttypes.NetworkFeeEstimate{ + NetworkFee: cloneMoney(src.NetworkFee), + EstimationContext: strings.TrimSpace(src.EstimationContext), + } +} + +func cloneFeeLines(src []*paymenttypes.FeeLine) []*paymenttypes.FeeLine { + if len(src) == 0 { + return nil + } + out := make([]*paymenttypes.FeeLine, 0, len(src)) + for _, line := range src { + if line == nil { + continue + } + out = append(out, cloneFeeLine(line)) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneFeeLine(src *paymenttypes.FeeLine) *paymenttypes.FeeLine { + if src == nil { + return nil + } + return &paymenttypes.FeeLine{ + LedgerAccountRef: strings.TrimSpace(src.LedgerAccountRef), + Money: cloneMoney(src.Money), + LineType: src.LineType, + Side: src.Side, + Meta: cloneMetadata(src.Meta), + } +} + +func cloneFeeRules(src []*paymenttypes.AppliedRule) []*paymenttypes.AppliedRule { + if len(src) == 0 { + return nil + } + out := make([]*paymenttypes.AppliedRule, 0, len(src)) + for _, rule := range src { + if rule == nil { + continue + } + out = append(out, cloneFeeRule(rule)) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneFeeRule(src *paymenttypes.AppliedRule) *paymenttypes.AppliedRule { + if src == nil { + return nil + } + return &paymenttypes.AppliedRule{ + RuleID: strings.TrimSpace(src.RuleID), + RuleVersion: strings.TrimSpace(src.RuleVersion), + Formula: strings.TrimSpace(src.Formula), + Rounding: src.Rounding, + TaxCode: strings.TrimSpace(src.TaxCode), + TaxRate: strings.TrimSpace(src.TaxRate), + Parameters: cloneMetadata(src.Parameters), + } +} + +func cloneFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote { + if src == nil { + return nil + } + dst := &paymenttypes.FXQuote{ + QuoteRef: strings.TrimSpace(src.QuoteRef), + Side: src.Side, + ExpiresAtUnixMs: src.ExpiresAtUnixMs, + PricedAtUnixMs: src.PricedAtUnixMs, + Provider: strings.TrimSpace(src.Provider), + RateRef: strings.TrimSpace(src.RateRef), + Firm: src.Firm, + BaseAmount: cloneMoney(src.BaseAmount), + QuoteAmount: cloneMoney(src.QuoteAmount), + } + if src.Pair != nil { + dst.Pair = &paymenttypes.CurrencyPair{ + Base: normalizeCurrency(src.Pair.Base), + Quote: normalizeCurrency(src.Pair.Quote), + } + } + if src.Price != nil { + dst.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)} + } + return dst +} + +func cloneRoute(src *paymenttypes.QuoteRouteSpecification) *paymenttypes.QuoteRouteSpecification { + if src == nil { + return nil + } + dst := &paymenttypes.QuoteRouteSpecification{ + Rail: strings.TrimSpace(src.Rail), + Provider: strings.TrimSpace(src.Provider), + PayoutMethod: strings.TrimSpace(src.PayoutMethod), + Network: strings.TrimSpace(src.Network), + RouteRef: strings.TrimSpace(src.RouteRef), + PricingProfileRef: strings.TrimSpace(src.PricingProfileRef), + } + if src.Settlement != nil { + dst.Settlement = &paymenttypes.QuoteRouteSettlement{ + Model: strings.TrimSpace(src.Settlement.Model), + Asset: cloneAsset(src.Settlement.Asset), + } + } + if len(src.Hops) > 0 { + dst.Hops = make([]*paymenttypes.QuoteRouteHop, 0, len(src.Hops)) + for _, hop := range src.Hops { + if hop == nil { + continue + } + dst.Hops = append(dst.Hops, &paymenttypes.QuoteRouteHop{ + Index: hop.Index, + Rail: strings.TrimSpace(hop.Rail), + Gateway: strings.TrimSpace(hop.Gateway), + InstanceID: strings.TrimSpace(hop.InstanceID), + Network: strings.TrimSpace(hop.Network), + Role: hop.Role, + }) + } + } + return dst +} + +func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset { + if src == nil { + return nil + } + return &paymenttypes.Asset{ + Chain: strings.TrimSpace(src.Chain), + TokenSymbol: strings.TrimSpace(src.TokenSymbol), + ContractAddress: strings.TrimSpace(src.ContractAddress), + } +} + +func cloneExecutionConditions(src *paymenttypes.QuoteExecutionConditions) *paymenttypes.QuoteExecutionConditions { + if src == nil { + return nil + } + dst := &paymenttypes.QuoteExecutionConditions{ + Readiness: src.Readiness, + BatchingEligible: src.BatchingEligible, + PrefundingRequired: src.PrefundingRequired, + PrefundingCostIncluded: src.PrefundingCostIncluded, + LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution, + LatencyHint: strings.TrimSpace(src.LatencyHint), + } + if len(src.Assumptions) > 0 { + dst.Assumptions = cloneStringSlice(src.Assumptions) + } + return dst +} + +func cloneMetadata(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + out := make(map[string]string, len(src)) + for key, value := range src { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneStringSlice(src []string) []string { + if len(src) == 0 { + return nil + } + out := make([]string, 0, len(src)) + for _, item := range src { + token := strings.TrimSpace(item) + if token == "" { + continue + } + out = append(out, token) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) { + var dst model.PaymentIntent + if err := bsonClone(src, &dst); err != nil { + return model.PaymentIntent{}, err + } + return dst, nil +} + +func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSnapshot, error) { + if src == nil { + return nil, nil + } + dst := &model.PaymentQuoteSnapshot{} + if err := bsonClone(src, dst); err != nil { + return nil, err + } + return dst, nil +} + +func bsonClone(src any, dst any) error { + data, err := bson.Marshal(src) + if err != nil { + return err + } + return bson.Unmarshal(data, dst) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go new file mode 100644 index 00000000..25ea0e7a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go @@ -0,0 +1,128 @@ +package opagg + +import ( + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func sampleIntent(ref, cardToken, amount string) model.PaymentIntent { + return model.PaymentIntent{ + Ref: ref, + Kind: model.PaymentKindPayout, + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "src-wallet-1", + Asset: &paymenttypes.Asset{Chain: "TRON", TokenSymbol: "USDT"}, + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Token: cardToken, + Country: "RU", + }, + }, + Amount: &paymenttypes.Money{ + Amount: amount, + Currency: "USDT", + }, + SettlementMode: model.SettlementModeFixSource, + SettlementCurrency: "RUB", + } +} + +func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuoteSnapshot { + fee := "1.5" + tax := "0.3" + totalCost := addStrings(debit, feeTotal) + + return &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{ + Amount: debit, + Currency: "USDT", + }, + ExpectedSettlementAmount: &paymenttypes.Money{ + Amount: settlement, + Currency: "RUB", + }, + ExpectedFeeTotal: &paymenttypes.Money{ + Amount: feeTotal, + Currency: "USDT", + }, + TotalCost: &paymenttypes.Money{ + Amount: totalCost, + Currency: "USDT", + }, + FeeLines: []*paymenttypes.FeeLine{ + { + LedgerAccountRef: "ledger:fees:usdt", + Money: &paymenttypes.Money{Amount: fee, Currency: "USDT"}, + LineType: paymenttypes.PostingLineTypeFee, + Side: paymenttypes.EntrySideDebit, + Meta: map[string]string{"component": "platform_fee", "provider": "monetix"}, + }, + { + LedgerAccountRef: "ledger:tax:usdt", + Money: &paymenttypes.Money{Amount: tax, Currency: "USDT"}, + LineType: paymenttypes.PostingLineTypeTax, + Side: paymenttypes.EntrySideDebit, + Meta: map[string]string{"component": "vat", "provider": "monetix"}, + }, + }, + FeeRules: []*paymenttypes.AppliedRule{ + { + RuleID: "rule.platform.usdt", + RuleVersion: "2026-02-01", + Formula: "flat(1.50)+tax(0.30)", + TaxCode: "VAT", + TaxRate: "0.20", + Parameters: map[string]string{"country": "RU"}, + }, + }, + FXQuote: &paymenttypes.FXQuote{ + QuoteRef: "fx-usdt-rub", + Provider: "test-oracle", + RateRef: "rate-usdt-rub", + Side: paymenttypes.FXSideSellBaseBuyQuote, + Firm: true, + Price: &paymenttypes.Decimal{Value: "91.5"}, + BaseAmount: &paymenttypes.Money{Amount: debit, Currency: "USDT"}, + QuoteAmount: &paymenttypes.Money{ + Amount: settlement, + Currency: "RUB", + }, + }, + Route: &paymenttypes.QuoteRouteSpecification{ + RouteRef: "rte-recipient-1", + PricingProfileRef: "fee_profile_1", + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"}, + {Index: 3, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"}, + }, + Settlement: &paymenttypes.QuoteRouteSettlement{ + Model: "fix_source", + Asset: &paymenttypes.Asset{TokenSymbol: "USDT"}, + }, + }, + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: paymenttypes.QuoteExecutionReadinessLiquidityReady, + BatchingEligible: true, + LiquidityCheckRequiredAtExecution: true, + LatencyHint: "instant", + Assumptions: []string{"execution_time_liquidity_check"}, + }, + } +} + +func addStrings(left, right string) string { + l, lErr := decimal.NewFromString(left) + r, rErr := decimal.NewFromString(right) + if lErr != nil || rErr != nil { + return "" + } + return l.Add(r).String() +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/helpers.go new file mode 100644 index 00000000..db09f4c4 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/helpers.go @@ -0,0 +1,34 @@ +package opagg + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" +) + +func isEmptyIntentSnapshot(intent model.PaymentIntent) bool { + return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func itoa(v int) string { + if v <= 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/keying.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/keying.go new file mode 100644 index 00000000..aad666aa --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/keying.go @@ -0,0 +1,218 @@ +package opagg + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func endpointKey(ep model.PaymentEndpoint) (string, error) { + endpointType := normalizeEndpointType(ep) + if endpointType == model.EndpointTypeUnspecified { + return "", merrors.InvalidArgument("endpoint type is required") + } + + parts := []string{ + "type=" + strings.ToLower(strings.TrimSpace(string(endpointType))), + "instance=" + strings.TrimSpace(ep.InstanceID), + } + + switch endpointType { + case model.EndpointTypeLedger: + if ep.Ledger == nil { + return "", merrors.InvalidArgument("ledger endpoint is required") + } + parts = append(parts, + "account="+strings.TrimSpace(ep.Ledger.LedgerAccountRef), + "contra="+strings.TrimSpace(ep.Ledger.ContraLedgerAccountRef), + ) + case model.EndpointTypeManagedWallet: + if ep.ManagedWallet == nil { + return "", merrors.InvalidArgument("managed_wallet endpoint is required") + } + parts = append(parts, + "wallet="+strings.TrimSpace(ep.ManagedWallet.ManagedWalletRef), + "asset="+assetKey(ep.ManagedWallet.Asset), + ) + case model.EndpointTypeExternalChain: + if ep.ExternalChain == nil { + return "", merrors.InvalidArgument("external_chain endpoint is required") + } + parts = append(parts, + "address="+strings.TrimSpace(ep.ExternalChain.Address), + "memo="+strings.TrimSpace(ep.ExternalChain.Memo), + "asset="+assetKey(ep.ExternalChain.Asset), + ) + case model.EndpointTypeCard: + if ep.Card == nil { + return "", merrors.InvalidArgument("card endpoint is required") + } + parts = append(parts, + "token="+strings.TrimSpace(ep.Card.Token), + "pan="+strings.TrimSpace(ep.Card.Pan), + "masked="+strings.TrimSpace(ep.Card.MaskedPan), + "country="+strings.TrimSpace(ep.Card.Country), + "exp="+strconv.FormatUint(uint64(ep.Card.ExpMonth), 10)+"-"+strconv.FormatUint(uint64(ep.Card.ExpYear), 10), + ) + default: + return "", merrors.InvalidArgument("unsupported endpoint type") + } + + return strings.Join(parts, "|"), nil +} + +func normalizeEndpointType(ep model.PaymentEndpoint) model.PaymentEndpointType { + if ep.Type != model.EndpointTypeUnspecified { + return ep.Type + } + switch { + case ep.Ledger != nil: + return model.EndpointTypeLedger + case ep.ManagedWallet != nil: + return model.EndpointTypeManagedWallet + case ep.ExternalChain != nil: + return model.EndpointTypeExternalChain + case ep.Card != nil: + return model.EndpointTypeCard + default: + return model.EndpointTypeUnspecified + } +} + +func routeSignature(route *paymenttypes.QuoteRouteSpecification) string { + if route == nil { + return "none" + } + parts := []string{ + "route_ref=" + strings.TrimSpace(route.RouteRef), + "rail=" + strings.ToUpper(strings.TrimSpace(route.Rail)), + "provider=" + strings.TrimSpace(route.Provider), + "network=" + strings.TrimSpace(route.Network), + "settlement=" + settlementKey(route.Settlement), + } + for i, hop := range route.Hops { + if hop == nil { + continue + } + parts = append(parts, fmt.Sprintf( + "hop[%d]=%d:%s:%s:%s:%s:%s", + i, + hop.Index, + strings.ToUpper(strings.TrimSpace(hop.Rail)), + strings.TrimSpace(hop.Gateway), + strings.TrimSpace(hop.InstanceID), + strings.TrimSpace(hop.Network), + strings.ToUpper(strings.TrimSpace(string(hop.Role))), + )) + } + return strings.Join(parts, "|") +} + +func settlementKey(s *paymenttypes.QuoteRouteSettlement) string { + if s == nil { + return "none" + } + return strings.Join([]string{ + "model=" + strings.TrimSpace(s.Model), + "asset=" + assetKey(s.Asset), + }, "|") +} + +func fxQuoteSignature(q *paymenttypes.FXQuote) string { + if q == nil { + return "none" + } + pair := "none" + if q.Pair != nil { + pair = strings.ToUpper(strings.TrimSpace(q.Pair.Base)) + "/" + strings.ToUpper(strings.TrimSpace(q.Pair.Quote)) + } + price := "" + if q.Price != nil { + price = strings.TrimSpace(q.Price.Value) + } + return strings.Join([]string{ + "pair=" + pair, + "side=" + strings.ToUpper(strings.TrimSpace(string(q.Side))), + "price=" + price, + "provider=" + strings.TrimSpace(q.Provider), + "rate_ref=" + strings.TrimSpace(q.RateRef), + "firm=" + strconv.FormatBool(q.Firm), + }, "|") +} + +func feeLineKey(line *paymenttypes.FeeLine) string { + if line == nil { + return "" + } + return strings.Join([]string{ + strings.TrimSpace(line.LedgerAccountRef), + strings.ToUpper(strings.TrimSpace(string(line.LineType))), + strings.ToUpper(strings.TrimSpace(string(line.Side))), + moneyCurrency(line.Money), + metadataSignature(line.Meta), + }, "|") +} + +func feeRuleKey(rule *paymenttypes.AppliedRule) string { + if rule == nil { + return "" + } + return strings.Join([]string{ + strings.TrimSpace(rule.RuleID), + strings.TrimSpace(rule.RuleVersion), + strings.TrimSpace(rule.Formula), + strings.TrimSpace(rule.TaxCode), + strings.TrimSpace(rule.TaxRate), + metadataSignature(rule.Parameters), + }, "|") +} + +func metadataSignature(meta map[string]string) string { + if len(meta) == 0 { + return "" + } + keys := make([]string, 0, len(meta)) + for key := range meta { + k := strings.TrimSpace(key) + if k == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) == 0 { + return "" + } + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+strings.TrimSpace(meta[key])) + } + return strings.Join(parts, ",") +} + +func moneyCurrency(m *paymenttypes.Money) string { + if m == nil { + return "" + } + return normalizeCurrency(m.Currency) +} + +func normalizeCurrency(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func assetKey(asset *paymenttypes.Asset) string { + if asset == nil { + return "" + } + return strings.Join([]string{ + strings.ToUpper(strings.TrimSpace(asset.Chain)), + strings.ToUpper(strings.TrimSpace(asset.TokenSymbol)), + strings.TrimSpace(asset.ContractAddress), + }, ":") +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_core.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_core.go new file mode 100644 index 00000000..4f5c1621 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_core.go @@ -0,0 +1,137 @@ +package opagg + +import ( + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func mergeIntentSnapshot(dst *model.PaymentIntent, src model.PaymentIntent) error { + if dst == nil { + return merrors.InvalidArgument("intent_snapshot is required") + } + + sum, err := mergeMoney(dst.Amount, src.Amount, "intent_snapshot.amount") + if err != nil { + return err + } + dst.Amount = sum + + if dst.SettlementCurrency == "" { + dst.SettlementCurrency = strings.TrimSpace(src.SettlementCurrency) + } else if srcCurrency := strings.TrimSpace(src.SettlementCurrency); srcCurrency != "" && !strings.EqualFold(dst.SettlementCurrency, srcCurrency) { + return merrors.InvalidArgument("intent_snapshot.settlement_currency mismatch") + } + + if dst.Attributes == nil { + dst.Attributes = map[string]string{} + } + for key, value := range src.Attributes { + k := strings.TrimSpace(key) + if k == "" { + continue + } + if _, exists := dst.Attributes[k]; exists { + continue + } + dst.Attributes[k] = strings.TrimSpace(value) + } + return nil +} + +func mergeQuoteSnapshot(dst *model.PaymentQuoteSnapshot, src *model.PaymentQuoteSnapshot) error { + if dst == nil { + return merrors.InvalidArgument("quote_snapshot is required") + } + if src == nil { + return nil + } + + var err error + dst.DebitAmount, err = mergeMoney(dst.DebitAmount, src.DebitAmount, "quote_snapshot.debit_amount") + if err != nil { + return err + } + dst.DebitSettlementAmount, err = mergeMoney(dst.DebitSettlementAmount, src.DebitSettlementAmount, "quote_snapshot.debit_settlement_amount") + if err != nil { + return err + } + dst.ExpectedSettlementAmount, err = mergeMoney(dst.ExpectedSettlementAmount, src.ExpectedSettlementAmount, "quote_snapshot.expected_settlement_amount") + if err != nil { + return err + } + dst.ExpectedFeeTotal, err = mergeMoney(dst.ExpectedFeeTotal, src.ExpectedFeeTotal, "quote_snapshot.expected_fee_total") + if err != nil { + return err + } + dst.TotalCost, err = mergeMoney(dst.TotalCost, src.TotalCost, "quote_snapshot.total_cost") + if err != nil { + return err + } + + dst.NetworkFee, err = mergeNetworkFee(dst.NetworkFee, src.NetworkFee) + if err != nil { + return err + } + dst.FeeLines, err = mergeFeeLines(dst.FeeLines, src.FeeLines) + if err != nil { + return err + } + dst.FeeRules = mergeFeeRules(dst.FeeRules, src.FeeRules) + + dst.Route, err = mergeRoute(dst.Route, src.Route) + if err != nil { + return err + } + dst.ExecutionConditions = mergeExecutionConditions(dst.ExecutionConditions, src.ExecutionConditions) + + dst.FXQuote, err = mergeFXQuote(dst.FXQuote, src.FXQuote) + if err != nil { + return err + } + + if strings.TrimSpace(dst.QuoteRef) == "" { + dst.QuoteRef = strings.TrimSpace(src.QuoteRef) + } else if srcRef := strings.TrimSpace(src.QuoteRef); srcRef != "" && dst.QuoteRef != srcRef { + return merrors.InvalidArgument("quote_snapshot.quote_ref mismatch") + } + + return nil +} + +func mergeMoney(dst, src *paymenttypes.Money, field string) (*paymenttypes.Money, error) { + if dst == nil { + return cloneMoney(src), nil + } + if src == nil { + return dst, nil + } + + dstCurrency := normalizeCurrency(dst.Currency) + srcCurrency := normalizeCurrency(src.Currency) + if dstCurrency == "" || srcCurrency == "" { + return nil, merrors.InvalidArgument(field + ": currency is required") + } + if dstCurrency != srcCurrency { + return nil, merrors.InvalidArgument(field + ": currency mismatch") + } + + left, err := parseDecimal(dst.Amount) + if err != nil { + return nil, merrors.InvalidArgument(field + ": invalid amount") + } + right, err := parseDecimal(src.Amount) + if err != nil { + return nil, merrors.InvalidArgument(field + ": invalid amount") + } + dst.Amount = left.Add(right).String() + dst.Currency = dstCurrency + return dst, nil +} + +func parseDecimal(raw string) (decimal.Decimal, error) { + return decimal.NewFromString(strings.TrimSpace(raw)) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_quote_parts.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_quote_parts.go new file mode 100644 index 00000000..630a685f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_quote_parts.go @@ -0,0 +1,219 @@ +package opagg + +import ( + "strings" + + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func mergeRoute(dst, src *paymenttypes.QuoteRouteSpecification) (*paymenttypes.QuoteRouteSpecification, error) { + if dst == nil { + return cloneRoute(src), nil + } + if src == nil { + return dst, nil + } + if routeSignature(dst) != routeSignature(src) { + return nil, merrors.InvalidArgument("quote_snapshot.route mismatch") + } + return dst, nil +} + +func mergeFXQuote(dst, src *paymenttypes.FXQuote) (*paymenttypes.FXQuote, error) { + if dst == nil { + return cloneFXQuote(src), nil + } + if src == nil { + return dst, nil + } + if fxQuoteSignature(dst) != fxQuoteSignature(src) { + return nil, merrors.InvalidArgument("quote_snapshot.fx_quote mismatch") + } + var err error + dst.BaseAmount, err = mergeMoney(dst.BaseAmount, src.BaseAmount, "quote_snapshot.fx_quote.base_amount") + if err != nil { + return nil, err + } + dst.QuoteAmount, err = mergeMoney(dst.QuoteAmount, src.QuoteAmount, "quote_snapshot.fx_quote.quote_amount") + if err != nil { + return nil, err + } + if dst.ExpiresAtUnixMs == 0 || (src.ExpiresAtUnixMs > 0 && src.ExpiresAtUnixMs < dst.ExpiresAtUnixMs) { + dst.ExpiresAtUnixMs = src.ExpiresAtUnixMs + } + if src.PricedAtUnixMs > dst.PricedAtUnixMs { + dst.PricedAtUnixMs = src.PricedAtUnixMs + } + if dst.QuoteRef == "" { + dst.QuoteRef = src.QuoteRef + } + return dst, nil +} + +func mergeExecutionConditions(dst, src *paymenttypes.QuoteExecutionConditions) *paymenttypes.QuoteExecutionConditions { + if dst == nil { + return cloneExecutionConditions(src) + } + if src == nil { + return dst + } + + dst.Readiness = mergedReadiness(dst.Readiness, src.Readiness) + dst.BatchingEligible = dst.BatchingEligible && src.BatchingEligible + dst.PrefundingRequired = dst.PrefundingRequired || src.PrefundingRequired + dst.PrefundingCostIncluded = dst.PrefundingCostIncluded || src.PrefundingCostIncluded + dst.LiquidityCheckRequiredAtExecution = dst.LiquidityCheckRequiredAtExecution || src.LiquidityCheckRequiredAtExecution + if dst.LatencyHint == "" { + dst.LatencyHint = src.LatencyHint + } else if srcHint := strings.TrimSpace(src.LatencyHint); srcHint != "" && !strings.EqualFold(dst.LatencyHint, srcHint) { + dst.LatencyHint = "mixed" + } + dst.Assumptions = mergeAssumptions(dst.Assumptions, src.Assumptions) + + return dst +} + +func mergedReadiness(a, b paymenttypes.QuoteExecutionReadiness) paymenttypes.QuoteExecutionReadiness { + scoreA := readinessScore(a) + scoreB := readinessScore(b) + if scoreA <= scoreB { + return a + } + return b +} + +func readinessScore(v paymenttypes.QuoteExecutionReadiness) int { + switch v { + case paymenttypes.QuoteExecutionReadinessIndicative: + return 0 + case paymenttypes.QuoteExecutionReadinessLiquidityObtainable: + return 1 + case paymenttypes.QuoteExecutionReadinessLiquidityReady: + return 2 + default: + return 2 + } +} + +func mergeAssumptions(dst, src []string) []string { + if len(dst) == 0 && len(src) == 0 { + return nil + } + seen := make(map[string]struct{}, len(dst)+len(src)) + out := make([]string, 0, len(dst)+len(src)) + for _, item := range dst { + key := strings.TrimSpace(item) + if key == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + for _, item := range src { + key := strings.TrimSpace(item) + if key == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + if len(out) == 0 { + return nil + } + return out +} + +func mergeNetworkFee(dst, src *paymenttypes.NetworkFeeEstimate) (*paymenttypes.NetworkFeeEstimate, error) { + if dst == nil { + return cloneNetworkFee(src), nil + } + if src == nil { + return dst, nil + } + sum, err := mergeMoney(dst.NetworkFee, src.NetworkFee, "quote_snapshot.network_fee.network_fee") + if err != nil { + return nil, err + } + dst.NetworkFee = sum + if dst.EstimationContext == "" { + dst.EstimationContext = strings.TrimSpace(src.EstimationContext) + } else if ctx := strings.TrimSpace(src.EstimationContext); ctx != "" && !strings.EqualFold(dst.EstimationContext, ctx) { + dst.EstimationContext = "mixed" + } + return dst, nil +} + +func mergeFeeLines(dst, src []*paymenttypes.FeeLine) ([]*paymenttypes.FeeLine, error) { + if len(dst) == 0 { + return cloneFeeLines(src), nil + } + if len(src) == 0 { + return dst, nil + } + + out := cloneFeeLines(dst) + indexByKey := make(map[string]int, len(out)) + for i, line := range out { + if line == nil { + continue + } + indexByKey[feeLineKey(line)] = i + } + + for _, line := range src { + if line == nil { + continue + } + key := feeLineKey(line) + if idx, exists := indexByKey[key]; exists { + sum, err := mergeMoney(out[idx].Money, line.Money, "quote_snapshot.fee_lines["+key+"]") + if err != nil { + return nil, err + } + out[idx].Money = sum + continue + } + cloned := cloneFeeLine(line) + indexByKey[key] = len(out) + out = append(out, cloned) + } + + return out, nil +} + +func mergeFeeRules(dst, src []*paymenttypes.AppliedRule) []*paymenttypes.AppliedRule { + if len(dst) == 0 { + return cloneFeeRules(src) + } + if len(src) == 0 { + return dst + } + + out := cloneFeeRules(dst) + seen := make(map[string]struct{}, len(out)) + for _, rule := range out { + if rule == nil { + continue + } + seen[feeRuleKey(rule)] = struct{}{} + } + for _, rule := range src { + if rule == nil { + continue + } + key := feeRuleKey(rule) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, cloneFeeRule(rule)) + } + return out +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go new file mode 100644 index 00000000..2e05f5e8 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go @@ -0,0 +1,49 @@ +package opagg + +import ( + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" +) + +// Aggregator compacts compatible quote items into recipient-level execution groups. +type Aggregator interface { + Aggregate(in Input) (*Output, error) +} + +// Input contains quote/intents selected for one execution request scope. +type Input struct { + Items []Item +} + +// Item is one quote-intent pair candidate for aggregation. +type Item struct { + IntentRef string + IntentSnapshot model.PaymentIntent + QuoteSnapshot *model.PaymentQuoteSnapshot +} + +// Group is one aggregated recipient operation group. +type Group struct { + RecipientKey string + IntentRefs []string + IntentSnapshot model.PaymentIntent + QuoteSnapshot *model.PaymentQuoteSnapshot +} + +// Output is the aggregation result. +type Output struct { + Groups []Group +} + +// Dependencies configures operation aggregator integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) Aggregator { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return &svc{logger: dep.Logger.Named("opagg")} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/service.go new file mode 100644 index 00000000..22264e68 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/service.go @@ -0,0 +1,192 @@ +package opagg + +import ( + "strconv" + "strings" + "time" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const ( + keySep = "\x1f" + + attrAggregatedByRecipient = "orchestrator.v2.aggregated_by_recipient" + attrAggregatedItems = "orchestrator.v2.aggregated_items" +) + +type svc struct { + logger mlogger.Logger +} + +type groupAccumulator struct { + recipientKey string + intentRefs []string + intent model.PaymentIntent + quote *model.PaymentQuoteSnapshot +} + +func (s *svc) Aggregate(in Input) (out *Output, err error) { + logger := s.logger + logger.Debug("Starting Aggregate", zap.Int("items_count", len(in.Items))) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, zap.Int("groups_count", len(out.Groups))) + } + if err != nil { + logger.Warn("Failed to aggregate", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Aggregate", fields...) + }(time.Now()) + + if len(in.Items) == 0 { + return nil, merrors.InvalidArgument("items are required") + } + + groups := make(map[string]*groupAccumulator, len(in.Items)) + order := make([]string, 0, len(in.Items)) + + for i := range in.Items { + item := in.Items[i] + if err := validateItem(item); err != nil { + return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error()) + } + + key, recipientKey, err := groupingKey(item) + if err != nil { + return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error()) + } + if !isBatchingEligible(item.QuoteSnapshot) { + key = key + keySep + "non_batching=" + itoa(i) + } + + intentRef := firstNonEmpty( + strings.TrimSpace(item.IntentRef), + strings.TrimSpace(item.IntentSnapshot.Ref), + "intent-"+itoa(i+1), + ) + + acc, exists := groups[key] + if !exists { + intentSnapshot, cloneErr := cloneIntentSnapshot(item.IntentSnapshot) + if cloneErr != nil { + return nil, cloneErr + } + quoteSnapshot, cloneErr := cloneQuoteSnapshot(item.QuoteSnapshot) + if cloneErr != nil { + return nil, cloneErr + } + if quoteSnapshot == nil { + return nil, merrors.InvalidArgument("items[" + itoa(i) + "].quote_snapshot is required") + } + + groups[key] = &groupAccumulator{ + recipientKey: recipientKey, + intentRefs: []string{intentRef}, + intent: intentSnapshot, + quote: quoteSnapshot, + } + order = append(order, key) + continue + } + + if err := mergeIntentSnapshot(&acc.intent, item.IntentSnapshot); err != nil { + return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error()) + } + if err := mergeQuoteSnapshot(acc.quote, item.QuoteSnapshot); err != nil { + return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error()) + } + acc.intentRefs = append(acc.intentRefs, intentRef) + } + + out = &Output{ + Groups: make([]Group, 0, len(order)), + } + for _, key := range order { + acc := groups[key] + if acc == nil || acc.quote == nil { + continue + } + finalIntent, err := cloneIntentSnapshot(acc.intent) + if err != nil { + return nil, err + } + finalQuote, err := cloneQuoteSnapshot(acc.quote) + if err != nil { + return nil, err + } + if len(acc.intentRefs) > 1 { + if finalIntent.Attributes == nil { + finalIntent.Attributes = map[string]string{} + } + finalIntent.Attributes[attrAggregatedByRecipient] = "true" + finalIntent.Attributes[attrAggregatedItems] = strconv.Itoa(len(acc.intentRefs)) + } + + out.Groups = append(out.Groups, Group{ + RecipientKey: acc.recipientKey, + IntentRefs: cloneStringSlice(acc.intentRefs), + IntentSnapshot: finalIntent, + QuoteSnapshot: finalQuote, + }) + } + + if len(out.Groups) == 0 { + return nil, merrors.InvalidArgument("aggregation produced no groups") + } + return out, nil +} + +func validateItem(item Item) error { + if isEmptyIntentSnapshot(item.IntentSnapshot) { + return merrors.InvalidArgument("intent_snapshot is required") + } + if item.QuoteSnapshot == nil { + return merrors.InvalidArgument("quote_snapshot is required") + } + if item.IntentSnapshot.Amount == nil { + return merrors.InvalidArgument("intent_snapshot.amount is required") + } + if strings.TrimSpace(item.IntentSnapshot.Amount.Currency) == "" { + return merrors.InvalidArgument("intent_snapshot.amount.currency is required") + } + return nil +} + +func groupingKey(item Item) (string, string, error) { + sourceKey, err := endpointKey(item.IntentSnapshot.Source) + if err != nil { + return "", "", merrors.InvalidArgument("intent_snapshot.source: " + err.Error()) + } + recipientKey, err := endpointKey(item.IntentSnapshot.Destination) + if err != nil { + return "", "", merrors.InvalidArgument("intent_snapshot.destination: " + err.Error()) + } + + quote := item.QuoteSnapshot + key := strings.Join([]string{ + "kind=" + strings.ToLower(strings.TrimSpace(string(item.IntentSnapshot.Kind))), + "source=" + sourceKey, + "recipient=" + recipientKey, + "settlement_mode=" + strings.ToLower(strings.TrimSpace(string(item.IntentSnapshot.SettlementMode))), + "settlement_currency=" + normalizeCurrency(item.IntentSnapshot.SettlementCurrency), + "debit_currency=" + moneyCurrency(quote.DebitAmount), + "settlement_amount_currency=" + moneyCurrency(quote.ExpectedSettlementAmount), + "route=" + routeSignature(quote.Route), + "fx=" + fxQuoteSignature(quote.FXQuote), + }, keySep) + + return key, recipientKey, nil +} + +func isBatchingEligible(quote *model.PaymentQuoteSnapshot) bool { + if quote == nil || quote.ExecutionConditions == nil { + return true + } + return quote.ExecutionConditions.BatchingEligible +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_rules.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_rules.go new file mode 100644 index 00000000..651bafd2 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_rules.go @@ -0,0 +1,30 @@ +package ostate + +import "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + +var aggregateTransitions = map[agg.State]map[agg.State]struct{}{ + agg.StateUnspecified: { + agg.StateCreated: {}, + }, + agg.StateCreated: { + agg.StateExecuting: {}, + agg.StateFailed: {}, + }, + agg.StateExecuting: { + agg.StateNeedsAttention: {}, + agg.StateSettled: {}, + agg.StateFailed: {}, + }, + agg.StateNeedsAttention: { + agg.StateExecuting: {}, + agg.StateSettled: {}, + agg.StateFailed: {}, + }, + agg.StateSettled: {}, + agg.StateFailed: {}, +} + +var aggregateTerminalStates = map[agg.State]struct{}{ + agg.StateSettled: {}, + agg.StateFailed: {}, +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_test.go new file mode 100644 index 00000000..55a8123c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_test.go @@ -0,0 +1,129 @@ +package ostate + +import ( + "errors" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" +) + +func TestAggregateTransitionMatrix(t *testing.T) { + sm := New() + + tests := []struct { + name string + from agg.State + to agg.State + wantOK bool + wantErr error + }{ + { + name: "unspecified to created", + from: agg.StateUnspecified, + to: agg.StateCreated, + wantOK: true, + }, + { + name: "created to executing", + from: agg.StateCreated, + to: agg.StateExecuting, + wantOK: true, + }, + { + name: "executing to needs attention", + from: agg.StateExecuting, + to: agg.StateNeedsAttention, + wantOK: true, + }, + { + name: "executing to settled", + from: agg.StateExecuting, + to: agg.StateSettled, + wantOK: true, + }, + { + name: "needs attention back to executing", + from: agg.StateNeedsAttention, + to: agg.StateExecuting, + wantOK: true, + }, + { + name: "idempotent self transition", + from: agg.StateFailed, + to: agg.StateFailed, + wantOK: true, + }, + { + name: "created to settled denied", + from: agg.StateCreated, + to: agg.StateSettled, + wantOK: false, + wantErr: ErrAggregateTransitionNotAllowed, + }, + { + name: "settled to executing denied", + from: agg.StateSettled, + to: agg.StateExecuting, + wantOK: false, + wantErr: ErrAggregateTransitionNotAllowed, + }, + { + name: "unknown from state", + from: agg.State("paused"), + to: agg.StateExecuting, + wantOK: false, + wantErr: ErrUnknownAggregateState, + }, + { + name: "unknown to state", + from: agg.StateExecuting, + to: agg.State("paused"), + wantOK: false, + wantErr: ErrUnknownAggregateState, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sm.CanTransitionAggregate(tt.from, tt.to); got != tt.wantOK { + t.Fatalf("CanTransitionAggregate mismatch: got=%v want=%v", got, tt.wantOK) + } + + err := sm.EnsureAggregateTransition(tt.from, tt.to) + if tt.wantOK { + if err != nil { + t.Fatalf("EnsureAggregateTransition returned error: %v", err) + } + return + } + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("expected error %v, got %v", tt.wantErr, err) + } + }) + } +} + +func TestAggregateTerminalStates(t *testing.T) { + sm := New() + + tests := []struct { + state agg.State + expect bool + }{ + {state: agg.StateUnspecified, expect: false}, + {state: agg.StateCreated, expect: false}, + {state: agg.StateExecuting, expect: false}, + {state: agg.StateNeedsAttention, expect: false}, + {state: agg.StateSettled, expect: true}, + {state: agg.StateFailed, expect: true}, + } + + for _, tt := range tests { + if got := sm.IsAggregateTerminal(tt.state); got != tt.expect { + t.Fatalf("IsAggregateTerminal(%q) mismatch: got=%v want=%v", tt.state, got, tt.expect) + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/errors.go new file mode 100644 index 00000000..9b26eee8 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/errors.go @@ -0,0 +1,10 @@ +package ostate + +import "errors" + +var ( + ErrUnknownAggregateState = errors.New("unknown aggregate state") + ErrAggregateTransitionNotAllowed = errors.New("aggregate transition not allowed") + ErrUnknownStepState = errors.New("unknown step state") + ErrStepTransitionNotAllowed = errors.New("step transition not allowed") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go new file mode 100644 index 00000000..07fb7d9f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go @@ -0,0 +1,30 @@ +package ostate + +import ( + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/pkg/mlogger" +) + +// StateMachine is the single source of truth for orchestration-v2 state transitions. +type StateMachine interface { + CanTransitionAggregate(from, to agg.State) bool + CanTransitionStep(from, to agg.StepState) bool + EnsureAggregateTransition(from, to agg.State) error + EnsureStepTransition(from, to agg.StepState) error + IsAggregateTerminal(state agg.State) bool + IsStepTerminal(state agg.StepState) bool +} + +// Dependencies configures state-machine integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) StateMachine { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return &svc{logger: dep.Logger.Named("ostate")} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/service.go new file mode 100644 index 00000000..51e3d339 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/service.go @@ -0,0 +1,115 @@ +package ostate + +import ( + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/mlogger" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger +} + +func (s *svc) CanTransitionAggregate(from, to agg.State) bool { + return canTransitionAggregate(from, to) +} + +func (s *svc) CanTransitionStep(from, to agg.StepState) bool { + return canTransitionStep(from, to) +} + +func (s *svc) EnsureAggregateTransition(from, to agg.State) error { + logger := s.logger + logger.Debug("Starting Ensure aggregate transition", + zap.String("from", string(from)), + zap.String("to", string(to)), + ) + if !isKnownAggregateState(from) { + err := xerr.Wrapf(ErrUnknownAggregateState, "%q", from) + logger.Warn("Failed to ensure aggregate transition", zap.Error(err)) + return err + } + if !isKnownAggregateState(to) { + err := xerr.Wrapf(ErrUnknownAggregateState, "%q", to) + logger.Warn("Failed to ensure aggregate transition", zap.Error(err)) + return err + } + if canTransitionAggregate(from, to) { + logger.Debug("Completed Ensure aggregate transition", zap.Bool("allowed", true)) + return nil + } + err := xerr.Wrapf(ErrAggregateTransitionNotAllowed, "%s -> %s", from, to) + logger.Warn("Failed to ensure aggregate transition", zap.Error(err)) + return err +} + +func (s *svc) EnsureStepTransition(from, to agg.StepState) error { + logger := s.logger + logger.Debug("Starting Ensure step transition", + zap.String("from", string(from)), + zap.String("to", string(to)), + ) + if !isKnownStepState(from) { + err := xerr.Wrapf(ErrUnknownStepState, "%q", from) + logger.Warn("Failed to ensure step transition", zap.Error(err)) + return err + } + if !isKnownStepState(to) { + err := xerr.Wrapf(ErrUnknownStepState, "%q", to) + logger.Warn("Failed to ensure step transition", zap.Error(err)) + return err + } + if canTransitionStep(from, to) { + logger.Debug("Completed Ensure step transition", zap.Bool("allowed", true)) + return nil + } + err := xerr.Wrapf(ErrStepTransitionNotAllowed, "%s -> %s", from, to) + logger.Warn("Failed to ensure step transition", zap.Error(err)) + return err +} + +func (s *svc) IsAggregateTerminal(state agg.State) bool { + _, ok := aggregateTerminalStates[state] + return ok +} + +func (s *svc) IsStepTerminal(state agg.StepState) bool { + _, ok := stepTerminalStates[state] + return ok +} + +func canTransitionAggregate(from, to agg.State) bool { + if from == to { + return isKnownAggregateState(from) + } + allowed, ok := aggregateTransitions[from] + if !ok { + return false + } + _, ok = allowed[to] + return ok +} + +func canTransitionStep(from, to agg.StepState) bool { + if from == to { + return isKnownStepState(from) + } + allowed, ok := stepTransitions[from] + if !ok { + return false + } + _, ok = allowed[to] + return ok +} + +func isKnownAggregateState(state agg.State) bool { + _, ok := aggregateTransitions[state] + return ok +} + +func isKnownStepState(state agg.StepState) bool { + _, ok := stepTransitions[state] + return ok +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_rules.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_rules.go new file mode 100644 index 00000000..cdf5edef --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_rules.go @@ -0,0 +1,37 @@ +package ostate + +import "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + +var stepTransitions = map[agg.StepState]map[agg.StepState]struct{}{ + agg.StepStateUnspecified: { + agg.StepStatePending: {}, + }, + agg.StepStatePending: { + agg.StepStateRunning: {}, + agg.StepStateFailed: {}, + agg.StepStateNeedsAttention: {}, + agg.StepStateSkipped: {}, + }, + agg.StepStateRunning: { + agg.StepStateCompleted: {}, + agg.StepStateFailed: {}, + agg.StepStateNeedsAttention: {}, + }, + agg.StepStateCompleted: {}, + agg.StepStateFailed: { + agg.StepStateRunning: {}, + agg.StepStateNeedsAttention: {}, + }, + agg.StepStateNeedsAttention: { + agg.StepStateRunning: {}, + agg.StepStateFailed: {}, + agg.StepStateCompleted: {}, + agg.StepStateSkipped: {}, + }, + agg.StepStateSkipped: {}, +} + +var stepTerminalStates = map[agg.StepState]struct{}{ + agg.StepStateCompleted: {}, + agg.StepStateSkipped: {}, +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_test.go new file mode 100644 index 00000000..13979583 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_test.go @@ -0,0 +1,130 @@ +package ostate + +import ( + "errors" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" +) + +func TestStepTransitionMatrix(t *testing.T) { + sm := New() + + tests := []struct { + name string + from agg.StepState + to agg.StepState + wantOK bool + wantErr error + }{ + { + name: "unspecified to pending", + from: agg.StepStateUnspecified, + to: agg.StepStatePending, + wantOK: true, + }, + { + name: "pending to running", + from: agg.StepStatePending, + to: agg.StepStateRunning, + wantOK: true, + }, + { + name: "running to completed", + from: agg.StepStateRunning, + to: agg.StepStateCompleted, + wantOK: true, + }, + { + name: "failed to running retry", + from: agg.StepStateFailed, + to: agg.StepStateRunning, + wantOK: true, + }, + { + name: "needs attention to completed", + from: agg.StepStateNeedsAttention, + to: agg.StepStateCompleted, + wantOK: true, + }, + { + name: "idempotent self transition", + from: agg.StepStateSkipped, + to: agg.StepStateSkipped, + wantOK: true, + }, + { + name: "pending to completed denied", + from: agg.StepStatePending, + to: agg.StepStateCompleted, + wantOK: false, + wantErr: ErrStepTransitionNotAllowed, + }, + { + name: "completed to running denied", + from: agg.StepStateCompleted, + to: agg.StepStateRunning, + wantOK: false, + wantErr: ErrStepTransitionNotAllowed, + }, + { + name: "unknown from state", + from: agg.StepState("waiting"), + to: agg.StepStateRunning, + wantOK: false, + wantErr: ErrUnknownStepState, + }, + { + name: "unknown to state", + from: agg.StepStateRunning, + to: agg.StepState("waiting"), + wantOK: false, + wantErr: ErrUnknownStepState, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sm.CanTransitionStep(tt.from, tt.to); got != tt.wantOK { + t.Fatalf("CanTransitionStep mismatch: got=%v want=%v", got, tt.wantOK) + } + + err := sm.EnsureStepTransition(tt.from, tt.to) + if tt.wantOK { + if err != nil { + t.Fatalf("EnsureStepTransition returned error: %v", err) + } + return + } + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("expected error %v, got %v", tt.wantErr, err) + } + }) + } +} + +func TestStepTerminalStates(t *testing.T) { + sm := New() + + tests := []struct { + state agg.StepState + expect bool + }{ + {state: agg.StepStateUnspecified, expect: false}, + {state: agg.StepStatePending, expect: false}, + {state: agg.StepStateRunning, expect: false}, + {state: agg.StepStateNeedsAttention, expect: false}, + {state: agg.StepStateFailed, expect: false}, + {state: agg.StepStateCompleted, expect: true}, + {state: agg.StepStateSkipped, expect: true}, + } + + for _, tt := range tests { + if got := sm.IsStepTerminal(tt.state); got != tt.expect { + t.Fatalf("IsStepTerminal(%q) mismatch: got=%v want=%v", tt.state, got, tt.expect) + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/module.go new file mode 100644 index 00000000..ca3fa78c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/module.go @@ -0,0 +1,50 @@ +package pquery + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Service provides read models for orchestration-v2 payments. +type Service interface { + GetPayment(ctx context.Context, in GetPaymentInput) (*agg.Payment, error) + ListPayments(ctx context.Context, in ListPaymentsInput) (*ListPaymentsOutput, error) +} + +// GetPaymentInput scopes one payment lookup. +type GetPaymentInput struct { + OrganizationRef bson.ObjectID + PaymentRef string +} + +// ListPaymentsInput scopes cursor-based listing. +type ListPaymentsInput struct { + OrganizationRef bson.ObjectID + States []agg.State + QuotationRef string + CreatedFrom *time.Time + CreatedTo *time.Time + Cursor *prepo.ListCursor + Limit int32 +} + +// ListPaymentsOutput is one list page. +type ListPaymentsOutput struct { + Items []*agg.Payment + NextCursor *prepo.ListCursor +} + +// Dependencies defines query service dependencies. +type Dependencies struct { + Repository prepo.Repository + Logger mlogger.Logger +} + +func New(deps Dependencies) (Service, error) { + return newService(deps) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go new file mode 100644 index 00000000..246e418d --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go @@ -0,0 +1,425 @@ +package pquery + +import ( + "bytes" + "context" + "sort" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +const ( + defaultLimit int32 = 50 + maxLimit int32 = 200 +) + +var allStates = []agg.State{ + agg.StateCreated, + agg.StateExecuting, + agg.StateNeedsAttention, + agg.StateSettled, + agg.StateFailed, +} + +type svc struct { + logger mlogger.Logger + repo prepo.Repository +} + +type normalizedInput struct { + organizationRef bson.ObjectID + quotationRef string + states []agg.State + createdFrom *time.Time + createdTo *time.Time + cursor *prepo.ListCursor + limit int32 +} + +func newService(deps Dependencies) (Service, error) { + if deps.Repository == nil { + return nil, merrors.InvalidArgument("payment repository v2 is required") + } + return &svc{ + logger: deps.Logger.Named("pquery"), + repo: deps.Repository, + }, nil +} + +func (s *svc) GetPayment(ctx context.Context, in GetPaymentInput) (payment *agg.Payment, err error) { + logger := s.logger + logger.Debug("Starting Get payment", + zap.String("organization_ref", in.OrganizationRef.Hex()), + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if payment != nil { + fields = append(fields, + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + if err != nil { + logger.Warn("Failed to get payment", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Get payment", fields...) + }(time.Now()) + + if in.OrganizationRef.IsZero() { + return nil, merrors.InvalidArgument("organization_ref is required") + } + paymentRef := strings.TrimSpace(in.PaymentRef) + if paymentRef == "" { + return nil, merrors.InvalidArgument("payment_ref is required") + } + payment, err = s.repo.GetByPaymentRef(ctx, in.OrganizationRef, paymentRef) + return payment, err +} + +func (s *svc) ListPayments(ctx context.Context, in ListPaymentsInput) (out *ListPaymentsOutput, err error) { + logger := s.logger + logger.Debug("Starting List payments", + zap.String("organization_ref", in.OrganizationRef.Hex()), + zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), + zap.Int("states_count", len(in.States)), + zap.Int32("limit", in.Limit), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, zap.Int("items_count", len(out.Items))) + if out.NextCursor != nil { + fields = append(fields, + zap.String("next_cursor_id", out.NextCursor.ID.Hex()), + zap.Time("next_cursor_created_at", out.NextCursor.CreatedAt), + ) + } + } + if err != nil { + logger.Warn("Failed to list payments", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed List payments", fields...) + }(time.Now()) + + norm, err := normalizeInput(in) + if err != nil { + return nil, err + } + if norm.quotationRef != "" { + out, err = s.listByQuotationRef(ctx, norm) + return out, err + } + out, err = s.listByStates(ctx, norm) + return out, err +} + +func normalizeInput(in ListPaymentsInput) (*normalizedInput, error) { + if in.OrganizationRef.IsZero() { + return nil, merrors.InvalidArgument("organization_ref is required") + } + if in.CreatedFrom != nil && in.CreatedTo != nil { + from := in.CreatedFrom.UTC() + to := in.CreatedTo.UTC() + if !from.Before(to) { + return nil, merrors.InvalidArgument("created_from must be before created_to") + } + } + states, err := normalizeStates(in.States) + if err != nil { + return nil, err + } + var createdFrom *time.Time + if in.CreatedFrom != nil { + from := in.CreatedFrom.UTC() + createdFrom = &from + } + var createdTo *time.Time + if in.CreatedTo != nil { + to := in.CreatedTo.UTC() + createdTo = &to + } + + return &normalizedInput{ + organizationRef: in.OrganizationRef, + quotationRef: strings.TrimSpace(in.QuotationRef), + states: states, + createdFrom: createdFrom, + createdTo: createdTo, + cursor: in.Cursor, + limit: sanitizeLimit(in.Limit), + }, nil +} + +func normalizeStates(src []agg.State) ([]agg.State, error) { + if len(src) == 0 { + return append([]agg.State(nil), allStates...), nil + } + out := make([]agg.State, 0, len(src)) + seen := map[agg.State]struct{}{} + for i := range src { + state, ok := normalizeState(src[i]) + if !ok { + return nil, merrors.InvalidArgument("states contains invalid value") + } + if _, exists := seen[state]; exists { + continue + } + seen[state] = struct{}{} + out = append(out, state) + } + if len(out) == 0 { + return append([]agg.State(nil), allStates...), nil + } + return out, nil +} + +func normalizeState(state agg.State) (agg.State, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case string(agg.StateCreated): + return agg.StateCreated, true + case string(agg.StateExecuting): + return agg.StateExecuting, true + case string(agg.StateNeedsAttention): + return agg.StateNeedsAttention, true + case string(agg.StateSettled): + return agg.StateSettled, true + case string(agg.StateFailed): + return agg.StateFailed, true + default: + return agg.StateUnspecified, false + } +} + +func sanitizeLimit(limit int32) int32 { + if limit <= 0 { + return defaultLimit + } + if limit > maxLimit { + return maxLimit + } + return limit +} + +func (s *svc) listByQuotationRef(ctx context.Context, in *normalizedInput) (*ListPaymentsOutput, error) { + cursor := in.cursor + out := make([]*agg.Payment, 0, in.limit) + + for len(out) < int(in.limit) { + page, err := s.repo.ListByQuotationRef(ctx, prepo.ListByQuotationRefInput{ + OrganizationRef: in.organizationRef, + QuotationRef: in.quotationRef, + Limit: in.limit, + Cursor: cursor, + }) + if err != nil { + return nil, err + } + if page == nil || len(page.Items) == 0 { + break + } + + for i := range page.Items { + item := page.Items[i] + if !matchesFilters(item, in) { + continue + } + out = append(out, item) + if len(out) == int(in.limit) { + break + } + } + if len(out) == int(in.limit) || page.NextCursor == nil { + break + } + cursor = page.NextCursor + } + + return buildOutput(out, in.limit), nil +} + +func (s *svc) listByStates(ctx context.Context, in *normalizedInput) (*ListPaymentsOutput, error) { + cursor := in.cursor + out := make([]*agg.Payment, 0, in.limit) + + for len(out) < int(in.limit) { + merged, next, err := s.fetchStatesPage(ctx, in, cursor) + if err != nil { + return nil, err + } + if len(merged) == 0 { + break + } + + for i := range merged { + if !matchesFilters(merged[i], in) { + continue + } + out = append(out, merged[i]) + if len(out) == int(in.limit) { + break + } + } + if len(out) == int(in.limit) || next == nil { + break + } + cursor = next + } + + return buildOutput(out, in.limit), nil +} + +func (s *svc) fetchStatesPage( + ctx context.Context, + in *normalizedInput, + cursor *prepo.ListCursor, +) ([]*agg.Payment, *prepo.ListCursor, error) { + batch := in.limit + if batch < 20 { + batch = 20 + } + + merged := make([]*agg.Payment, 0, len(in.states)*int(batch)) + var next *prepo.ListCursor + for i := range in.states { + page, err := s.repo.ListByState(ctx, prepo.ListByStateInput{ + OrganizationRef: in.organizationRef, + State: in.states[i], + Limit: batch, + Cursor: cursor, + }) + if err != nil { + return nil, nil, err + } + if page == nil || len(page.Items) == 0 { + continue + } + merged = append(merged, page.Items...) + if next == nil || cursorLess(page.NextCursor, next) { + next = page.NextCursor + } + } + if len(merged) == 0 { + return nil, nil, nil + } + + sortPaymentsDesc(merged) + merged = dedupeByPaymentRef(merged) + if len(merged) == 0 { + return nil, next, nil + } + oldest := cursorFromPayment(merged[len(merged)-1]) + if cursorLess(oldest, next) { + next = oldest + } + return merged, next, nil +} + +func cursorFromPayment(payment *agg.Payment) *prepo.ListCursor { + if payment == nil || payment.ID.IsZero() || payment.CreatedAt.IsZero() { + return nil + } + return &prepo.ListCursor{ + CreatedAt: payment.CreatedAt.UTC(), + ID: payment.ID, + } +} + +func cursorLess(left *prepo.ListCursor, right *prepo.ListCursor) bool { + if left == nil { + return false + } + if right == nil { + return true + } + if left.CreatedAt.Before(right.CreatedAt) { + return true + } + if left.CreatedAt.After(right.CreatedAt) { + return false + } + return bytes.Compare(left.ID[:], right.ID[:]) < 0 +} + +func buildOutput(items []*agg.Payment, limit int32) *ListPaymentsOutput { + if len(items) == 0 { + return &ListPaymentsOutput{} + } + if int32(len(items)) > limit { + items = items[:limit] + } + var nextCursor *prepo.ListCursor + if int32(len(items)) == limit { + nextCursor = cursorFromPayment(items[len(items)-1]) + } + return &ListPaymentsOutput{ + Items: items, + NextCursor: nextCursor, + } +} + +func matchesFilters(payment *agg.Payment, in *normalizedInput) bool { + if payment == nil { + return false + } + if in.quotationRef != "" && !strings.EqualFold(strings.TrimSpace(payment.QuotationRef), in.quotationRef) { + return false + } + if in.createdFrom != nil && payment.CreatedAt.Before(*in.createdFrom) { + return false + } + if in.createdTo != nil && !payment.CreatedAt.Before(*in.createdTo) { + return false + } + return containsState(in.states, payment.State) +} + +func containsState(states []agg.State, state agg.State) bool { + for i := range states { + if states[i] == state { + return true + } + } + return false +} + +func sortPaymentsDesc(items []*agg.Payment) { + sort.Slice(items, func(i, j int) bool { + left := items[i] + right := items[j] + if !left.CreatedAt.Equal(right.CreatedAt) { + return left.CreatedAt.After(right.CreatedAt) + } + return bytes.Compare(left.ID[:], right.ID[:]) > 0 + }) +} + +func dedupeByPaymentRef(items []*agg.Payment) []*agg.Payment { + if len(items) == 0 { + return nil + } + out := make([]*agg.Payment, 0, len(items)) + seen := map[string]struct{}{} + for i := range items { + ref := strings.TrimSpace(items[i].PaymentRef) + if ref == "" { + continue + } + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + out = append(out, items[i]) + } + return out +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go new file mode 100644 index 00000000..d669758b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go @@ -0,0 +1,135 @@ +package prepo + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/db/storable" + pm "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +const paymentsV2Collection = "payments_v2" + +type paymentDocument struct { + storable.Base `bson:",inline"` + pm.OrganizationBoundBase `bson:",inline"` + PaymentRef string `bson:"paymentRef"` + IdempotencyKey string `bson:"idempotencyKey"` + QuotationRef string `bson:"quotationRef"` + ClientPaymentRef string `bson:"clientPaymentRef,omitempty"` + IntentSnapshot model.PaymentIntent `bson:"intentSnapshot"` + QuoteSnapshot *model.PaymentQuoteSnapshot `bson:"quoteSnapshot"` + State agg.State `bson:"state"` + Version uint64 `bson:"version"` + StepExecutions []agg.StepExecution `bson:"stepExecutions,omitempty"` +} + +func (*paymentDocument) Collection() string { + return paymentsV2Collection +} + +func toDocument(payment *agg.Payment) (*paymentDocument, error) { + if payment == nil { + return nil, nil + } + doc := &paymentDocument{ + Base: payment.Base, + OrganizationBoundBase: payment.OrganizationBoundBase, + PaymentRef: strings.TrimSpace(payment.PaymentRef), + IdempotencyKey: strings.TrimSpace(payment.IdempotencyKey), + QuotationRef: strings.TrimSpace(payment.QuotationRef), + ClientPaymentRef: strings.TrimSpace(payment.ClientPaymentRef), + IntentSnapshot: payment.IntentSnapshot, + QuoteSnapshot: payment.QuoteSnapshot, + State: payment.State, + Version: payment.Version, + StepExecutions: cloneStepExecutions(payment.StepExecutions), + } + return cloneDocument(doc) +} + +func fromDocument(doc *paymentDocument) (*agg.Payment, error) { + if doc == nil { + return nil, nil + } + cloned, err := cloneDocument(doc) + if err != nil { + return nil, err + } + return &agg.Payment{ + Base: cloned.Base, + OrganizationBoundBase: cloned.OrganizationBoundBase, + PaymentRef: cloned.PaymentRef, + IdempotencyKey: cloned.IdempotencyKey, + QuotationRef: cloned.QuotationRef, + ClientPaymentRef: cloned.ClientPaymentRef, + IntentSnapshot: cloned.IntentSnapshot, + QuoteSnapshot: cloned.QuoteSnapshot, + State: cloned.State, + Version: cloned.Version, + StepExecutions: cloneStepExecutions(cloned.StepExecutions), + }, nil +} + +func cloneDocument(doc *paymentDocument) (*paymentDocument, error) { + if doc == nil { + return nil, nil + } + data, err := bson.Marshal(doc) + if err != nil { + return nil, err + } + out := &paymentDocument{} + if err := bson.Unmarshal(data, out); err != nil { + return nil, err + } + return out, nil +} + +func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution { + if len(src) == 0 { + return nil + } + out := make([]agg.StepExecution, 0, len(src)) + for i := range src { + step := src[i] + step.StepRef = strings.TrimSpace(step.StepRef) + step.StepCode = strings.TrimSpace(step.StepCode) + step.FailureCode = strings.TrimSpace(step.FailureCode) + step.FailureMsg = strings.TrimSpace(step.FailureMsg) + if step.Attempt == 0 { + step.Attempt = 1 + } + step.ExternalRefs = cloneExternalRefs(step.ExternalRefs) + step.StartedAt = cloneTime(step.StartedAt) + step.CompletedAt = cloneTime(step.CompletedAt) + out = append(out, step) + } + return out +} + +func cloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef { + if len(refs) == 0 { + return nil + } + out := make([]agg.ExternalRef, 0, len(refs)) + for i := range refs { + ref := refs[i] + ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID) + ref.Kind = strings.TrimSpace(ref.Kind) + ref.Ref = strings.TrimSpace(ref.Ref) + out = append(out, ref) + } + return out +} + +func cloneTime(ts *time.Time) *time.Time { + if ts == nil { + return nil + } + val := ts.UTC() + return &val +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/errors.go new file mode 100644 index 00000000..0f55b63b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/errors.go @@ -0,0 +1,9 @@ +package prepo + +import "errors" + +var ( + ErrPaymentNotFound = errors.New("payment repository v2: payment not found") + ErrDuplicatePayment = errors.New("payment repository v2: duplicate payment") + ErrVersionConflict = errors.New("payment repository v2: version conflict") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go new file mode 100644 index 00000000..ad9a4089 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go @@ -0,0 +1,40 @@ +package prepo + +import ( + ri "github.com/tech/sendico/pkg/db/repository/index" +) + +type indexDefinition = ri.Definition + +func requiredIndexes() []*indexDefinition { + return []*indexDefinition{ + { + Keys: []ri.Key{ + {Field: "organizationRef", Sort: ri.Asc}, + {Field: "paymentRef", Sort: ri.Asc}, + }, + Unique: true, + }, + { + Keys: []ri.Key{ + {Field: "organizationRef", Sort: ri.Asc}, + {Field: "idempotencyKey", Sort: ri.Asc}, + }, + Unique: true, + }, + { + Keys: []ri.Key{ + {Field: "organizationRef", Sort: ri.Asc}, + {Field: "quotationRef", Sort: ri.Asc}, + {Field: "createdAt", Sort: ri.Desc}, + }, + }, + { + Keys: []ri.Key{ + {Field: "organizationRef", Sort: ri.Asc}, + {Field: "state", Sort: ri.Asc}, + {Field: "createdAt", Sort: ri.Desc}, + }, + }, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go new file mode 100644 index 00000000..ad2d22b5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go @@ -0,0 +1,63 @@ +package prepo + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// Repository persists orchestration-v2 payment aggregates. +type Repository interface { + Create(ctx context.Context, payment *agg.Payment) error + UpdateCAS(ctx context.Context, payment *agg.Payment, expectedVersion uint64) error + GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*agg.Payment, error) + GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*agg.Payment, error) + ListByQuotationRef(ctx context.Context, in ListByQuotationRefInput) (*ListOutput, error) + ListByState(ctx context.Context, in ListByStateInput) (*ListOutput, error) +} + +// ListCursor is a stable pagination cursor sorted by created_at desc then id desc. +type ListCursor struct { + CreatedAt time.Time + ID bson.ObjectID +} + +// ListOutput is a page of payment aggregates. +type ListOutput struct { + Items []*agg.Payment + NextCursor *ListCursor +} + +// ListByQuotationRefInput defines listing scope by quotation_ref. +type ListByQuotationRefInput struct { + OrganizationRef bson.ObjectID + QuotationRef string + Limit int32 + Cursor *ListCursor +} + +// ListByStateInput defines listing scope by aggregate state. +type ListByStateInput struct { + OrganizationRef bson.ObjectID + State agg.State + Limit int32 + Cursor *ListCursor +} + +// Dependencies configures repository integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +// NewMongo constructs a Mongo-backed payment repository-v2. +func NewMongo(collection *mongo.Collection, deps ...Dependencies) (Repository, error) { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return newWithStoreLogger(newMongoStore(collection), dep.Logger) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go new file mode 100644 index 00000000..6f8ddefd --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go @@ -0,0 +1,212 @@ +package prepo + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type mongoStore struct { + collection *mongo.Collection +} + +func newMongoStore(collection *mongo.Collection) paymentStore { + return &mongoStore{ + collection: collection, + } +} + +func (s *mongoStore) EnsureIndexes(defs []*indexDefinition) error { + if s.collection == nil { + return merrors.InvalidArgument("payment repository v2: mongo collection is required") + } + if len(defs) == 0 { + return nil + } + models := make([]mongo.IndexModel, 0, len(defs)) + for i := range defs { + def := defs[i] + if def == nil || len(def.Keys) == 0 { + continue + } + keys := bson.D{} + for j := range def.Keys { + key := def.Keys[j] + name := strings.TrimSpace(key.Field) + if name == "" { + continue + } + switch key.Type { + case "": + keys = append(keys, bson.E{Key: name, Value: int32(key.Sort)}) + default: + keys = append(keys, bson.E{Key: name, Value: string(key.Type)}) + } + } + if len(keys) == 0 { + continue + } + opt := options.Index() + if def.Name != "" { + opt.SetName(def.Name) + } + if def.Unique { + opt.SetUnique(true) + } + if def.Sparse { + opt.SetSparse(true) + } + if def.TTL != nil { + opt.SetExpireAfterSeconds(int32(*def.TTL)) + } + if def.PartialFilter != nil { + opt.SetPartialFilterExpression(def.PartialFilter.BuildQuery()) + } + models = append(models, mongo.IndexModel{ + Keys: keys, + Options: opt, + }) + } + if len(models) == 0 { + return nil + } + _, err := s.collection.Indexes().CreateMany(context.Background(), models) + return err +} + +func (s *mongoStore) Create(ctx context.Context, doc *paymentDocument) error { + if s.collection == nil { + return merrors.InvalidArgument("payment repository v2: mongo collection is required") + } + if doc == nil { + return merrors.InvalidArgument("payment repository v2: payment document is required") + } + _, err := s.collection.InsertOne(ctx, doc) + if mongo.IsDuplicateKeyError(err) { + return ErrDuplicatePayment + } + return err +} + +func (s *mongoStore) UpdateCAS(ctx context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) { + if s.collection == nil { + return false, merrors.InvalidArgument("payment repository v2: mongo collection is required") + } + if doc == nil { + return false, merrors.InvalidArgument("payment repository v2: payment document is required") + } + filter := bson.D{ + {Key: "_id", Value: doc.ID}, + {Key: "organizationRef", Value: doc.OrganizationRef}, + {Key: "version", Value: expectedVersion}, + } + result, err := s.collection.ReplaceOne(ctx, filter, doc) + if mongo.IsDuplicateKeyError(err) { + return false, ErrDuplicatePayment + } + if err != nil { + return false, err + } + return result.MatchedCount > 0, nil +} + +func (s *mongoStore) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) { + return s.findOne(ctx, bson.D{ + {Key: "organizationRef", Value: orgRef}, + {Key: "paymentRef", Value: paymentRef}, + }) +} + +func (s *mongoStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) { + return s.findOne(ctx, bson.D{ + {Key: "organizationRef", Value: orgRef}, + {Key: "idempotencyKey", Value: idempotencyKey}, + }) +} + +func (s *mongoStore) GetByID(ctx context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) { + return s.findOne(ctx, bson.D{ + {Key: "_id", Value: id}, + {Key: "organizationRef", Value: orgRef}, + }) +} + +func (s *mongoStore) ListByQuotationRef(ctx context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) { + filter := bson.D{ + {Key: "organizationRef", Value: orgRef}, + {Key: "quotationRef", Value: quotationRef}, + } + return s.list(ctx, filter, cursor, limit) +} + +func (s *mongoStore) ListByState(ctx context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) { + filter := bson.D{ + {Key: "organizationRef", Value: orgRef}, + {Key: "state", Value: state}, + } + return s.list(ctx, filter, cursor, limit) +} + +func (s *mongoStore) findOne(ctx context.Context, filter bson.D) (*paymentDocument, error) { + if s.collection == nil { + return nil, merrors.InvalidArgument("payment repository v2: mongo collection is required") + } + doc := &paymentDocument{} + err := s.collection.FindOne(ctx, filter).Decode(doc) + if err == nil { + return doc, nil + } + if err == mongo.ErrNoDocuments { + return nil, ErrPaymentNotFound + } + return nil, err +} + +func (s *mongoStore) list(ctx context.Context, filter bson.D, cursor *listCursor, limit int64) ([]*paymentDocument, error) { + if s.collection == nil { + return nil, merrors.InvalidArgument("payment repository v2: mongo collection is required") + } + if cursor != nil { + filter = append(filter, bson.E{ + Key: "$or", + Value: bson.A{ + bson.D{{Key: "createdAt", Value: bson.D{{Key: "$lt", Value: cursor.CreatedAt}}}}, + bson.D{ + {Key: "createdAt", Value: cursor.CreatedAt}, + {Key: "_id", Value: bson.D{{Key: "$lt", Value: cursor.ID}}}, + }, + }, + }) + } + opt := options.Find(). + SetSort(bson.D{ + {Key: "createdAt", Value: -1}, + {Key: "_id", Value: -1}, + }). + SetLimit(limit) + cur, err := s.collection.Find(ctx, filter, opt) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + items := make([]*paymentDocument, 0) + for cur.Next(ctx) { + doc := &paymentDocument{} + if err := cur.Decode(doc); err != nil { + return nil, err + } + items = append(items, doc) + } + if err := cur.Err(); err != nil { + return nil, err + } + return items, nil +} + +var _ paymentStore = (*mongoStore)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go new file mode 100644 index 00000000..2711368a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go @@ -0,0 +1,548 @@ +package prepo + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +const ( + defaultListLimit int64 = 50 + maxListLimit int64 = 200 +) + +type listCursor struct { + CreatedAt time.Time + ID bson.ObjectID +} + +type paymentStore interface { + EnsureIndexes(defs []*indexDefinition) error + Create(ctx context.Context, doc *paymentDocument) error + UpdateCAS(ctx context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) + GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) + GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) + GetByID(ctx context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) + ListByQuotationRef(ctx context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) + ListByState(ctx context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) +} + +type svc struct { + logger mlogger.Logger + store paymentStore + now func() time.Time +} + +func newWithStore(store paymentStore) (Repository, error) { + return newWithStoreLogger(store, nil) +} + +func newWithStoreLogger(store paymentStore, logger mlogger.Logger) (Repository, error) { + if store == nil { + return nil, merrors.InvalidArgument("payment repository v2: store is required") + } + if err := store.EnsureIndexes(requiredIndexes()); err != nil { + return nil, err + } + return &svc{ + logger: logger.Named("prepo"), + store: store, + now: func() time.Time { + return time.Now().UTC() + }, + }, nil +} + +func (s *svc) Create(ctx context.Context, payment *agg.Payment) (err error) { + logger := s.logger + paymentRef := "" + if payment != nil { + paymentRef = strings.TrimSpace(payment.PaymentRef) + } + logger.Debug("Starting Create", zap.String("payment_ref", paymentRef)) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("payment_ref", paymentRef), + } + if payment != nil { + fields = append(fields, + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + if err != nil { + logger.Warn("Failed to create", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Create", fields...) + }(time.Now()) + + doc, err := prepareCreate(payment, s.now().UTC()) + if err != nil { + return err + } + if err := s.store.Create(ctx, doc); err != nil { + if isDuplicate(err) { + return ErrDuplicatePayment + } + return err + } + out, err := fromDocument(doc) + if err != nil { + return err + } + *payment = *out + paymentRef = strings.TrimSpace(payment.PaymentRef) + return nil +} + +func (s *svc) UpdateCAS(ctx context.Context, payment *agg.Payment, expectedVersion uint64) (err error) { + logger := s.logger + paymentRef := "" + if payment != nil { + paymentRef = strings.TrimSpace(payment.PaymentRef) + } + logger.Debug("Starting Update cas", + zap.String("payment_ref", paymentRef), + zap.Uint64("expected_version", expectedVersion), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("payment_ref", paymentRef), + zap.Uint64("expected_version", expectedVersion), + } + if payment != nil { + fields = append(fields, + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + if err != nil { + logger.Warn("Failed to update cas", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Update cas", fields...) + }(time.Now()) + + doc, err := prepareUpdate(payment, expectedVersion, s.now().UTC()) + if err != nil { + return err + } + + updated, err := s.store.UpdateCAS(ctx, doc, expectedVersion) + if err != nil { + if isDuplicate(err) { + return ErrDuplicatePayment + } + return err + } + if !updated { + if _, findErr := s.store.GetByID(ctx, doc.OrganizationRef, doc.ID); findErr != nil { + if errors.Is(findErr, ErrPaymentNotFound) { + return ErrPaymentNotFound + } + return findErr + } + return ErrVersionConflict + } + + out, err := fromDocument(doc) + if err != nil { + return err + } + *payment = *out + paymentRef = strings.TrimSpace(payment.PaymentRef) + return nil +} + +func (s *svc) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (payment *agg.Payment, err error) { + logger := s.logger + requestPaymentRef := strings.TrimSpace(paymentRef) + logger.Debug("Starting Get by payment ref", + zap.String("organization_ref", orgRef.Hex()), + zap.String("payment_ref", requestPaymentRef), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("organization_ref", orgRef.Hex()), + zap.String("payment_ref", requestPaymentRef), + } + if payment != nil { + fields = append(fields, + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + if err != nil { + logger.Warn("Failed to get by payment ref", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Get by payment ref", fields...) + }(time.Now()) + + if orgRef.IsZero() { + return nil, merrors.InvalidArgument("organization_ref is required") + } + paymentRef = strings.TrimSpace(paymentRef) + if paymentRef == "" { + return nil, merrors.InvalidArgument("payment_ref is required") + } + doc, err := s.store.GetByPaymentRef(ctx, orgRef, paymentRef) + if err != nil { + return nil, err + } + payment, err = fromDocument(doc) + return payment, err +} + +func (s *svc) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (payment *agg.Payment, err error) { + logger := s.logger + hasKey := strings.TrimSpace(idempotencyKey) != "" + logger.Debug("Starting Get by idempotency key", + zap.String("organization_ref", orgRef.Hex()), + zap.Bool("has_idempotency_key", hasKey), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("organization_ref", orgRef.Hex()), + zap.Bool("has_idempotency_key", hasKey), + } + if payment != nil { + fields = append(fields, + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + if err != nil { + logger.Warn("Failed to get by idempotency key", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Get by idempotency key", fields...) + }(time.Now()) + + if orgRef.IsZero() { + return nil, merrors.InvalidArgument("organization_ref is required") + } + idempotencyKey = strings.TrimSpace(idempotencyKey) + if idempotencyKey == "" { + return nil, merrors.InvalidArgument("idempotency_key is required") + } + doc, err := s.store.GetByIdempotencyKey(ctx, orgRef, idempotencyKey) + if err != nil { + return nil, err + } + payment, err = fromDocument(doc) + return payment, err +} + +func (s *svc) ListByQuotationRef(ctx context.Context, in ListByQuotationRefInput) (out *ListOutput, err error) { + logger := s.logger + logger.Debug("Starting List by quotation ref", + zap.String("organization_ref", in.OrganizationRef.Hex()), + zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), + zap.Int32("limit", in.Limit), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, zap.Int("items_count", len(out.Items))) + } + if err != nil { + logger.Warn("Failed to list by quotation ref", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed List by quotation ref", fields...) + }(time.Now()) + + if in.OrganizationRef.IsZero() { + return nil, merrors.InvalidArgument("organization_ref is required") + } + in.QuotationRef = strings.TrimSpace(in.QuotationRef) + if in.QuotationRef == "" { + return nil, merrors.InvalidArgument("quotation_ref is required") + } + cursor, err := normalizeCursor(in.Cursor) + if err != nil { + return nil, err + } + out, err = s.list(ctx, listQuery{ + limit: sanitizeLimit(in.Limit), + run: func(limit int64) ([]*paymentDocument, error) { + return s.store.ListByQuotationRef(ctx, in.OrganizationRef, in.QuotationRef, cursor, limit) + }, + }) + return out, err +} + +func (s *svc) ListByState(ctx context.Context, in ListByStateInput) (out *ListOutput, err error) { + logger := s.logger + logger.Debug("Starting List by state", + zap.String("organization_ref", in.OrganizationRef.Hex()), + zap.String("state", string(in.State)), + zap.Int32("limit", in.Limit), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, zap.Int("items_count", len(out.Items))) + } + if err != nil { + logger.Warn("Failed to list by state", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed List by state", fields...) + }(time.Now()) + + if in.OrganizationRef.IsZero() { + return nil, merrors.InvalidArgument("organization_ref is required") + } + state, ok := normalizeAggregateState(in.State) + if !ok { + return nil, merrors.InvalidArgument("state is invalid") + } + cursor, err := normalizeCursor(in.Cursor) + if err != nil { + return nil, err + } + out, err = s.list(ctx, listQuery{ + limit: sanitizeLimit(in.Limit), + run: func(limit int64) ([]*paymentDocument, error) { + return s.store.ListByState(ctx, in.OrganizationRef, state, cursor, limit) + }, + }) + return out, err +} + +type listQuery struct { + limit int64 + run func(limit int64) ([]*paymentDocument, error) +} + +func (s *svc) list(_ context.Context, query listQuery) (*ListOutput, error) { + fetchLimit := query.limit + 1 + docs, err := query.run(fetchLimit) + if err != nil { + return nil, err + } + if len(docs) == 0 { + return &ListOutput{}, nil + } + + nextCursor := (*ListCursor)(nil) + if int64(len(docs)) == fetchLimit { + docs = docs[:len(docs)-1] + last := docs[len(docs)-1] + nextCursor = &ListCursor{ + CreatedAt: last.CreatedAt.UTC(), + ID: last.ID, + } + } + + items := make([]*agg.Payment, 0, len(docs)) + for i := range docs { + entity, convErr := fromDocument(docs[i]) + if convErr != nil { + return nil, convErr + } + items = append(items, entity) + } + return &ListOutput{ + Items: items, + NextCursor: nextCursor, + }, nil +} + +func prepareCreate(payment *agg.Payment, now time.Time) (*paymentDocument, error) { + doc, err := normalizePayment(payment, false) + if err != nil { + return nil, err + } + if doc.ID.IsZero() { + doc.ID = bson.NewObjectID() + } + if doc.PaymentRef == "" { + doc.PaymentRef = doc.ID.Hex() + } + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if doc.Version == 0 { + doc.Version = 1 + } + return doc, nil +} + +func prepareUpdate(payment *agg.Payment, expectedVersion uint64, now time.Time) (*paymentDocument, error) { + if expectedVersion == 0 { + return nil, merrors.InvalidArgument("expected_version is required") + } + doc, err := normalizePayment(payment, true) + if err != nil { + return nil, err + } + if doc.ID.IsZero() { + return nil, merrors.InvalidArgument("payment id is required") + } + if doc.CreatedAt.IsZero() { + return nil, merrors.InvalidArgument("payment.created_at is required") + } + nextVersion := expectedVersion + 1 + if doc.Version != 0 && doc.Version != expectedVersion && doc.Version != nextVersion { + return nil, merrors.InvalidArgument("payment.version must equal expected_version or expected_version + 1") + } + doc.Version = nextVersion + doc.UpdatedAt = now + return doc, nil +} + +func normalizePayment(payment *agg.Payment, requirePaymentRef bool) (*paymentDocument, error) { + doc, err := toDocument(payment) + if err != nil { + return nil, err + } + if doc == nil { + return nil, merrors.InvalidArgument("payment is required") + } + doc.PaymentRef = strings.TrimSpace(doc.PaymentRef) + doc.IdempotencyKey = strings.TrimSpace(doc.IdempotencyKey) + doc.QuotationRef = strings.TrimSpace(doc.QuotationRef) + doc.ClientPaymentRef = strings.TrimSpace(doc.ClientPaymentRef) + + if doc.OrganizationRef.IsZero() { + return nil, merrors.InvalidArgument("organization_ref is required") + } + if requirePaymentRef && doc.PaymentRef == "" { + return nil, merrors.InvalidArgument("payment_ref is required") + } + if doc.IdempotencyKey == "" { + return nil, merrors.InvalidArgument("idempotency_key is required") + } + if doc.QuotationRef == "" { + return nil, merrors.InvalidArgument("quotation_ref is required") + } + if doc.QuoteSnapshot == nil { + return nil, merrors.InvalidArgument("quote_snapshot is required") + } + state, ok := normalizeAggregateState(doc.State) + if !ok { + return nil, merrors.InvalidArgument("state is invalid") + } + doc.State = state + + for i := range doc.StepExecutions { + step := &doc.StepExecutions[i] + step.StepRef = strings.TrimSpace(step.StepRef) + step.StepCode = strings.TrimSpace(step.StepCode) + step.FailureCode = strings.TrimSpace(step.FailureCode) + step.FailureMsg = strings.TrimSpace(step.FailureMsg) + if step.StepRef == "" { + return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref is required") + } + if step.StepCode == "" { + step.StepCode = step.StepRef + } + if step.Attempt == 0 { + step.Attempt = 1 + } + ss, ok := normalizeStepState(step.State) + if !ok { + return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].state is invalid") + } + step.State = ss + } + return doc, nil +} + +func normalizeAggregateState(state agg.State) (agg.State, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case string(agg.StateCreated): + return agg.StateCreated, true + case string(agg.StateExecuting): + return agg.StateExecuting, true + case string(agg.StateNeedsAttention): + return agg.StateNeedsAttention, true + case string(agg.StateSettled): + return agg.StateSettled, true + case string(agg.StateFailed): + return agg.StateFailed, true + default: + return agg.StateUnspecified, false + } +} + +func normalizeStepState(state agg.StepState) (agg.StepState, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case string(agg.StepStatePending): + return agg.StepStatePending, true + case string(agg.StepStateRunning): + return agg.StepStateRunning, true + case string(agg.StepStateCompleted): + return agg.StepStateCompleted, true + case string(agg.StepStateFailed): + return agg.StepStateFailed, true + case string(agg.StepStateNeedsAttention): + return agg.StepStateNeedsAttention, true + case string(agg.StepStateSkipped): + return agg.StepStateSkipped, true + default: + return agg.StepStateUnspecified, false + } +} + +func normalizeCursor(cursor *ListCursor) (*listCursor, error) { + if cursor == nil { + return nil, nil + } + if cursor.ID.IsZero() { + return nil, merrors.InvalidArgument("cursor.id is required") + } + if cursor.CreatedAt.IsZero() { + return nil, merrors.InvalidArgument("cursor.created_at is required") + } + return &listCursor{ + CreatedAt: cursor.CreatedAt.UTC(), + ID: cursor.ID, + }, nil +} + +func sanitizeLimit(limit int32) int64 { + if limit <= 0 { + return defaultListLimit + } + if limit > int32(maxListLimit) { + return maxListLimit + } + return int64(limit) +} + +func isDuplicate(err error) bool { + return errors.Is(err, ErrDuplicatePayment) +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go new file mode 100644 index 00000000..f048bd59 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go @@ -0,0 +1,479 @@ +package prepo + +import ( + "bytes" + "context" + "errors" + "sort" + "testing" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/merrors" + pm "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) { + store := newFakeStore() + _, err := newWithStore(store) + if err != nil { + t.Fatalf("newWithStore returned error: %v", err) + } + + if len(store.indexes) != 4 { + t.Fatalf("index count mismatch: got=%d want=4", len(store.indexes)) + } + + assertIndex(t, store.indexes[0], []string{"organizationRef", "paymentRef"}, true) + assertIndex(t, store.indexes[1], []string{"organizationRef", "idempotencyKey"}, true) + assertIndex(t, store.indexes[2], []string{"organizationRef", "quotationRef", "createdAt"}, false) + assertIndex(t, store.indexes[3], []string{"organizationRef", "state", "createdAt"}, false) +} + +func TestCreateAndGet(t *testing.T) { + now := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC) + store := newFakeStore() + repo, err := newWithStore(store) + if err != nil { + t.Fatalf("newWithStore returned error: %v", err) + } + repo.(*svc).now = func() time.Time { return now } + + org := bson.NewObjectID() + payment := &agg.Payment{ + OrganizationBoundBase: modelOrg(org), + IdempotencyKey: "idem-1", + QuotationRef: "quote-1", + State: agg.StateCreated, + IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()}, + QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"}, + StepExecutions: []agg.StepExecution{ + {StepRef: "s1", StepCode: "step-1", State: agg.StepStatePending, Attempt: 1}, + }, + } + + if err := repo.Create(context.Background(), payment); err != nil { + t.Fatalf("Create returned error: %v", err) + } + if payment.ID.IsZero() { + t.Fatal("expected generated id") + } + if payment.PaymentRef == "" { + t.Fatal("expected generated payment_ref") + } + if payment.Version != 1 { + t.Fatalf("version mismatch: got=%d want=1", payment.Version) + } + if !payment.CreatedAt.Equal(now) || !payment.UpdatedAt.Equal(now) { + t.Fatalf("timestamps mismatch: created=%v updated=%v", payment.CreatedAt, payment.UpdatedAt) + } + + gotByRef, err := repo.GetByPaymentRef(context.Background(), org, payment.PaymentRef) + if err != nil { + t.Fatalf("GetByPaymentRef returned error: %v", err) + } + if gotByRef.PaymentRef != payment.PaymentRef { + t.Fatalf("payment_ref mismatch: got=%q want=%q", gotByRef.PaymentRef, payment.PaymentRef) + } + + gotByIdem, err := repo.GetByIdempotencyKey(context.Background(), org, payment.IdempotencyKey) + if err != nil { + t.Fatalf("GetByIdempotencyKey returned error: %v", err) + } + if gotByIdem.PaymentRef != payment.PaymentRef { + t.Fatalf("idempotency lookup mismatch: got=%q want=%q", gotByIdem.PaymentRef, payment.PaymentRef) + } +} + +func TestCreate_Duplicate(t *testing.T) { + store := newFakeStore() + repo, err := newWithStore(store) + if err != nil { + t.Fatalf("newWithStore returned error: %v", err) + } + + org := bson.NewObjectID() + first := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateCreated, time.Now()) + second := newPaymentFixture(org, "idem-1", "quote-1", "pay-2", agg.StateCreated, time.Now()) + + if err := repo.Create(context.Background(), first); err != nil { + t.Fatalf("Create(first) returned error: %v", err) + } + err = repo.Create(context.Background(), second) + if !errors.Is(err, ErrDuplicatePayment) { + t.Fatalf("expected ErrDuplicatePayment, got %v", err) + } +} + +func TestUpdateCAS(t *testing.T) { + now := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC) + later := now.Add(2 * time.Minute) + + store := newFakeStore() + repoIface, err := newWithStore(store) + if err != nil { + t.Fatalf("newWithStore returned error: %v", err) + } + repo := repoIface.(*svc) + repo.now = func() time.Time { return now } + + org := bson.NewObjectID() + payment := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, now) + if err := repo.Create(context.Background(), payment); err != nil { + t.Fatalf("Create returned error: %v", err) + } + + repo.now = func() time.Time { return later } + payment.State = agg.StateNeedsAttention + payment.StepExecutions[0].State = agg.StepStateNeedsAttention + if err := repo.UpdateCAS(context.Background(), payment, 1); err != nil { + t.Fatalf("UpdateCAS returned error: %v", err) + } + if payment.Version != 2 { + t.Fatalf("version mismatch: got=%d want=2", payment.Version) + } + if !payment.UpdatedAt.Equal(later) { + t.Fatalf("updated_at mismatch: got=%v want=%v", payment.UpdatedAt, later) + } + + // stale version update + payment.State = agg.StateExecuting + err = repo.UpdateCAS(context.Background(), payment, 1) + if !errors.Is(err, ErrVersionConflict) { + t.Fatalf("expected ErrVersionConflict, got %v", err) + } + + // missing payment update + missing := newPaymentFixture(org, "idem-x", "quote-x", "pay-x", agg.StateExecuting, now) + missing.ID = bson.NewObjectID() + missing.Version = 2 + err = repo.UpdateCAS(context.Background(), missing, 1) + if !errors.Is(err, ErrPaymentNotFound) { + t.Fatalf("expected ErrPaymentNotFound, got %v", err) + } +} + +func TestListByQuotationRefAndState(t *testing.T) { + store := newFakeStore() + repo, err := newWithStore(store) + if err != nil { + t.Fatalf("newWithStore returned error: %v", err) + } + + org := bson.NewObjectID() + base := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC) + + p1 := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, base.Add(3*time.Minute)) + p2 := newPaymentFixture(org, "idem-2", "quote-1", "pay-2", agg.StateExecuting, base.Add(2*time.Minute)) + p3 := newPaymentFixture(org, "idem-3", "quote-1", "pay-3", agg.StateSettled, base.Add(1*time.Minute)) + p4 := newPaymentFixture(org, "idem-4", "quote-2", "pay-4", agg.StateExecuting, base.Add(4*time.Minute)) + + for _, p := range []*agg.Payment{p1, p2, p3, p4} { + if err := repo.Create(context.Background(), p); err != nil { + t.Fatalf("Create returned error: %v", err) + } + } + + page1, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{ + OrganizationRef: org, + QuotationRef: "quote-1", + Limit: 2, + }) + if err != nil { + t.Fatalf("ListByQuotationRef(page1) returned error: %v", err) + } + if len(page1.Items) != 2 { + t.Fatalf("page1 size mismatch: got=%d want=2", len(page1.Items)) + } + if page1.Items[0].PaymentRef != p1.PaymentRef || page1.Items[1].PaymentRef != p2.PaymentRef { + t.Fatalf("page1 order mismatch: got=%q,%q", page1.Items[0].PaymentRef, page1.Items[1].PaymentRef) + } + if page1.NextCursor == nil { + t.Fatal("expected next cursor") + } + + page2, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{ + OrganizationRef: org, + QuotationRef: "quote-1", + Limit: 2, + Cursor: page1.NextCursor, + }) + if err != nil { + t.Fatalf("ListByQuotationRef(page2) returned error: %v", err) + } + if len(page2.Items) != 1 || page2.Items[0].PaymentRef != p3.PaymentRef { + t.Fatalf("page2 mismatch") + } + if page2.NextCursor != nil { + t.Fatalf("expected nil next cursor") + } + + statePage, err := repo.ListByState(context.Background(), ListByStateInput{ + OrganizationRef: org, + State: agg.StateExecuting, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListByState returned error: %v", err) + } + if len(statePage.Items) != 3 { + t.Fatalf("state page size mismatch: got=%d want=3", len(statePage.Items)) + } + if statePage.Items[0].PaymentRef != p4.PaymentRef { + t.Fatalf("state order mismatch: first=%q want=%q", statePage.Items[0].PaymentRef, p4.PaymentRef) + } +} + +func TestValidationErrors(t *testing.T) { + store := newFakeStore() + repo, err := newWithStore(store) + if err != nil { + t.Fatalf("newWithStore returned error: %v", err) + } + org := bson.NewObjectID() + + tests := []struct { + name string + run func() error + }{ + { + name: "create missing payment", + run: func() error { + return repo.Create(context.Background(), nil) + }, + }, + { + name: "create invalid state", + run: func() error { + p := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.State("bad"), time.Now()) + return repo.Create(context.Background(), p) + }, + }, + { + name: "update expected version missing", + run: func() error { + p := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, time.Now()) + p.ID = bson.NewObjectID() + return repo.UpdateCAS(context.Background(), p, 0) + }, + }, + { + name: "list state invalid", + run: func() error { + _, err := repo.ListByState(context.Background(), ListByStateInput{ + OrganizationRef: org, + State: agg.State("bad"), + }) + return err + }, + }, + { + name: "list cursor invalid", + run: func() error { + _, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{ + OrganizationRef: org, + QuotationRef: "quote-1", + Cursor: &ListCursor{CreatedAt: time.Now()}, + }) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.run(); !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +type fakeStore struct { + indexes []*indexDefinition + docs map[bson.ObjectID]*paymentDocument +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + docs: map[bson.ObjectID]*paymentDocument{}, + } +} + +func (f *fakeStore) EnsureIndexes(defs []*indexDefinition) error { + f.indexes = defs + return nil +} + +func (f *fakeStore) Create(_ context.Context, doc *paymentDocument) error { + cloned, err := cloneDocument(doc) + if err != nil { + return err + } + for _, existing := range f.docs { + if existing.OrganizationRef == cloned.OrganizationRef && existing.PaymentRef == cloned.PaymentRef { + return ErrDuplicatePayment + } + if existing.OrganizationRef == cloned.OrganizationRef && existing.IdempotencyKey == cloned.IdempotencyKey { + return ErrDuplicatePayment + } + } + f.docs[cloned.ID] = cloned + return nil +} + +func (f *fakeStore) UpdateCAS(_ context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) { + existing := f.docs[doc.ID] + if existing == nil { + return false, nil + } + if existing.OrganizationRef != doc.OrganizationRef { + return false, nil + } + if existing.Version != expectedVersion { + return false, nil + } + cloned, err := cloneDocument(doc) + if err != nil { + return false, err + } + f.docs[doc.ID] = cloned + return true, nil +} + +func (f *fakeStore) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) { + for _, doc := range f.docs { + if doc.OrganizationRef == orgRef && doc.PaymentRef == paymentRef { + return cloneDocument(doc) + } + } + return nil, ErrPaymentNotFound +} + +func (f *fakeStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) { + for _, doc := range f.docs { + if doc.OrganizationRef == orgRef && doc.IdempotencyKey == idempotencyKey { + return cloneDocument(doc) + } + } + return nil, ErrPaymentNotFound +} + +func (f *fakeStore) GetByID(_ context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) { + doc := f.docs[id] + if doc == nil || doc.OrganizationRef != orgRef { + return nil, ErrPaymentNotFound + } + return cloneDocument(doc) +} + +func (f *fakeStore) ListByQuotationRef(_ context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) { + return f.list(func(doc *paymentDocument) bool { + return doc.OrganizationRef == orgRef && doc.QuotationRef == quotationRef + }, cursor, limit) +} + +func (f *fakeStore) ListByState(_ context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) { + return f.list(func(doc *paymentDocument) bool { + return doc.OrganizationRef == orgRef && doc.State == state + }, cursor, limit) +} + +func (f *fakeStore) list(match func(*paymentDocument) bool, cursor *listCursor, limit int64) ([]*paymentDocument, error) { + items := make([]*paymentDocument, 0) + for _, doc := range f.docs { + if !match(doc) { + continue + } + if cursor != nil { + if !isBeforeCursor(doc, *cursor) { + continue + } + } + cloned, err := cloneDocument(doc) + if err != nil { + return nil, err + } + items = append(items, cloned) + } + + sort.Slice(items, func(i, j int) bool { + left := items[i] + right := items[j] + if !left.CreatedAt.Equal(right.CreatedAt) { + return left.CreatedAt.After(right.CreatedAt) + } + return bytes.Compare(left.ID[:], right.ID[:]) > 0 + }) + + if int64(len(items)) > limit { + items = items[:limit] + } + return items, nil +} + +func isBeforeCursor(doc *paymentDocument, cursor listCursor) bool { + if doc.CreatedAt.Before(cursor.CreatedAt) { + return true + } + if doc.CreatedAt.After(cursor.CreatedAt) { + return false + } + return bytes.Compare(doc.ID[:], cursor.ID[:]) < 0 +} + +func assertIndex(t *testing.T, def *indexDefinition, fields []string, unique bool) { + t.Helper() + if def == nil { + t.Fatal("expected index definition") + } + if def.Unique != unique { + t.Fatalf("index unique mismatch: got=%v want=%v", def.Unique, unique) + } + if len(def.Keys) != len(fields) { + t.Fatalf("index key count mismatch: got=%d want=%d", len(def.Keys), len(fields)) + } + for i := range fields { + if def.Keys[i].Field != fields[i] { + t.Fatalf("index key[%d] mismatch: got=%q want=%q", i, def.Keys[i].Field, fields[i]) + } + } +} + +func newPaymentFixture(org bson.ObjectID, idem, quote, paymentRef string, state agg.State, createdAt time.Time) *agg.Payment { + return &agg.Payment{ + Base: modelBase(createdAt), + OrganizationBoundBase: modelOrg(org), + PaymentRef: paymentRef, + IdempotencyKey: idem, + QuotationRef: quote, + ClientPaymentRef: "client-" + paymentRef, + IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()}, + QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: quote}, + State: state, + Version: 1, + StepExecutions: []agg.StepExecution{ + {StepRef: "s1", StepCode: "step-1", State: agg.StepStatePending, Attempt: 1}, + }, + } +} + +func modelOrg(org bson.ObjectID) pm.OrganizationBoundBase { + return pm.OrganizationBoundBase{OrganizationRef: org} +} + +func modelBase(createdAt time.Time) storable.Base { + return storable.Base{ + ID: bson.NewObjectID(), + CreatedAt: createdAt.UTC(), + UpdatedAt: createdAt.UTC(), + } +} + +func testMoney() *paymenttypes.Money { + return &paymenttypes.Money{Amount: "10", Currency: "USDT"} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/errors.go new file mode 100644 index 00000000..6d04aac1 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/errors.go @@ -0,0 +1,7 @@ +package prmap + +import "github.com/tech/sendico/pkg/merrors" + +func invalidMissing(field string) error { + return merrors.InvalidArgument(field + " is required") +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/helpers.go new file mode 100644 index 00000000..de92d133 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/helpers.go @@ -0,0 +1,38 @@ +package prmap + +import ( + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" +) + +func tsOrNil(value time.Time) *timestamppb.Timestamp { + if value.IsZero() { + return nil + } + return timestamppb.New(value.UTC()) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go new file mode 100644 index 00000000..ad21f400 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go @@ -0,0 +1,203 @@ +package prmap + +import ( + "strconv" + "strings" + + "github.com/tech/sendico/payments/storage/model" + pkgmodel "github.com/tech/sendico/pkg/model" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type ledgerMethodData struct { + LedgerAccountRef string `bson:"ledgerAccountRef"` + ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"` +} + +func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error) { + source, err := mapIntentEndpoint(src.Source, "intent_snapshot.source") + if err != nil { + return nil, err + } + destination, err := mapIntentEndpoint(src.Destination, "intent_snapshot.destination") + if err != nil { + return nil, err + } + + settlementMode := settlementModeToProto(src.SettlementMode) + return "ationv2.QuoteIntent{ + Source: source, + Destination: destination, + Amount: moneyToProto(src.Amount), + SettlementMode: settlementMode, + FeeTreatment: feeTreatmentForSettlementMode(settlementMode), + SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)), + Comment: strings.TrimSpace(src.Attributes["comment"]), + }, nil +} + +func mapIntentEndpoint(src model.PaymentEndpoint, field string) (*endpointv1.PaymentEndpoint, error) { + switch src.Type { + case model.EndpointTypeManagedWallet: + if src.ManagedWallet == nil { + return nil, invalidMissing(field + ".managed_wallet") + } + if strings.TrimSpace(src.ManagedWallet.ManagedWalletRef) == "" { + return nil, invalidMissing(field + ".managed_wallet.managed_wallet_ref") + } + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, walletData(src.ManagedWallet)) + case model.EndpointTypeExternalChain: + if src.ExternalChain == nil { + return nil, invalidMissing(field + ".external_chain") + } + if strings.TrimSpace(src.ExternalChain.Address) == "" { + return nil, invalidMissing(field + ".external_chain.address") + } + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, externalChainData(src.ExternalChain)) + case model.EndpointTypeCard: + if src.Card == nil { + return nil, invalidMissing(field + ".card") + } + if strings.TrimSpace(src.Card.Token) == "" && strings.TrimSpace(src.Card.Pan) == "" { + return nil, invalidMissing(field + ".card.pan_or_token") + } + return endpointWithCard(src.Card) + case model.EndpointTypeLedger: + if src.Ledger == nil { + return nil, invalidMissing(field + ".ledger") + } + if strings.TrimSpace(src.Ledger.LedgerAccountRef) == "" { + return nil, invalidMissing(field + ".ledger.ledger_account_ref") + } + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, ledgerData(src.Ledger)) + default: + return nil, invalidMissing(field) + } +} + +func endpointWithCard(card *model.CardEndpoint) (*endpointv1.PaymentEndpoint, error) { + token := strings.TrimSpace(card.Token) + if token != "" { + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, tokenCardData(card)) + } + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, rawCardData(card)) +} + +func endpointWithMethod( + methodType endpointv1.PaymentMethodType, + methodData any, +) (*endpointv1.PaymentEndpoint, error) { + data, err := bson.Marshal(methodData) + if err != nil { + return nil, err + } + return &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: methodType, + Data: data, + }, + }, + }, nil +} + +func walletData(src *model.ManagedWalletEndpoint) pkgmodel.WalletPaymentData { + walletID := "" + if src != nil { + walletID = strings.TrimSpace(src.ManagedWalletRef) + } + return pkgmodel.WalletPaymentData{ + WalletID: walletID, + } +} + +func externalChainData(src *model.ExternalChainEndpoint) pkgmodel.CryptoAddressPaymentData { + currency := pkgmodel.Currency("") + network := "" + address := "" + if src != nil { + if src.Asset != nil { + currency = pkgmodel.Currency(strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol))) + network = strings.TrimSpace(src.Asset.Chain) + } + address = strings.TrimSpace(src.Address) + } + data := pkgmodel.CryptoAddressPaymentData{ + Currency: currency, + Network: network, + Address: address, + } + if src != nil && strings.TrimSpace(src.Memo) != "" { + memo := strings.TrimSpace(src.Memo) + data.DestinationTag = &memo + } + return data +} + +func rawCardData(src *model.CardEndpoint) pkgmodel.CardPaymentData { + if src == nil { + return pkgmodel.CardPaymentData{} + } + return pkgmodel.CardPaymentData{ + Pan: strings.TrimSpace(src.Pan), + FirstName: strings.TrimSpace(src.Cardholder), + LastName: strings.TrimSpace(src.CardholderSurname), + ExpMonth: uintToString(src.ExpMonth), + ExpYear: uintToString(src.ExpYear), + Country: strings.TrimSpace(src.Country), + } +} + +func tokenCardData(src *model.CardEndpoint) pkgmodel.TokenPaymentData { + if src == nil { + return pkgmodel.TokenPaymentData{} + } + return pkgmodel.TokenPaymentData{ + Token: strings.TrimSpace(src.Token), + Last4: strings.TrimSpace(src.MaskedPan), + ExpMonth: uintToString(src.ExpMonth), + ExpYear: uintToString(src.ExpYear), + CardholderName: strings.TrimSpace(src.Cardholder), + Country: strings.TrimSpace(src.Country), + } +} + +func ledgerData(src *model.LedgerEndpoint) ledgerMethodData { + if src == nil { + return ledgerMethodData{} + } + return ledgerMethodData{ + LedgerAccountRef: strings.TrimSpace(src.LedgerAccountRef), + ContraLedgerAccountRef: strings.TrimSpace(src.ContraLedgerAccountRef), + } +} + +func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode { + switch mode { + case model.SettlementModeFixReceived: + return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + case model.SettlementModeFixSource: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } +} + +func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + } +} + +func uintToString(value uint32) string { + if value == 0 { + return "" + } + return strconv.FormatUint(uint64(value), 10) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go new file mode 100644 index 00000000..4399b715 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go @@ -0,0 +1,68 @@ +package prmap + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/merrors" +) + +func validateMapInput(in MapInput) error { + if in.Payment == nil { + return merrors.InvalidArgument("payment is required") + } + return validatePaymentInvariants(in.Payment) +} + +func validatePaymentInvariants(payment *agg.Payment) error { + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + if strings.TrimSpace(payment.PaymentRef) == "" { + return merrors.InvalidArgument("payment.payment_ref is required") + } + if strings.TrimSpace(payment.QuotationRef) == "" { + return merrors.InvalidArgument("payment.quotation_ref is required") + } + if payment.IntentSnapshot.Amount == nil { + return merrors.InvalidArgument("payment.intent_snapshot.amount is required") + } + if strings.TrimSpace(payment.IntentSnapshot.SettlementCurrency) == "" { + return merrors.InvalidArgument("payment.intent_snapshot.settlement_currency is required") + } + if payment.QuoteSnapshot == nil { + return merrors.InvalidArgument("payment.quote_snapshot is required") + } + if payment.QuoteSnapshot.DebitAmount == nil { + return merrors.InvalidArgument("payment.quote_snapshot.debit_amount is required") + } + if _, ok := normalizeAggregateState(payment.State); !ok { + return merrors.InvalidArgument("payment.state is invalid") + } + if payment.Version == 0 { + return merrors.InvalidArgument("payment.version is required") + } + if payment.CreatedAt.IsZero() { + return merrors.InvalidArgument("payment.created_at is required") + } + if payment.UpdatedAt.IsZero() { + return merrors.InvalidArgument("payment.updated_at is required") + } + + for i := range payment.StepExecutions { + if err := validateStepInvariants(payment.StepExecutions[i], i); err != nil { + return err + } + } + return nil +} + +func validateStepInvariants(step agg.StepExecution, index int) error { + if strings.TrimSpace(step.StepRef) == "" { + return merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].step_ref is required") + } + if _, ok := normalizeStepState(step.State); !ok { + return merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].state is invalid") + } + return nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go new file mode 100644 index 00000000..7a20c2fe --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go @@ -0,0 +1,36 @@ +package prmap + +import ( + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/pkg/mlogger" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" +) + +// Mapper transforms orchestration-v2 runtime aggregate snapshots into API responses. +type Mapper interface { + Map(in MapInput) (*MapOutput, error) +} + +// MapInput is the mapper payload. +type MapInput struct { + Payment *agg.Payment +} + +// MapOutput is the mapper result. +type MapOutput struct { + Payment *orchestrationv2.Payment +} + +// Dependencies configures payment mapper integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) Mapper { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return &svc{logger: dep.Logger.Named("prmap")} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go new file mode 100644 index 00000000..ed5aa573 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go @@ -0,0 +1,324 @@ +package prmap + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func mapQuoteSnapshot( + src *model.PaymentQuoteSnapshot, + fallbackQuoteRef string, + intentRef string, +) *quotationv2.PaymentQuote { + if src == nil { + return nil + } + resolvedSettlementMode := resolvedSettlementModeFromSnapshot(src) + fxQuote := fxQuoteToProto(src.FXQuote) + + return "ationv2.PaymentQuote{ + State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, + BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + TransferPrincipalAmount: moneyToProto(src.DebitAmount), + DestinationAmount: moneyToProto(src.ExpectedSettlementAmount), + FeeLines: feeLinesToProto(src.FeeLines), + FeeRules: feeRulesToProto(src.FeeRules), + FxQuote: fxQuote, + QuoteRef: firstNonEmpty(src.QuoteRef, fallbackQuoteRef), + ExpiresAt: quoteExpiryToProto(fxQuote), + PricedAt: quotePricedAtToProto(fxQuote), + Route: routeToProto(src.Route), + ExecutionConditions: executionConditionsToProto(src.ExecutionConditions), + PayerTotalDebitAmount: moneyToProto(src.TotalCost), + ResolvedSettlementMode: resolvedSettlementMode, + ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), + IntentRef: strings.TrimSpace(intentRef), + } +} + +func moneyToProto(src *paymenttypes.Money) *moneyv1.Money { + if src == nil { + return nil + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())), + } +} + +func fxQuoteToProto(src *paymenttypes.FXQuote) *oraclev1.Quote { + if src == nil { + return nil + } + out := &oraclev1.Quote{ + QuoteRef: strings.TrimSpace(src.QuoteRef), + Side: fxSideToProto(src.GetSide()), + Price: &moneyv1.Decimal{Value: strings.TrimSpace(src.GetPrice().GetValue())}, + BaseAmount: moneyToProto(src.GetBaseAmount()), + QuoteAmount: moneyToProto(src.GetQuoteAmount()), + ExpiresAtUnixMs: src.GetExpiresAtUnixMs(), + Provider: strings.TrimSpace(src.GetProvider()), + RateRef: strings.TrimSpace(src.GetRateRef()), + Firm: src.GetFirm(), + } + if pair := src.GetPair(); pair != nil { + out.Pair = &fxv1.CurrencyPair{ + Base: strings.ToUpper(strings.TrimSpace(pair.GetBase())), + Quote: strings.ToUpper(strings.TrimSpace(pair.GetQuote())), + } + } + if src.GetPricedAtUnixMs() > 0 { + out.PricedAt = tsOrNil(time.UnixMilli(src.GetPricedAtUnixMs())) + } + return out +} + +func routeToProto(src *paymenttypes.QuoteRouteSpecification) *quotationv2.RouteSpecification { + if src == nil { + return nil + } + out := "ationv2.RouteSpecification{ + Rail: strings.TrimSpace(src.Rail), + Provider: strings.TrimSpace(src.Provider), + PayoutMethod: strings.TrimSpace(src.PayoutMethod), + Network: strings.TrimSpace(src.Network), + RouteRef: strings.TrimSpace(src.RouteRef), + PricingProfileRef: strings.TrimSpace(src.PricingProfileRef), + Settlement: routeSettlementToProto(src.Settlement), + } + if len(src.Hops) > 0 { + out.Hops = make([]*quotationv2.RouteHop, 0, len(src.Hops)) + for _, hop := range src.Hops { + if hop == nil { + continue + } + out.Hops = append(out.Hops, "ationv2.RouteHop{ + Index: hop.Index, + Rail: strings.TrimSpace(hop.Rail), + Gateway: strings.TrimSpace(hop.Gateway), + InstanceId: strings.TrimSpace(hop.InstanceID), + Network: strings.TrimSpace(hop.Network), + Role: routeHopRoleToProto(hop.Role), + }) + } + } + return out +} + +func routeSettlementToProto(src *paymenttypes.QuoteRouteSettlement) *quotationv2.RouteSettlement { + if src == nil { + return nil + } + out := "ationv2.RouteSettlement{ + Model: strings.TrimSpace(src.Model), + } + if src.Asset != nil { + out.Asset = &paymentv1.ChainAsset{ + Key: &paymentv1.ChainAssetKey{ + Chain: strings.ToUpper(strings.TrimSpace(src.Asset.Chain)), + TokenSymbol: strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol)), + }, + } + if contract := strings.TrimSpace(src.Asset.ContractAddress); contract != "" { + out.Asset.ContractAddress = &contract + } + } + if out.Asset == nil && out.Model == "" { + return nil + } + return out +} + +func executionConditionsToProto(src *paymenttypes.QuoteExecutionConditions) *quotationv2.ExecutionConditions { + if src == nil { + return nil + } + return "ationv2.ExecutionConditions{ + Readiness: readinessToProto(src.Readiness), + BatchingEligible: src.BatchingEligible, + PrefundingRequired: src.PrefundingRequired, + PrefundingCostIncluded: src.PrefundingCostIncluded, + LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution, + LatencyHint: strings.TrimSpace(src.LatencyHint), + Assumptions: cloneAssumptions(src.Assumptions), + } +} + +func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine { + if len(lines) == 0 { + return nil + } + out := make([]*feesv1.DerivedPostingLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + continue + } + out = append(out, &feesv1.DerivedPostingLine{ + LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), + Money: moneyToProto(line.GetMoney()), + LineType: postingLineTypeToProto(line.GetLineType()), + Side: entrySideToProto(line.GetSide()), + Meta: cloneStringMap(line.Meta), + }) + } + return out +} + +func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule { + if len(rules) == 0 { + return nil + } + out := make([]*feesv1.AppliedRule, 0, len(rules)) + for _, rule := range rules { + if rule == nil { + continue + } + out = append(out, &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: cloneStringMap(rule.Parameters), + }) + } + return out +} + +func resolvedSettlementModeFromSnapshot(snapshot *model.PaymentQuoteSnapshot) paymentv1.SettlementMode { + if snapshot == nil || snapshot.Route == nil || snapshot.Route.Settlement == nil { + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } + switch strings.ToUpper(strings.TrimSpace(snapshot.Route.Settlement.Model)) { + case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED": + return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } +} + +func quoteExpiryToProto(src *oraclev1.Quote) *timestamppb.Timestamp { + if src == nil || src.GetExpiresAtUnixMs() <= 0 { + return nil + } + return tsOrNil(time.UnixMilli(src.GetExpiresAtUnixMs())) +} + +func quotePricedAtToProto(src *oraclev1.Quote) *timestamppb.Timestamp { + if src == nil || src.GetPricedAt() == nil { + return nil + } + return timestamppb.New(src.GetPricedAt().AsTime().UTC()) +} + +func cloneAssumptions(src []string) []string { + if len(src) == 0 { + return nil + } + out := make([]string, 0, len(src)) + for _, item := range src { + if trimmed := strings.TrimSpace(item); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + out := make(map[string]string, len(src)) + for k, v := range src { + out[k] = v + } + return out +} + +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 routeHopRoleToProto(role paymenttypes.QuoteRouteHopRole) quotationv2.RouteHopRole { + switch role { + case paymenttypes.QuoteRouteHopRoleSource: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE + case paymenttypes.QuoteRouteHopRoleTransit: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT + case paymenttypes.QuoteRouteHopRoleDestination: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION + default: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_UNSPECIFIED + } +} + +func readinessToProto(readiness paymenttypes.QuoteExecutionReadiness) quotationv2.QuoteExecutionReadiness { + switch readiness { + case paymenttypes.QuoteExecutionReadinessLiquidityReady: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY + case paymenttypes.QuoteExecutionReadinessLiquidityObtainable: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE + case paymenttypes.QuoteExecutionReadinessIndicative: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE + default: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_UNSPECIFIED + } +} + +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 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 roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode { + switch mode { + case paymenttypes.RoundingModeHalfEven: + return moneyv1.RoundingMode_ROUND_HALF_EVEN + case paymenttypes.RoundingModeHalfUp: + return moneyv1.RoundingMode_ROUND_HALF_UP + case paymenttypes.RoundingModeDown: + return moneyv1.RoundingMode_ROUND_DOWN + default: + return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service.go new file mode 100644 index 00000000..02b32028 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service.go @@ -0,0 +1,82 @@ +package prmap + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/pkg/mlogger" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger +} + +func (s *svc) Map(in MapInput) (out *MapOutput, err error) { + logger := s.logger + paymentRef := "" + if in.Payment != nil { + paymentRef = strings.TrimSpace(in.Payment.PaymentRef) + } + logger.Debug("Starting Map", zap.String("payment_ref", paymentRef)) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("payment_ref", paymentRef), + } + if out != nil && out.Payment != nil { + fields = append(fields, + zap.String("state", out.Payment.GetState().String()), + zap.Uint64("version", out.Payment.GetVersion()), + ) + } + if err != nil { + logger.Warn("Failed to map", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Map", fields...) + }(time.Now()) + + if err := validateMapInput(in); err != nil { + return nil, err + } + + protoPayment, err := mapPayment(in.Payment) + if err != nil { + return nil, err + } + out = &MapOutput{Payment: protoPayment} + return out, nil +} + +func mapPayment(src *agg.Payment) (*orchestrationv2.Payment, error) { + if src == nil { + return nil, nil + } + + intentSnapshot, err := mapIntentSnapshot(src.IntentSnapshot) + if err != nil { + return nil, err + } + quoteSnapshot := mapQuoteSnapshot(src.QuoteSnapshot, strings.TrimSpace(src.QuotationRef), strings.TrimSpace(src.IntentSnapshot.Ref)) + steps, err := mapStepExecutions(src.StepExecutions) + if err != nil { + return nil, err + } + + return &orchestrationv2.Payment{ + PaymentRef: strings.TrimSpace(src.PaymentRef), + QuotationRef: strings.TrimSpace(src.QuotationRef), + IntentSnapshot: intentSnapshot, + QuoteSnapshot: quoteSnapshot, + ClientPaymentRef: strings.TrimSpace(src.ClientPaymentRef), + State: mapAggregateState(src.State), + Version: src.Version, + StepExecutions: steps, + CreatedAt: tsOrNil(src.CreatedAt), + UpdatedAt: tsOrNil(src.UpdatedAt), + }, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go new file mode 100644 index 00000000..2442611a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -0,0 +1,373 @@ +package prmap + +import ( + "errors" + "testing" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/merrors" + pkgmodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestMap_Success(t *testing.T) { + mapper := New() + payment := newPaymentFixture() + + out, err := mapper.Map(MapInput{Payment: payment}) + if err != nil { + t.Fatalf("Map returned error: %v", err) + } + if out == nil || out.Payment == nil { + t.Fatalf("expected mapped payment") + } + + protoPayment := out.Payment + if got, want := protoPayment.GetPaymentRef(), payment.PaymentRef; got != want { + t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want) + } + if got, want := protoPayment.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := protoPayment.GetCreatedAt().AsTime().UTC(), payment.CreatedAt.UTC(); !got.Equal(want) { + t.Fatalf("created_at mismatch: got=%v want=%v", got, want) + } + if got, want := protoPayment.GetUpdatedAt().AsTime().UTC(), payment.UpdatedAt.UTC(); !got.Equal(want) { + t.Fatalf("updated_at mismatch: got=%v want=%v", got, want) + } + + intent := protoPayment.GetIntentSnapshot() + if intent == nil { + t.Fatalf("expected intent_snapshot") + } + if got, want := intent.GetSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { + t.Fatalf("settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := intent.GetComment(), "invoice-7"; got != want { + t.Fatalf("comment mismatch: got=%q want=%q", got, want) + } + if source := intent.GetSource().GetPaymentMethod(); source == nil { + t.Fatalf("expected source payment_method") + } else { + if got, want := source.GetType(), endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET; got != want { + t.Fatalf("source method type mismatch: got=%s want=%s", got.String(), want.String()) + } + var wallet pkgmodel.WalletPaymentData + if err := bson.Unmarshal(source.GetData(), &wallet); err != nil { + t.Fatalf("failed to decode wallet data: %v", err) + } + if got, want := wallet.WalletID, "mw-src"; got != want { + t.Fatalf("wallet id mismatch: got=%q want=%q", got, want) + } + } + if destination := intent.GetDestination().GetPaymentMethod(); destination == nil { + t.Fatalf("expected destination payment_method") + } else if got, want := destination.GetType(), endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN; got != want { + t.Fatalf("destination method type mismatch: got=%s want=%s", got.String(), want.String()) + } + + quote := protoPayment.GetQuoteSnapshot() + if quote == nil { + t.Fatalf("expected quote_snapshot") + } + if got, want := quote.GetQuoteRef(), payment.QuotationRef; got != want { + t.Fatalf("quote_ref mismatch: got=%q want=%q", got, want) + } + if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want { + t.Fatalf("quote state mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("resolved_settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { + t.Fatalf("resolved_fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetIntentRef(), payment.IntentSnapshot.Ref; got != want { + t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) + } + + steps := protoPayment.GetStepExecutions() + if len(steps) != 2 { + t.Fatalf("step count mismatch: got=%d want=2", len(steps)) + } + if got, want := steps[0].GetAttempt(), uint32(1); got != want { + t.Fatalf("attempt normalization mismatch: got=%d want=%d", got, want) + } + if got, want := steps[1].GetFailure().GetCategory(), sharedv1.PaymentFailureCode_FAILURE_BALANCE; got != want { + t.Fatalf("failure category mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := steps[1].GetRefs()[0].GetRail(), gatewayv1.Rail_RAIL_LEDGER; got != want { + t.Fatalf("external ref rail mismatch: got=%s want=%s", got.String(), want.String()) + } +} + +func TestMap_InvalidArguments(t *testing.T) { + mapper := New() + now := time.Date(2026, time.January, 11, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + payment *agg.Payment + }{ + { + name: "nil payment", + }, + { + name: "missing payment ref", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.PaymentRef = "" + return p + }(), + }, + { + name: "missing quote snapshot", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.QuoteSnapshot = nil + return p + }(), + }, + { + name: "invalid aggregate state", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.State = agg.StateUnspecified + return p + }(), + }, + { + name: "invalid step state", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.StepExecutions[0].State = agg.StepStateUnspecified + return p + }(), + }, + { + name: "missing intent amount", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.IntentSnapshot.Amount = nil + return p + }(), + }, + { + name: "unsupported endpoint type", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.IntentSnapshot.Source.Type = model.EndpointTypeUnspecified + return p + }(), + }, + { + name: "missing timestamps", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.CreatedAt = now + p.UpdatedAt = time.Time{} + return p + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := mapper.Map(MapInput{Payment: tt.payment}) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +func newPaymentFixture() *agg.Payment { + createdAt := time.Date(2026, time.January, 11, 12, 0, 0, 0, time.UTC) + startedAt := createdAt.Add(2 * time.Minute) + + return &agg.Payment{ + Base: storable.Base{ + ID: bson.NewObjectID(), + CreatedAt: createdAt, + UpdatedAt: createdAt.Add(5 * time.Minute), + }, + OrganizationBoundBase: pkgmodel.OrganizationBoundBase{ + OrganizationRef: bson.NewObjectID(), + }, + PaymentRef: "pay-1", + IdempotencyKey: "idem-1", + QuotationRef: "quote-1", + ClientPaymentRef: "client-1", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-1", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "mw-src", + Asset: &paymenttypes.Asset{ + Chain: "TRON", + TokenSymbol: "USDT", + }, + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Token: "tok_1", + MaskedPan: "1234", + Cardholder: "John", + ExpMonth: 12, + ExpYear: 2030, + Country: "US", + }, + }, + Amount: &paymenttypes.Money{ + Amount: "100", + Currency: "USDT", + }, + SettlementMode: model.SettlementModeFixSource, + SettlementCurrency: "USD", + Attributes: map[string]string{ + "comment": "invoice-7", + }, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{ + Amount: "100", + Currency: "USDT", + }, + ExpectedSettlementAmount: &paymenttypes.Money{ + Amount: "95", + Currency: "USD", + }, + TotalCost: &paymenttypes.Money{ + Amount: "101", + Currency: "USDT", + }, + FeeLines: []*paymenttypes.FeeLine{ + { + LedgerAccountRef: "fees:1", + Money: &paymenttypes.Money{ + Amount: "1", + Currency: "USDT", + }, + LineType: paymenttypes.PostingLineTypeFee, + Side: paymenttypes.EntrySideDebit, + Meta: map[string]string{"bucket": "service"}, + }, + }, + FeeRules: []*paymenttypes.AppliedRule{ + { + RuleID: "rule-1", + RuleVersion: "v1", + Formula: "x*0.01", + Rounding: paymenttypes.RoundingModeHalfUp, + TaxCode: "VAT", + TaxRate: "0.10", + Parameters: map[string]string{"jurisdiction": "EU"}, + }, + }, + FXQuote: &paymenttypes.FXQuote{ + QuoteRef: "fx-1", + Pair: &paymenttypes.CurrencyPair{ + Base: "USDT", + Quote: "USD", + }, + Side: paymenttypes.FXSideSellBaseBuyQuote, + Price: &paymenttypes.Decimal{ + Value: "0.95", + }, + BaseAmount: &paymenttypes.Money{ + Amount: "100", + Currency: "USDT", + }, + QuoteAmount: &paymenttypes.Money{ + Amount: "95", + Currency: "USD", + }, + ExpiresAtUnixMs: createdAt.Add(10 * time.Minute).UnixMilli(), + PricedAtUnixMs: createdAt.Add(-1 * time.Minute).UnixMilli(), + Provider: "oracle-1", + RateRef: "rate-1", + Firm: true, + }, + Route: &paymenttypes.QuoteRouteSpecification{ + Rail: "CARD_PAYOUT", + Provider: "provider-1", + PayoutMethod: "CARD", + Network: "VISA", + RouteRef: "route-1", + PricingProfileRef: "pricing-1", + Settlement: &paymenttypes.QuoteRouteSettlement{ + Model: "FIX_RECEIVED", + Asset: &paymenttypes.Asset{ + Chain: "TRON", + TokenSymbol: "USDT", + }, + }, + Hops: []*paymenttypes.QuoteRouteHop{ + { + Index: 10, + Rail: "LEDGER", + Role: paymenttypes.QuoteRouteHopRoleSource, + }, + { + Index: 20, + Rail: "CARD_PAYOUT", + Gateway: "gw-card", + InstanceID: "card-1", + Role: paymenttypes.QuoteRouteHopRoleDestination, + }, + }, + }, + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: paymenttypes.QuoteExecutionReadinessLiquidityReady, + BatchingEligible: true, + PrefundingRequired: false, + PrefundingCostIncluded: true, + LiquidityCheckRequiredAtExecution: true, + LatencyHint: "fast", + Assumptions: []string{"funds_ready"}, + }, + }, + State: agg.StateExecuting, + Version: 3, + StepExecutions: []agg.StepExecution{ + { + StepRef: "s1", + StepCode: "hop.20.card_payout.send", + State: agg.StepStateRunning, + Attempt: 0, + StartedAt: &startedAt, + }, + { + StepRef: "s2", + StepCode: "edge.10_20.ledger.debit", + State: agg.StepStateFailed, + Attempt: 2, + FailureCode: "ledger_balance_low", + FailureMsg: "insufficient balance", + ExternalRefs: []agg.ExternalRef{ + { + GatewayInstanceID: "ledger-1", + Kind: "ledger_entry_ref", + Ref: "entry-1", + }, + }, + }, + }, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go new file mode 100644 index 00000000..96cb5ba3 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go @@ -0,0 +1,120 @@ +package prmap + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" +) + +func normalizeAggregateState(state agg.State) (agg.State, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case string(agg.StateCreated): + return agg.StateCreated, true + case string(agg.StateExecuting): + return agg.StateExecuting, true + case string(agg.StateNeedsAttention): + return agg.StateNeedsAttention, true + case string(agg.StateSettled): + return agg.StateSettled, true + case string(agg.StateFailed): + return agg.StateFailed, true + default: + return agg.StateUnspecified, false + } +} + +func normalizeStepState(state agg.StepState) (agg.StepState, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case string(agg.StepStatePending): + return agg.StepStatePending, true + case string(agg.StepStateRunning): + return agg.StepStateRunning, true + case string(agg.StepStateCompleted): + return agg.StepStateCompleted, true + case string(agg.StepStateFailed): + return agg.StepStateFailed, true + case string(agg.StepStateNeedsAttention): + return agg.StepStateNeedsAttention, true + case string(agg.StepStateSkipped): + return agg.StepStateSkipped, true + default: + return agg.StepStateUnspecified, false + } +} + +func mapAggregateState(state agg.State) orchestrationv2.OrchestrationState { + switch normalized, _ := normalizeAggregateState(state); normalized { + case agg.StateCreated: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED + case agg.StateExecuting: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING + case agg.StateNeedsAttention: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_NEEDS_ATTENTION + case agg.StateSettled: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED + case agg.StateFailed: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED + default: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_UNSPECIFIED + } +} + +func mapStepState(state agg.StepState) orchestrationv2.StepExecutionState { + switch normalized, _ := normalizeStepState(state); normalized { + case agg.StepStatePending: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_PENDING + case agg.StepStateRunning: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING + case agg.StepStateCompleted: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED + case agg.StepStateFailed: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED + case agg.StepStateNeedsAttention: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_NEEDS_ATTENTION + case agg.StepStateSkipped: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_SKIPPED + default: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_UNSPECIFIED + } +} + +func inferFailureCategory(failureCode string) sharedv1.PaymentFailureCode { + code := strings.ToLower(strings.TrimSpace(failureCode)) + switch { + case strings.Contains(code, "balance"), strings.Contains(code, "insufficient_funds"): + return sharedv1.PaymentFailureCode_FAILURE_BALANCE + case strings.Contains(code, "ledger"): + return sharedv1.PaymentFailureCode_FAILURE_LEDGER + case strings.Contains(code, "fx"): + return sharedv1.PaymentFailureCode_FAILURE_FX + case strings.Contains(code, "chain"), strings.Contains(code, "crypto"), strings.Contains(code, "provider"), strings.Contains(code, "card"): + return sharedv1.PaymentFailureCode_FAILURE_CHAIN + case strings.Contains(code, "fee"), strings.Contains(code, "charge"): + return sharedv1.PaymentFailureCode_FAILURE_FEES + case strings.Contains(code, "policy"), strings.Contains(code, "risk"), strings.Contains(code, "compliance"): + return sharedv1.PaymentFailureCode_FAILURE_POLICY + default: + return sharedv1.PaymentFailureCode_FAILURE_UNSPECIFIED + } +} + +func inferRail(kind string, stepCode string) gatewayv1.Rail { + all := strings.ToLower(strings.TrimSpace(kind + " " + stepCode)) + switch { + case strings.Contains(all, "ledger"): + return gatewayv1.Rail_RAIL_LEDGER + case strings.Contains(all, "card_payout"), strings.Contains(all, "card"): + return gatewayv1.Rail_RAIL_CARD_PAYOUT + case strings.Contains(all, "provider_settlement"), strings.Contains(all, "provider"): + return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT + case strings.Contains(all, "fiat_onramp"), strings.Contains(all, "onramp"): + return gatewayv1.Rail_RAIL_FIAT_ONRAMP + case strings.Contains(all, "crypto"), strings.Contains(all, "chain"), strings.Contains(all, "tx"): + return gatewayv1.Rail_RAIL_CRYPTO + default: + return gatewayv1.Rail_RAIL_UNSPECIFIED + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go new file mode 100644 index 00000000..e9814d05 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go @@ -0,0 +1,94 @@ +package prmap + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/merrors" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" +) + +func mapStepExecutions(src []agg.StepExecution) ([]*orchestrationv2.StepExecution, error) { + if len(src) == 0 { + return nil, nil + } + out := make([]*orchestrationv2.StepExecution, 0, len(src)) + for i := range src { + mapped, err := mapStepExecution(src[i], i) + if err != nil { + return nil, err + } + out = append(out, mapped) + } + return out, nil +} + +func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepExecution, error) { + state, ok := normalizeStepState(step.State) + if !ok { + return nil, merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].state is invalid") + } + + attempt := step.Attempt + if attempt == 0 { + attempt = 1 + } + + return &orchestrationv2.StepExecution{ + StepRef: strings.TrimSpace(step.StepRef), + StepCode: strings.TrimSpace(step.StepCode), + State: mapStepState(state), + Attempt: attempt, + StartedAt: tsOrNil(derefTime(step.StartedAt)), + CompletedAt: tsOrNil(derefTime(step.CompletedAt)), + Failure: mapStepFailure(step, state), + Refs: mapExternalRefs(step.StepCode, step.ExternalRefs), + }, nil +} + +func mapStepFailure(step agg.StepExecution, state agg.StepState) *orchestrationv2.Failure { + if state != agg.StepStateFailed && state != agg.StepStateNeedsAttention { + return nil + } + code := strings.TrimSpace(step.FailureCode) + msg := strings.TrimSpace(step.FailureMsg) + if code == "" && msg == "" { + return nil + } + return &orchestrationv2.Failure{ + Category: inferFailureCategory(code), + Code: code, + Message: msg, + } +} + +func mapExternalRefs(stepCode string, refs []agg.ExternalRef) []*orchestrationv2.ExternalReference { + if len(refs) == 0 { + return nil + } + out := make([]*orchestrationv2.ExternalReference, 0, len(refs)) + for i := range refs { + ref := refs[i] + kind := strings.TrimSpace(ref.Kind) + value := strings.TrimSpace(ref.Ref) + gatewayInstanceID := strings.TrimSpace(ref.GatewayInstanceID) + if kind == "" && value == "" && gatewayInstanceID == "" { + continue + } + out = append(out, &orchestrationv2.ExternalReference{ + Rail: inferRail(kind, stepCode), + GatewayInstanceId: gatewayInstanceID, + Kind: kind, + Ref: value, + }) + } + return out +} + +func derefTime(value *time.Time) time.Time { + if value == nil { + return time.Time{} + } + return value.UTC() +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go new file mode 100644 index 00000000..1654b174 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go @@ -0,0 +1,132 @@ +package psvc + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "go.uber.org/zap" +) + +func (s *svc) recomputeAggregateState(ctx context.Context, payment *agg.Payment) (bool, error) { + logger := s.logger + if payment == nil { + return false, nil + } + current := payment.State + target := s.deriveAggregateTarget(payment) + next, changed, err := s.transitionAggregateState(current, target) + if err != nil { + return false, err + } + if !changed { + return false, nil + } + payment.State = next + logger.Debug("psvc.payment_state_changed", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("from_state", string(current)), + zap.String("to_state", string(next)), + zap.Uint64("version", payment.Version), + ) + if next == agg.StateSettled || next == agg.StateNeedsAttention || next == agg.StateFailed { + logger.Debug("psvc.payment_finalization_state", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("state", string(next)), + zap.Uint64("version", payment.Version), + ) + } + return true, s.observer.RecordPayment(ctx, oobs.RecordPaymentInput{ + Payment: payment, + Event: paymentEventForState(next), + }) +} + +func (s *svc) deriveAggregateTarget(payment *agg.Payment) agg.State { + if payment == nil { + return agg.StateUnspecified + } + if s.state.IsAggregateTerminal(payment.State) { + return payment.State + } + if len(payment.StepExecutions) == 0 { + return agg.StateCreated + } + + allDone := true + for i := range payment.StepExecutions { + step := payment.StepExecutions[i] + switch step.State { + case agg.StepStateNeedsAttention: + return agg.StateNeedsAttention + case agg.StepStateFailed: + if step.Attempt >= s.maxAttemptsForStep(step.StepRef) { + return agg.StateNeedsAttention + } + allDone = false + case agg.StepStateCompleted, agg.StepStateSkipped: + default: + allDone = false + } + } + if allDone { + return agg.StateSettled + } + return agg.StateExecuting +} + +func (s *svc) transitionAggregateState(current, target agg.State) (agg.State, bool, error) { + if current == target { + return current, false, nil + } + if s.state.IsAggregateTerminal(current) { + return current, false, nil + } + if err := s.state.EnsureAggregateTransition(current, target); err == nil { + return target, true, nil + } + if current == agg.StateCreated { + if err := s.state.EnsureAggregateTransition(current, agg.StateExecuting); err == nil { + current = agg.StateExecuting + } + } + if current == agg.StateNeedsAttention && target == agg.StateCreated { + if err := s.state.EnsureAggregateTransition(current, agg.StateExecuting); err == nil { + current = agg.StateExecuting + } + } + if current == target { + return current, true, nil + } + if err := s.state.EnsureAggregateTransition(current, target); err != nil { + return current, false, nil + } + return target, true, nil +} + +func paymentEventForState(state agg.State) oobs.PaymentEvent { + switch state { + case agg.StateNeedsAttention: + return oobs.PaymentEventNeedsAttention + case agg.StateSettled: + return oobs.PaymentEventSettled + case agg.StateFailed: + return oobs.PaymentEventFailed + default: + return oobs.PaymentEventStateChanged + } +} + +func (s *svc) maxAttemptsForStep(stepRef string) uint32 { + if s.retryPolicy.MaxAttemptsByStepRef != nil { + if maxAttempts := s.retryPolicy.MaxAttemptsByStepRef[stepRef]; maxAttempts > 0 { + return maxAttempts + } + } + if s.retryPolicy.MaxAttempts > 0 { + return s.retryPolicy.MaxAttempts + } + return 1 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go new file mode 100644 index 00000000..20c18e76 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go @@ -0,0 +1,53 @@ +package psvc + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" +) + +type defaultLedgerExecutor struct{} +type defaultCryptoExecutor struct{} +type defaultProviderSettlementExecutor struct{} +type defaultCardPayoutExecutor struct{} +type defaultObserveConfirmExecutor struct{} + +func (defaultLedgerExecutor) ExecuteLedger(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{StepExecution: step}, nil +} + +func (defaultCryptoExecutor) ExecuteCrypto(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return asyncOutput(req.StepExecution, "operation_ref", "crypto:"+req.Step.StepRef), nil +} + +func (defaultProviderSettlementExecutor) ExecuteProviderSettlement(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return asyncOutput(req.StepExecution, "operation_ref", "provider:"+req.Step.StepRef), nil +} + +func (defaultCardPayoutExecutor) ExecuteCardPayout(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return asyncOutput(req.StepExecution, "card_payout_ref", "card:"+req.Step.StepRef), nil +} + +func (defaultObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return asyncOutput(req.StepExecution, "operation_ref", "observe:"+req.Step.StepRef), nil +} + +func asyncOutput(step agg.StepExecution, kind, ref string) *sexec.ExecuteOutput { + step.State = agg.StepStateRunning + step.ExternalRefs = append(step.ExternalRefs, agg.ExternalRef{ + Kind: strings.TrimSpace(kind), + Ref: strings.TrimSpace(ref), + }) + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{ + StepExecution: step, + Async: true, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go new file mode 100644 index 00000000..1e2e6289 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -0,0 +1,284 @@ +package psvc + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" +) + +func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (resp *orchestrationv2.ExecutePaymentResponse, err error) { + logger := s.logger + orgRef := "" + if req != nil && req.GetMeta() != nil { + orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) + } + logger.Debug("Starting Execute payment", + zap.String("organization_ref", orgRef), + zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())), + zap.String("intent_ref", strings.TrimSpace(req.GetIntentRef())), + zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if resp != nil && resp.Payment != nil { + fields = append(fields, + zap.String("payment_ref", strings.TrimSpace(resp.Payment.GetPaymentRef())), + zap.String("state", resp.Payment.GetState().String()), + zap.Uint64("version", resp.Payment.GetVersion()), + ) + } + if err != nil { + logger.Warn("Failed to execute payment", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Execute payment", fields...) + }(time.Now()) + + requestCtx, fingerprint, err := s.prepareExecute(req) + if err != nil { + return nil, err + } + + payment, reused, err := s.tryReuse(ctx, requestCtx, fingerprint) + if err != nil { + return nil, remapIdempotencyError(err) + } + if !reused { + payment, err = s.createNewPayment(ctx, requestCtx) + if err != nil { + return nil, remapIdempotencyError(err) + } + } + if payment != nil { + logger.Debug("psvc.payment_started", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Bool("reused", reused), + zap.String("state", string(payment.State)), + ) + } + + payment, err = s.runRuntime(ctx, payment) + if err != nil { + return nil, err + } + if payment != nil { + logger.Debug("psvc.payment_execution_progressed", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + protoPayment, err := s.mapPayment(payment) + if err != nil { + return nil, err + } + if payment != nil { + logger.Debug("psvc.payment_finalized", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + resp = &orchestrationv2.ExecutePaymentResponse{Payment: protoPayment} + return resp, nil +} + +func (s *svc) prepareExecute(req *orchestrationv2.ExecutePaymentRequest) (*reqval.Ctx, string, error) { + requestCtx, err := s.validator.Validate(mapExecuteReq(req)) + if err != nil { + return nil, "", err + } + fingerprint, err := s.idempotency.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: requestCtx.QuotationRef, + IntentRef: requestCtx.IntentRef, + ClientPaymentRef: requestCtx.ClientPaymentRef, + }) + if err != nil { + return nil, "", err + } + return requestCtx, fingerprint, nil +} + +func mapExecuteReq(req *orchestrationv2.ExecutePaymentRequest) *reqval.Req { + if req == nil { + return nil + } + out := &reqval.Req{ + QuotationRef: req.GetQuotationRef(), + IntentRef: req.GetIntentRef(), + ClientPaymentRef: req.GetClientPaymentRef(), + } + meta := req.GetMeta() + if meta == nil { + return out + } + out.Meta = &reqval.Meta{OrganizationRef: meta.GetOrganizationRef()} + if meta.GetTrace() != nil { + out.Meta.Trace = &reqval.Trace{IdempotencyKey: meta.GetTrace().GetIdempotencyKey()} + } + return out +} + +func (s *svc) tryReuse(ctx context.Context, requestCtx *reqval.Ctx, requestFingerprint string) (*agg.Payment, bool, error) { + existing, err := s.repository.GetByIdempotencyKey(ctx, requestCtx.OrganizationID, requestCtx.IdempotencyKey) + if err != nil { + if errors.Is(err, prepo.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) { + return nil, false, nil + } + return nil, false, err + } + if existing == nil { + return nil, false, nil + } + + existingFingerprint, err := s.idempotency.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: existing.QuotationRef, + IntentRef: existing.IntentSnapshot.Ref, + ClientPaymentRef: existing.ClientPaymentRef, + }) + if err != nil { + return nil, false, err + } + if strings.TrimSpace(existingFingerprint) != strings.TrimSpace(requestFingerprint) { + return nil, false, idem.ErrIdempotencyParamMismatch + } + return existing, true, nil +} + +func (s *svc) createNewPayment(ctx context.Context, requestCtx *reqval.Ctx) (*agg.Payment, error) { + resolved, graph, err := s.resolveAndPlan(ctx, requestCtx) + if err != nil { + return nil, err + } + payment, err := s.aggregate.Create(agg.Input{ + OrganizationRef: requestCtx.OrganizationID, + IdempotencyKey: requestCtx.IdempotencyKey, + QuotationRef: resolved.QuotationRef, + ClientPaymentRef: requestCtx.ClientPaymentRef, + IntentSnapshot: resolved.IntentSnapshot, + QuoteSnapshot: resolved.QuoteSnapshot, + Steps: toStepShells(graph), + }) + if err != nil { + return nil, err + } + + if err := s.repository.Create(ctx, payment); err != nil { + if !errors.Is(err, prepo.ErrDuplicatePayment) { + return nil, err + } + reused, ok, reuseErr := s.tryReuse(ctx, requestCtx, mustFingerprint(s.idempotency, requestCtx)) + if reuseErr != nil { + return nil, reuseErr + } + if ok { + return reused, nil + } + return nil, err + } + + if err := s.recordPaymentCreated(ctx, payment, graph); err != nil { + return nil, err + } + return payment, nil +} + +func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsnap.Output, *xplan.Graph, error) { + resolved, err := s.quote.Resolve(ctx, s.quoteStore, qsnap.Input{ + OrganizationID: requestCtx.OrganizationID, + QuotationRef: requestCtx.QuotationRef, + IntentRef: requestCtx.IntentRef, + }) + if err != nil { + return nil, nil, err + } + graph, err := s.planner.Compile(xplan.Input{ + IntentSnapshot: resolved.IntentSnapshot, + QuoteSnapshot: resolved.QuoteSnapshot, + }) + if err != nil { + return nil, nil, err + } + return resolved, graph, nil +} + +func toStepShells(graph *xplan.Graph) []agg.StepShell { + if graph == nil || len(graph.Steps) == 0 { + return nil + } + out := make([]agg.StepShell, 0, len(graph.Steps)) + for i := range graph.Steps { + out = append(out, agg.StepShell{ + StepRef: graph.Steps[i].StepRef, + StepCode: graph.Steps[i].StepCode, + }) + } + return out +} + +func (s *svc) recordPaymentCreated(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) error { + if err := s.observer.RecordPayment(ctx, oobs.RecordPaymentInput{ + Payment: payment, + Event: oobs.PaymentEventCreated, + Fields: map[string]string{ + "route_ref": graphRouteRef(graph), + }, + }); err != nil { + return err + } + for i := range payment.StepExecutions { + if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: payment.StepExecutions[i], + Event: oobs.StepEventScheduled, + }); err != nil { + return err + } + } + return nil +} + +func graphRouteRef(graph *xplan.Graph) string { + if graph == nil { + return "" + } + return strings.TrimSpace(graph.RouteRef) +} + +func remapIdempotencyError(err error) error { + if errors.Is(err, idem.ErrIdempotencyParamMismatch) { + return merrors.InvalidArgument(err.Error()) + } + return err +} + +func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string { + if idemSvc == nil || requestCtx == nil { + return "" + } + value, err := idemSvc.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: requestCtx.QuotationRef, + IntentRef: requestCtx.IntentRef, + ClientPaymentRef: requestCtx.ClientPaymentRef, + }) + if err != nil { + return "" + } + return value +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/external.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/external.go new file mode 100644 index 00000000..ad8fa952 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/external.go @@ -0,0 +1,209 @@ +package psvc + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func (s *svc) ReconcileExternal(ctx context.Context, in ReconcileExternalInput) (out *ReconcileExternalOutput, err error) { + logger := s.logger + logger.Debug("Starting Reconcile external", + zap.String("organization_ref", strings.TrimSpace(in.OrganizationRef)), + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + zap.String("event_source", externalEventSource(in.Event)), + zap.String("event_status", externalEventStatus(in.Event)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil && out.Payment != nil { + fields = append(fields, + zap.String("state", out.Payment.GetState().String()), + zap.Uint64("version", out.Payment.GetVersion()), + ) + } + if err != nil { + logger.Warn("Failed to reconcile external", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Reconcile external", fields...) + }(time.Now()) + + orgRef := strings.TrimSpace(in.OrganizationRef) + if orgRef == "" { + return nil, merrors.InvalidArgument("organization_ref is required") + } + orgID, err := bson.ObjectIDFromHex(orgRef) + if err != nil { + return nil, merrors.InvalidArgument("organization_ref must be a valid objectID") + } + paymentRef, err := parsePaymentRef(in.PaymentRef) + if err != nil { + return nil, err + } + + payment, err := s.repository.GetByPaymentRef(ctx, orgID, paymentRef) + if err != nil { + return nil, err + } + if payment == nil { + return nil, prepo.ErrPaymentNotFound + } + + reconOut, err := s.reconciler.Reconcile(erecon.Input{ + Payment: payment, + Event: in.Event, + }) + if err != nil { + return nil, err + } + if reconOut == nil || reconOut.Payment == nil { + return nil, merrors.Internal("reconciler returned nil payment") + } + + if err := s.recordExternal(ctx, reconOut.Payment, in.Event, reconOut.MatchedStepRef); err != nil { + return nil, err + } + if reconOut.StepChanged || reconOut.AggregateChanged { + if err := s.repository.UpdateCAS(ctx, reconOut.Payment, payment.Version); err != nil { + if errors.Is(err, prepo.ErrVersionConflict) { + fresh, reloadErr := s.repository.GetByPaymentRef(ctx, orgID, paymentRef) + if reloadErr != nil { + return nil, reloadErr + } + reconOut.Payment = fresh + } else { + return nil, err + } + } + } + + advanced, err := s.runRuntime(ctx, reconOut.Payment) + if err != nil { + return nil, err + } + mapped, err := s.mapPayment(advanced) + if err != nil { + return nil, err + } + out = &ReconcileExternalOutput{Payment: mapped} + return out, nil +} + +func (s *svc) recordExternal(ctx context.Context, payment *agg.Payment, event erecon.Event, matchedStepRef string) error { + input, ok := buildExternalRecordInput(payment, event, matchedStepRef) + if !ok { + return nil + } + return s.observer.RecordExternal(ctx, input) +} + +func buildExternalRecordInput(payment *agg.Payment, event erecon.Event, matchedStepRef string) (oobs.RecordExternalInput, bool) { + if payment == nil { + return oobs.RecordExternalInput{}, false + } + stepRef := strings.TrimSpace(matchedStepRef) + if stepRef == "" { + if event.Gateway != nil { + stepRef = strings.TrimSpace(event.Gateway.StepRef) + } else if event.Ledger != nil { + stepRef = strings.TrimSpace(event.Ledger.StepRef) + } else if event.Card != nil { + stepRef = strings.TrimSpace(event.Card.StepRef) + } + } + if stepRef == "" { + return oobs.RecordExternalInput{}, false + } + attempt := stepAttempt(payment.StepExecutions, stepRef) + if attempt == 0 { + attempt = 1 + } + + in := oobs.RecordExternalInput{ + PaymentRef: payment.PaymentRef, + StepRef: stepRef, + Attempt: attempt, + } + switch { + case event.Gateway != nil: + in.Source = oobs.ExternalSourceGateway + in.Status = strings.TrimSpace(string(event.Gateway.Status)) + in.RefKind = erecon.ExternalRefKindOperation + in.Ref = firstNonEmpty(event.Gateway.OperationRef, event.Gateway.TransferRef) + if strings.TrimSpace(event.Gateway.TransferRef) != "" { + in.RefKind = erecon.ExternalRefKindTransfer + } + in.Message = strings.TrimSpace(event.Gateway.FailureMsg) + case event.Ledger != nil: + in.Source = oobs.ExternalSourceLedger + in.Status = strings.TrimSpace(string(event.Ledger.Status)) + in.RefKind = erecon.ExternalRefKindLedger + in.Ref = strings.TrimSpace(event.Ledger.EntryRef) + in.Message = strings.TrimSpace(event.Ledger.FailureMsg) + case event.Card != nil: + in.Source = oobs.ExternalSourceCard + in.Status = strings.TrimSpace(string(event.Card.Status)) + in.RefKind = erecon.ExternalRefKindCardPayout + in.Ref = strings.TrimSpace(event.Card.PayoutRef) + in.Message = strings.TrimSpace(event.Card.FailureMsg) + default: + return oobs.RecordExternalInput{}, false + } + return in, true +} + +func stepAttempt(steps []agg.StepExecution, stepRef string) uint32 { + ref := strings.TrimSpace(stepRef) + for i := range steps { + if strings.TrimSpace(steps[i].StepRef) == ref { + return steps[i].Attempt + } + } + return 0 +} + +func firstNonEmpty(values ...string) string { + for i := range values { + if val := strings.TrimSpace(values[i]); val != "" { + return val + } + } + return "" +} + +func externalEventSource(event erecon.Event) string { + switch { + case event.Gateway != nil: + return "gateway" + case event.Ledger != nil: + return "ledger" + case event.Card != nil: + return "card" + default: + return "unknown" + } +} + +func externalEventStatus(event erecon.Event) string { + switch { + case event.Gateway != nil: + return strings.TrimSpace(string(event.Gateway.Status)) + case event.Ledger != nil: + return strings.TrimSpace(string(event.Ledger.Status)) + case event.Card != nil: + return strings.TrimSpace(string(event.Card.Status)) + default: + return "" + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go new file mode 100644 index 00000000..d3ce501a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go @@ -0,0 +1,72 @@ +package psvc + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/mlogger" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" +) + +// Service orchestrates execute/query/reconcile payment runtime operations. +type Service interface { + ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) + ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) + + ReconcileExternal(ctx context.Context, in ReconcileExternalInput) (*ReconcileExternalOutput, error) +} + +// ReconcileExternalInput is one internal external-event payload. +type ReconcileExternalInput struct { + OrganizationRef string + PaymentRef string + Event erecon.Event +} + +// ReconcileExternalOutput is reconciliation result payload. +type ReconcileExternalOutput struct { + Payment *orchestrationv2.Payment +} + +// Dependencies configures orchestration-v2 runtime modules. +type Dependencies struct { + Logger mlogger.Logger + + QuoteStore qsnap.Store + + Validator reqval.Validator + Idempotency idem.Service + Quote qsnap.Resolver + Aggregate agg.Factory + Planner xplan.Compiler + State ostate.StateMachine + Scheduler ssched.Runtime + Executors sexec.Registry + Reconciler erecon.Reconciler + Repository prepo.Repository + Query pquery.Service + Mapper prmap.Mapper + Observer oobs.Observer + + RetryPolicy ssched.RetryPolicy + Now func() time.Time + MaxTicks int +} + +func New(deps Dependencies) (Service, error) { + return newService(deps) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/query.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/query.go new file mode 100644 index 00000000..361d9920 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/query.go @@ -0,0 +1,166 @@ +package psvc + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" +) + +func (s *svc) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (resp *orchestrationv2.GetPaymentResponse, err error) { + logger := s.logger + orgRef := "" + if req != nil && req.GetMeta() != nil { + orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) + } + logger.Debug("Starting Get payment", + zap.String("organization_ref", orgRef), + zap.String("payment_ref", strings.TrimSpace(req.GetPaymentRef())), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if resp != nil && resp.Payment != nil { + fields = append(fields, + zap.String("state", resp.Payment.GetState().String()), + zap.Uint64("version", resp.Payment.GetVersion()), + ) + } + if err != nil { + logger.Warn("Failed to get payment", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Get payment", fields...) + }(time.Now()) + + _, orgID, err := parseOrganization(req.GetMeta()) + if err != nil { + return nil, err + } + paymentRef, err := parsePaymentRef(req.GetPaymentRef()) + if err != nil { + return nil, err + } + + payment, err := s.query.GetPayment(ctx, pquery.GetPaymentInput{ + OrganizationRef: orgID, + PaymentRef: paymentRef, + }) + if err != nil { + return nil, err + } + protoPayment, err := s.mapPayment(payment) + if err != nil { + return nil, err + } + resp = &orchestrationv2.GetPaymentResponse{Payment: protoPayment} + return resp, nil +} + +func (s *svc) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (resp *orchestrationv2.ListPaymentsResponse, err error) { + logger := s.logger + orgRef := "" + if req != nil && req.GetMeta() != nil { + orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) + } + logger.Debug("Starting List payments", + zap.String("organization_ref", orgRef), + zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())), + zap.Int("states_count", len(req.GetStates())), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if resp != nil { + fields = append(fields, zap.Int("payments_count", len(resp.GetPayments()))) + } + if err != nil { + logger.Warn("Failed to list payments", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed List payments", fields...) + }(time.Now()) + + _, orgID, err := parseOrganization(req.GetMeta()) + if err != nil { + return nil, err + } + states, err := mapStates(req.GetStates()) + if err != nil { + return nil, err + } + createdFrom, err := parseCreated(req.GetCreatedFrom(), "created_from") + if err != nil { + return nil, err + } + createdTo, err := parseCreated(req.GetCreatedTo(), "created_to") + if err != nil { + return nil, err + } + cursor, err := parseCursor(req.GetPage().GetCursor()) + if err != nil { + return nil, err + } + limit := int32(0) + if req.GetPage() != nil { + limit = req.GetPage().GetLimit() + } + + page, err := s.query.ListPayments(ctx, pquery.ListPaymentsInput{ + OrganizationRef: orgID, + States: states, + QuotationRef: strings.TrimSpace(req.GetQuotationRef()), + CreatedFrom: createdFrom, + CreatedTo: createdTo, + Cursor: cursor, + Limit: limit, + }) + if err != nil { + return nil, err + } + + items, err := s.mapPayments(page.Items) + if err != nil { + return nil, err + } + nextCursor, err := formatCursor(page.NextCursor) + if err != nil { + return nil, err + } + resp = &orchestrationv2.ListPaymentsResponse{ + Payments: items, + Page: &paginationv1.CursorPageResponse{NextCursor: nextCursor}, + } + return resp, nil +} + +func (s *svc) mapPayments(items []*agg.Payment) ([]*orchestrationv2.Payment, error) { + if len(items) == 0 { + return nil, nil + } + out := make([]*orchestrationv2.Payment, 0, len(items)) + for i := range items { + mapped, err := s.mapPayment(items[i]) + if err != nil { + return nil, err + } + out = append(out, mapped) + } + return out, nil +} + +func (s *svc) mapPayment(payment *agg.Payment) (*orchestrationv2.Payment, error) { + mapped, err := s.mapper.Map(prmap.MapInput{Payment: payment}) + if err != nil { + return nil, err + } + if mapped == nil { + return nil, nil + } + return mapped.Payment, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/request_helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/request_helpers.go new file mode 100644 index 00000000..553db795 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/request_helpers.go @@ -0,0 +1,128 @@ +package psvc + +import ( + "encoding/base64" + "encoding/json" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/pkg/merrors" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type listCursorPayload struct { + CreatedAt string `json:"created_at"` + ID string `json:"id"` +} + +func parseOrganization(meta *sharedv1.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("meta.organization_ref is required") + } + orgID, err := bson.ObjectIDFromHex(orgRef) + if err != nil { + return "", bson.NilObjectID, merrors.InvalidArgument("meta.organization_ref must be a valid objectID") + } + return orgRef, orgID, nil +} + +func parsePaymentRef(value string) (string, error) { + ref := strings.TrimSpace(value) + if ref == "" { + return "", merrors.InvalidArgument("payment_ref is required") + } + return ref, nil +} + +func parseCursor(value string) (*prepo.ListCursor, error) { + raw := strings.TrimSpace(value) + if raw == "" { + return nil, nil + } + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, merrors.InvalidArgument("page.cursor is invalid") + } + var payload listCursorPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, merrors.InvalidArgument("page.cursor is invalid") + } + id, err := bson.ObjectIDFromHex(strings.TrimSpace(payload.ID)) + if err != nil { + return nil, merrors.InvalidArgument("page.cursor is invalid") + } + createdAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(payload.CreatedAt)) + if err != nil { + return nil, merrors.InvalidArgument("page.cursor is invalid") + } + return &prepo.ListCursor{ + CreatedAt: createdAt.UTC(), + ID: id, + }, nil +} + +func formatCursor(cursor *prepo.ListCursor) (string, error) { + if cursor == nil || cursor.ID.IsZero() || cursor.CreatedAt.IsZero() { + return "", nil + } + data, err := json.Marshal(listCursorPayload{ + CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano), + ID: cursor.ID.Hex(), + }) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(data), nil +} + +func parseCreated(ts *timestamppb.Timestamp, field string) (*time.Time, error) { + if ts == nil { + return nil, nil + } + if err := ts.CheckValid(); err != nil { + return nil, merrors.InvalidArgument(field + " is invalid") + } + value := ts.AsTime().UTC() + return &value, nil +} + +func mapStates(states []orchestrationv2.OrchestrationState) ([]agg.State, error) { + if len(states) == 0 { + return nil, nil + } + out := make([]agg.State, 0, len(states)) + for i := range states { + state, ok := mapState(states[i]) + if !ok { + return nil, merrors.InvalidArgument("states contains invalid value") + } + out = append(out, state) + } + return out, nil +} + +func mapState(state orchestrationv2.OrchestrationState) (agg.State, bool) { + switch state { + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED: + return agg.StateCreated, true + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING: + return agg.StateExecuting, true + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_NEEDS_ATTENTION: + return agg.StateNeedsAttention, true + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED: + return agg.StateSettled, true + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED: + return agg.StateFailed, true + default: + return agg.StateUnspecified, false + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go new file mode 100644 index 00000000..62a36958 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go @@ -0,0 +1,422 @@ +package psvc + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" +) + +func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Payment, error) { + logger := s.logger + paymentRef := "" + state := agg.StateUnspecified + stepCount := 0 + if payment != nil { + paymentRef = strings.TrimSpace(payment.PaymentRef) + state = payment.State + stepCount = len(payment.StepExecutions) + } + logger.Debug("Starting Run runtime", + zap.String("payment_ref", paymentRef), + zap.String("state", string(state)), + zap.Int("steps_count", stepCount), + ) + + if payment == nil { + return nil, merrors.InvalidArgument("payment is required") + } + if s.state.IsAggregateTerminal(payment.State) { + logger.Debug("psvc.run_runtime.terminal", zap.String("payment_ref", paymentRef), zap.String("state", string(payment.State))) + return payment, nil + } + + current := payment + for tick := 0; tick < s.maxTicks; tick++ { + graph, err := s.compileGraph(current) + if err != nil { + return nil, err + } + + updated, changed, waitOnly, err := s.runTick(ctx, current, graph) + if err != nil { + return nil, err + } + logger.Debug("psvc.run_runtime.tick", + zap.String("payment_ref", paymentRef), + zap.Int("tick", tick), + zap.Bool("changed", changed), + zap.Bool("wait_only", waitOnly), + zap.String("state", string(updated.State)), + zap.Uint64("version", updated.Version), + ) + if !changed { + return updated, nil + } + + if s.state.IsAggregateTerminal(updated.State) { + return updated, nil + } + if waitOnly { + return updated, nil + } + current = updated + } + logger.Debug("psvc.run_runtime.max_ticks_reached", + zap.String("payment_ref", paymentRef), + zap.Int("max_ticks", s.maxTicks), + zap.String("state", string(current.State)), + zap.Uint64("version", current.Version), + ) + return current, nil +} + +func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) (*agg.Payment, bool, bool, error) { + logger := s.logger + expectedVersion := payment.Version + + scheduled, err := s.scheduler.Schedule(ssched.Input{ + Steps: graph.Steps, + StepExecutions: payment.StepExecutions, + Retry: s.retryPolicy, + }) + if err != nil { + return nil, false, false, err + } + logger.Debug("psvc.run_tick.scheduled", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Int("runnable_count", len(scheduled.Runnable)), + zap.Int("blocked_count", len(scheduled.Blocked)), + zap.Int("skipped_count", len(scheduled.Skipped)), + ) + + changed := mergeScheduledExecutions(payment, scheduled.StepExecutions) + if changed { + if err := s.recordScheduleTransitions(ctx, payment, scheduled.StepExecutions); err != nil { + return nil, false, false, err + } + } + + for i := range scheduled.Runnable { + stepChanged, runErr := s.executeRunnable(ctx, payment, graph, scheduled.Runnable[i]) + if runErr != nil { + return nil, false, false, runErr + } + changed = changed || stepChanged + } + + aggChanged, err := s.recomputeAggregateState(ctx, payment) + if err != nil { + return nil, false, false, err + } + changed = changed || aggChanged + if !changed { + return payment, false, len(scheduled.Runnable) == 0, nil + } + + if err := s.repository.UpdateCAS(ctx, payment, expectedVersion); err != nil { + if errors.Is(err, prepo.ErrVersionConflict) { + logger.Debug("psvc.run_tick.cas_conflict", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Uint64("expected_version", expectedVersion), + ) + fresh, reloadErr := s.repository.GetByPaymentRef(ctx, payment.OrganizationRef, payment.PaymentRef) + if reloadErr != nil { + return nil, false, false, reloadErr + } + return fresh, true, false, nil + } + return nil, false, false, err + } + logger.Debug("psvc.run_tick.persisted", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Uint64("version", payment.Version), + zap.String("state", string(payment.State)), + ) + return payment, true, len(scheduled.Runnable) == 0, nil +} + +func (s *svc) compileGraph(payment *agg.Payment) (*xplan.Graph, error) { + return s.planner.Compile(xplan.Input{ + IntentSnapshot: payment.IntentSnapshot, + QuoteSnapshot: payment.QuoteSnapshot, + }) +} + +func mergeScheduledExecutions(payment *agg.Payment, updated []agg.StepExecution) bool { + if payment == nil { + return false + } + if len(updated) == 0 { + return false + } + changed := false + index := stepIndexByRef(payment.StepExecutions) + for i := range updated { + step := updated[i] + idx, ok := index[strings.TrimSpace(step.StepRef)] + if !ok { + continue + } + if !stepExecutionEqual(payment.StepExecutions[idx], step) { + payment.StepExecutions[idx] = step + changed = true + } + } + return changed +} + +func (s *svc) recordScheduleTransitions(ctx context.Context, payment *agg.Payment, current []agg.StepExecution) error { + _ = current + for i := range payment.StepExecutions { + step := payment.StepExecutions[i] + if step.State != agg.StepStateSkipped && step.State != agg.StepStateNeedsAttention { + continue + } + event := oobs.StepEventSkipped + if step.State == agg.StepStateNeedsAttention { + event = oobs.StepEventBlocked + } + if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: step, + Event: event, + }); err != nil { + return err + } + } + return nil +} + +func (s *svc) executeRunnable(ctx context.Context, payment *agg.Payment, graph *xplan.Graph, runnable ssched.RunnableStep) (bool, error) { + logger := s.logger + logger.Debug("Starting Step execution", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(runnable.StepRef)), + zap.String("step_code", strings.TrimSpace(runnable.StepCode)), + zap.Uint32("attempt", runnable.Attempt), + ) + + idx, ok := findStepExecution(payment.StepExecutions, runnable.StepRef) + if !ok { + return false, merrors.InvalidArgument("step execution not found: " + runnable.StepRef) + } + stepExecution := payment.StepExecutions[idx] + stepExecution.Attempt = runnable.Attempt + + if stepExecution.State != agg.StepStateRunning { + if err := s.state.EnsureStepTransition(stepExecution.State, agg.StepStateRunning); err != nil { + stepExecution.State = agg.StepStateNeedsAttention + stepExecution.FailureCode = "step.transition_invalid" + stepExecution.FailureMsg = err.Error() + } else { + stepExecution.State = agg.StepStateRunning + } + now := s.nowUTC() + stepExecution.StartedAt = &now + stepExecution.CompletedAt = nil + if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: stepExecution, + Event: oobs.StepEventStarted, + }); err != nil { + return false, err + } + } + + step, ok := findGraphStep(graph, runnable.StepRef) + if !ok { + return false, merrors.InvalidArgument("graph step not found: " + runnable.StepRef) + } + out, err := s.executors.Execute(ctx, sexec.ExecuteInput{ + Payment: payment, + Step: step, + StepExecution: stepExecution, + }) + if err != nil { + logger.Warn("psvc.step_execution.executor_error", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(runnable.StepRef)), + zap.Uint32("attempt", runnable.Attempt), + zap.Error(err), + ) + failed := markStepFailed(stepExecution, "step.executor_error", err.Error(), s.nowUTC()) + payment.StepExecutions[idx] = failed + if obsErr := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: failed, + Event: oobs.StepEventFailed, + }); obsErr != nil { + return false, obsErr + } + return true, nil + } + + next := normalizeExecutorOutput(stepExecution, out, s.nowUTC()) + payment.StepExecutions[idx] = next + logger.Debug("Completed Step execution", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(next.StepRef)), + zap.String("state", string(next.State)), + zap.Uint32("attempt", next.Attempt), + ) + if next.State == agg.StepStateCompleted || next.State == agg.StepStateFailed || next.State == agg.StepStateNeedsAttention { + event := oobs.StepEventCompleted + if next.State != agg.StepStateCompleted { + event = oobs.StepEventFailed + } + if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: next, + Event: event, + Duration: stepDuration(next), + }); err != nil { + return false, err + } + } + return true, nil +} + +func normalizeExecutorOutput(current agg.StepExecution, out *sexec.ExecuteOutput, now time.Time) agg.StepExecution { + if out == nil { + next := current + next.State = agg.StepStateCompleted + next.CompletedAt = &now + return next + } + next := current + if out.StepExecution.StepRef != "" { + next.StepRef = out.StepExecution.StepRef + } + if out.StepExecution.StepCode != "" { + next.StepCode = out.StepExecution.StepCode + } + if out.StepExecution.Attempt != 0 { + next.Attempt = out.StepExecution.Attempt + } + next.ExternalRefs = out.StepExecution.ExternalRefs + next.FailureCode = strings.TrimSpace(out.StepExecution.FailureCode) + next.FailureMsg = strings.TrimSpace(out.StepExecution.FailureMsg) + + switch out.StepExecution.State { + case agg.StepStateCompleted, agg.StepStateFailed, agg.StepStateNeedsAttention, agg.StepStateSkipped: + next.State = out.StepExecution.State + case agg.StepStateRunning: + next.State = agg.StepStateRunning + default: + if out.Async { + next.State = agg.StepStateRunning + } else { + next.State = agg.StepStateCompleted + } + } + + if next.StartedAt == nil { + next.StartedAt = &now + } + if next.State == agg.StepStateRunning { + next.CompletedAt = nil + } else if next.CompletedAt == nil { + next.CompletedAt = &now + } + return next +} + +func markStepFailed(step agg.StepExecution, code, message string, now time.Time) agg.StepExecution { + step.State = agg.StepStateFailed + step.FailureCode = strings.TrimSpace(code) + step.FailureMsg = strings.TrimSpace(message) + if step.StartedAt == nil { + step.StartedAt = &now + } + step.CompletedAt = &now + return step +} + +func stepDuration(step agg.StepExecution) time.Duration { + if step.StartedAt == nil || step.CompletedAt == nil { + return 0 + } + if step.CompletedAt.Before(*step.StartedAt) { + return 0 + } + return step.CompletedAt.Sub(*step.StartedAt) +} + +func stepIndexByRef(steps []agg.StepExecution) map[string]int { + out := make(map[string]int, len(steps)) + for i := range steps { + out[strings.TrimSpace(steps[i].StepRef)] = i + } + return out +} + +func findStepExecution(steps []agg.StepExecution, stepRef string) (int, bool) { + ref := strings.TrimSpace(stepRef) + for i := range steps { + if strings.TrimSpace(steps[i].StepRef) == ref { + return i, true + } + } + return 0, false +} + +func findGraphStep(graph *xplan.Graph, stepRef string) (xplan.Step, bool) { + if graph == nil { + return xplan.Step{}, false + } + ref := strings.TrimSpace(stepRef) + for i := range graph.Steps { + if strings.TrimSpace(graph.Steps[i].StepRef) == ref { + return graph.Steps[i], true + } + } + return xplan.Step{}, false +} + +func stepExecutionEqual(left, right agg.StepExecution) bool { + if left.StepRef != right.StepRef || left.StepCode != right.StepCode { + return false + } + if left.State != right.State || left.Attempt != right.Attempt { + return false + } + if strings.TrimSpace(left.FailureCode) != strings.TrimSpace(right.FailureCode) { + return false + } + if strings.TrimSpace(left.FailureMsg) != strings.TrimSpace(right.FailureMsg) { + return false + } + if !timePtrEqual(left.StartedAt, right.StartedAt) || !timePtrEqual(left.CompletedAt, right.CompletedAt) { + return false + } + if len(left.ExternalRefs) != len(right.ExternalRefs) { + return false + } + for i := range left.ExternalRefs { + if left.ExternalRefs[i] != right.ExternalRefs[i] { + return false + } + } + return true +} + +func timePtrEqual(left *time.Time, right *time.Time) bool { + if left == nil && right == nil { + return true + } + if left == nil || right == nil { + return false + } + return left.UTC().Equal(right.UTC()) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go new file mode 100644 index 00000000..8089611f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go @@ -0,0 +1,197 @@ +package psvc + +import ( + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" +) + +const ( + defaultMaxTicks = 32 +) + +type svc struct { + logger mlogger.Logger + + quoteStore qsnap.Store + + validator reqval.Validator + idempotency idem.Service + quote qsnap.Resolver + aggregate agg.Factory + planner xplan.Compiler + state ostate.StateMachine + scheduler ssched.Runtime + executors sexec.Registry + reconciler erecon.Reconciler + repository prepo.Repository + query pquery.Service + mapper prmap.Mapper + observer oobs.Observer + + retryPolicy ssched.RetryPolicy + now func() time.Time + maxTicks int +} + +func newService(deps Dependencies) (Service, error) { + if deps.QuoteStore == nil { + return nil, merrors.InvalidArgument("quote store is required") + } + if deps.Repository == nil { + return nil, merrors.InvalidArgument("payment repository v2 is required") + } + + logger := deps.Logger.Named("psvc") + + observer := deps.Observer + if observer == nil { + var err error + observer, err = oobs.New(oobs.Dependencies{Logger: logger.Named("oobs")}) + if err != nil { + return nil, err + } + } + + query := deps.Query + if query == nil { + var err error + query, err = pquery.New(pquery.Dependencies{ + Repository: deps.Repository, + Logger: logger.Named("pquery"), + }) + if err != nil { + return nil, err + } + } + + out := &svc{ + logger: logger, + + quoteStore: deps.QuoteStore, + + validator: firstValidator(deps.Validator, logger.Named("reqval")), + idempotency: firstIdempotency(deps.Idempotency, logger.Named("idem")), + quote: firstQuoteResolver(deps.Quote, logger.Named("qsnap")), + aggregate: firstAggregateFactory(deps.Aggregate, logger.Named("agg")), + planner: firstPlanCompiler(deps.Planner, logger.Named("xplan")), + state: firstStateMachine(deps.State, logger.Named("ostate")), + scheduler: firstScheduler(deps.Scheduler, logger.Named("ssched")), + executors: firstExecutors(deps.Executors, logger.Named("sexec")), + reconciler: firstReconciler(deps.Reconciler, logger.Named("erecon")), + repository: deps.Repository, + query: query, + mapper: firstMapper(deps.Mapper, logger.Named("prmap")), + observer: observer, + + retryPolicy: deps.RetryPolicy, + now: deps.Now, + maxTicks: deps.MaxTicks, + } + if out.now == nil { + out.now = func() time.Time { return time.Now().UTC() } + } + if out.maxTicks <= 0 { + out.maxTicks = defaultMaxTicks + } + if out.retryPolicy.MaxAttempts == 0 { + out.retryPolicy.MaxAttempts = 2 + } + return out, nil +} + +func firstValidator(v reqval.Validator, logger mlogger.Logger) reqval.Validator { + if v != nil { + return v + } + return reqval.New(reqval.Dependencies{Logger: logger}) +} + +func firstIdempotency(v idem.Service, logger mlogger.Logger) idem.Service { + if v != nil { + return v + } + return idem.New(idem.Dependencies{Logger: logger}) +} + +func firstQuoteResolver(v qsnap.Resolver, logger mlogger.Logger) qsnap.Resolver { + if v != nil { + return v + } + return qsnap.New(qsnap.Dependencies{Logger: logger}) +} + +func firstAggregateFactory(v agg.Factory, logger mlogger.Logger) agg.Factory { + if v != nil { + return v + } + return agg.New(agg.Dependencies{Logger: logger}) +} + +func firstPlanCompiler(v xplan.Compiler, logger mlogger.Logger) xplan.Compiler { + if v != nil { + return v + } + return xplan.New(xplan.Dependencies{Logger: logger}) +} + +func firstStateMachine(v ostate.StateMachine, logger mlogger.Logger) ostate.StateMachine { + if v != nil { + return v + } + return ostate.New(ostate.Dependencies{Logger: logger}) +} + +func firstScheduler(v ssched.Runtime, logger mlogger.Logger) ssched.Runtime { + if v != nil { + return v + } + return ssched.New(ssched.Dependencies{Logger: logger}) +} + +func firstExecutors(v sexec.Registry, logger mlogger.Logger) sexec.Registry { + if v != nil { + return v + } + return sexec.New(sexec.Dependencies{ + Logger: logger, + Ledger: defaultLedgerExecutor{}, + Crypto: defaultCryptoExecutor{}, + ProviderSettlement: defaultProviderSettlementExecutor{}, + CardPayout: defaultCardPayoutExecutor{}, + ObserveConfirm: defaultObserveConfirmExecutor{}, + }) +} + +func firstReconciler(v erecon.Reconciler, logger mlogger.Logger) erecon.Reconciler { + if v != nil { + return v + } + return erecon.New(erecon.Dependencies{Logger: logger}) +} + +func firstMapper(v prmap.Mapper, logger mlogger.Logger) prmap.Mapper { + if v != nil { + return v + } + return prmap.New(prmap.Dependencies{Logger: logger}) +} + +func (s *svc) nowUTC() time.Time { + return s.now().UTC() +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go new file mode 100644 index 00000000..02b1efa5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -0,0 +1,670 @@ +package psvc + +import ( + "bytes" + "context" + "errors" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/merrors" + pm "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestExecutePayment_EndToEndSyncSettled(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-sync", "intent-sync", buildLedgerRoute())) + + resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-sync"), + QuotationRef: "quote-sync", + ClientPaymentRef: "client-1", + IntentRef: "intent-sync", + }) + if err != nil { + t.Fatalf("ExecutePayment returned error: %v", err) + } + if resp.GetPayment() == nil { + t.Fatal("expected payment in response") + } + if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got, want) + } + + getResp, err := env.svc.GetPayment(context.Background(), &orchestrationv2.GetPaymentRequest{ + Meta: testMeta(env.orgID, ""), + PaymentRef: resp.GetPayment().GetPaymentRef(), + }) + if err != nil { + t.Fatalf("GetPayment returned error: %v", err) + } + if getResp.GetPayment() == nil { + t.Fatal("expected payment from GetPayment") + } + if got, want := getResp.GetPayment().GetPaymentRef(), resp.GetPayment().GetPaymentRef(); got != want { + t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want) + } + + timeline, err := env.observer.PaymentTimeline(context.Background(), oobs.PaymentTimelineInput{ + PaymentRef: resp.GetPayment().GetPaymentRef(), + }) + if err != nil { + t.Fatalf("PaymentTimeline returned error: %v", err) + } + assertTimelineHasEvent(t, timeline.Items, "created") + assertTimelineHasEvent(t, timeline.Items, "settled") +} + +func TestExecutePayment_IdempotencyMismatch(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-idem", "intent-idem", buildLedgerRoute())) + + _, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-shared"), + QuotationRef: "quote-idem", + ClientPaymentRef: "client-a", + IntentRef: "intent-idem", + }) + if err != nil { + t.Fatalf("first ExecutePayment returned error: %v", err) + } + + _, err = env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-shared"), + QuotationRef: "quote-idem", + ClientPaymentRef: "client-b", + IntentRef: "intent-idem", + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for mismatch, got %v", err) + } +} + +func TestExecutePayment_RetryThenSuccess(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if req.StepExecution.Attempt == 1 { + return nil, errors.New("temporary ledger failure") + } + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-retry", "intent-retry", buildLedgerRoute())) + + resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-retry"), + QuotationRef: "quote-retry", + ClientPaymentRef: "client-retry", + IntentRef: "intent-retry", + }) + if err != nil { + t.Fatalf("ExecutePayment returned error: %v", err) + } + if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got, want) + } + if len(resp.GetPayment().GetStepExecutions()) != 1 { + t.Fatalf("expected one step execution, got=%d", len(resp.GetPayment().GetStepExecutions())) + } + if got, want := resp.GetPayment().GetStepExecutions()[0].GetAttempt(), uint32(2); got != want { + t.Fatalf("attempt mismatch: got=%d want=%d", got, want) + } +} + +func TestReconcileExternal_AdvancesAsyncPaymentToSettled(t *testing.T) { + var observeStepRef string + env := newTestEnv(t, func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + switch kind { + case "card_payout": + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + case "observe_confirm": + step.State = agg.StepStateRunning + step.ExternalRefs = append(step.ExternalRefs, agg.ExternalRef{ + GatewayInstanceID: "gw-card", + Kind: erecon.ExternalRefKindOperation, + Ref: "op-1", + }) + observeStepRef = step.StepRef + return &sexec.ExecuteOutput{StepExecution: step, Async: true}, nil + default: + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + } + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-async", "intent-async", buildCardRoute())) + + resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-async"), + QuotationRef: "quote-async", + ClientPaymentRef: "client-async", + IntentRef: "intent-async", + }) + if err != nil { + t.Fatalf("ExecutePayment returned error: %v", err) + } + if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING; got != want { + t.Fatalf("expected executing before external reconcile, got=%s", got) + } + if observeStepRef == "" { + t.Fatal("expected observe step ref to be captured") + } + + reconciled, err := env.svc.ReconcileExternal(context.Background(), ReconcileExternalInput{ + OrganizationRef: env.orgID.Hex(), + PaymentRef: resp.GetPayment().GetPaymentRef(), + Event: erecon.Event{ + Gateway: &erecon.GatewayEvent{ + StepRef: observeStepRef, + OperationRef: "op-1", + Status: erecon.GatewayStatusSuccess, + }, + }, + }) + if err != nil { + t.Fatalf("ReconcileExternal returned error: %v", err) + } + if reconciled == nil || reconciled.Payment == nil { + t.Fatal("expected reconciled payment") + } + if got, want := reconciled.Payment.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch after reconcile: got=%s want=%s", got, want) + } +} + +func TestListPayments_FiltersAndCursor(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + base := time.Date(2026, time.February, 20, 10, 0, 0, 0, time.UTC) + + mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-1", "idem-1", "quote-list", agg.StateExecuting, base.Add(3*time.Minute))) + mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-2", "idem-2", "quote-list", agg.StateSettled, base.Add(2*time.Minute))) + mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-3", "idem-3", "quote-list", agg.StateFailed, base.Add(1*time.Minute))) + mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-4", "idem-4", "quote-other", agg.StateExecuting, base.Add(4*time.Minute))) + + first, err := env.svc.ListPayments(context.Background(), &orchestrationv2.ListPaymentsRequest{ + Meta: testMeta(env.orgID, ""), + States: []orchestrationv2.OrchestrationState{orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED}, + QuotationRef: "quote-list", + Page: &paginationv1.CursorPageRequest{Limit: 1}, + }) + if err != nil { + t.Fatalf("ListPayments(first) returned error: %v", err) + } + if len(first.GetPayments()) != 1 { + t.Fatalf("expected one payment in first page, got=%d", len(first.GetPayments())) + } + if got, want := first.GetPayments()[0].GetPaymentRef(), "p-1"; got != want { + t.Fatalf("first page payment mismatch: got=%q want=%q", got, want) + } + if strings.TrimSpace(first.GetPage().GetNextCursor()) == "" { + t.Fatal("expected next cursor") + } + + second, err := env.svc.ListPayments(context.Background(), &orchestrationv2.ListPaymentsRequest{ + Meta: testMeta(env.orgID, ""), + States: []orchestrationv2.OrchestrationState{orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED}, + QuotationRef: "quote-list", + Page: &paginationv1.CursorPageRequest{ + Limit: 2, + Cursor: first.GetPage().GetNextCursor(), + }, + }) + if err != nil { + t.Fatalf("ListPayments(second) returned error: %v", err) + } + if len(second.GetPayments()) != 1 { + t.Fatalf("expected one payment in second page, got=%d", len(second.GetPayments())) + } + if got, want := second.GetPayments()[0].GetPaymentRef(), "p-2"; got != want { + t.Fatalf("second page payment mismatch: got=%q want=%q", got, want) + } +} + +func assertTimelineHasEvent(t *testing.T, items []oobs.TimelineEntry, event string) { + t.Helper() + for i := range items { + if items[i].Event == event { + return + } + } + t.Fatalf("timeline missing event %q", event) +} + +type testEnv struct { + svc Service + repo *memoryRepo + quotes *memoryQuoteStore + observer oobs.Observer + orgID bson.ObjectID +} + +func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error)) *testEnv { + t.Helper() + repo := newMemoryRepo(func() time.Time { + return time.Now().UTC() + }) + quotes := newMemoryQuoteStore() + observer, err := oobs.New(oobs.Dependencies{}) + if err != nil { + t.Fatalf("oobs.New returned error: %v", err) + } + + script := &scriptedExecutors{handler: handler} + registry := sexec.New(sexec.Dependencies{ + Ledger: script, + Crypto: script, + ProviderSettlement: script, + CardPayout: script, + ObserveConfirm: script, + }) + + svc, err := New(Dependencies{ + QuoteStore: quotes, + Repository: repo, + Executors: registry, + Observer: observer, + RetryPolicy: ssched.RetryPolicy{MaxAttempts: 2}, + MaxTicks: 20, + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + return &testEnv{ + svc: svc, + repo: repo, + quotes: quotes, + observer: observer, + orgID: bson.NewObjectID(), + } +} + +type scriptedExecutors struct { + handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) +} + +func (s *scriptedExecutors) ExecuteLedger(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("ledger", req) +} +func (s *scriptedExecutors) ExecuteCrypto(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("crypto", req) +} +func (s *scriptedExecutors) ExecuteProviderSettlement(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("provider_settlement", req) +} +func (s *scriptedExecutors) ExecuteCardPayout(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("card_payout", req) +} +func (s *scriptedExecutors) ExecuteObserveConfirm(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("observe_confirm", req) +} + +type memoryQuoteStore struct { + mu sync.Mutex + data map[string]*model.PaymentQuoteRecord +} + +func newMemoryQuoteStore() *memoryQuoteStore { + return &memoryQuoteStore{data: map[string]*model.PaymentQuoteRecord{}} +} + +func (s *memoryQuoteStore) Put(record *model.PaymentQuoteRecord) { + s.mu.Lock() + defer s.mu.Unlock() + s.data[quoteKey(record.OrganizationRef, record.QuoteRef)] = cloneQuoteRecord(record) +} + +func (s *memoryQuoteStore) GetByRef(_ context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + record := s.data[quoteKey(orgRef, quoteRef)] + if record == nil { + return nil, quotestorage.ErrQuoteNotFound + } + return cloneQuoteRecord(record), nil +} + +func quoteKey(orgRef bson.ObjectID, quoteRef string) string { + return orgRef.Hex() + "|" + strings.TrimSpace(quoteRef) +} + +func cloneQuoteRecord(in *model.PaymentQuoteRecord) *model.PaymentQuoteRecord { + if in == nil { + return nil + } + data, _ := bson.Marshal(in) + out := &model.PaymentQuoteRecord{} + _ = bson.Unmarshal(data, out) + return out +} + +type memoryRepo struct { + mu sync.Mutex + now func() time.Time + + byID map[bson.ObjectID]*agg.Payment + byPaymentRef map[string]bson.ObjectID + byIdempotency map[string]bson.ObjectID +} + +func newMemoryRepo(now func() time.Time) *memoryRepo { + return &memoryRepo{ + now: now, + byID: map[bson.ObjectID]*agg.Payment{}, + byPaymentRef: map[string]bson.ObjectID{}, + byIdempotency: map[string]bson.ObjectID{}, + } +} + +func (r *memoryRepo) Create(_ context.Context, payment *agg.Payment) error { + r.mu.Lock() + defer r.mu.Unlock() + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + if payment.ID.IsZero() { + payment.ID = bson.NewObjectID() + } + if strings.TrimSpace(payment.PaymentRef) == "" { + payment.PaymentRef = payment.ID.Hex() + } + if payment.CreatedAt.IsZero() { + payment.CreatedAt = r.now().UTC() + } + payment.UpdatedAt = payment.CreatedAt + if payment.Version == 0 { + payment.Version = 1 + } + + refKey := repoPaymentRefKey(payment.OrganizationRef, payment.PaymentRef) + if _, exists := r.byPaymentRef[refKey]; exists { + return prepo.ErrDuplicatePayment + } + idemKey := repoIdemKey(payment.OrganizationRef, payment.IdempotencyKey) + if _, exists := r.byIdempotency[idemKey]; exists { + return prepo.ErrDuplicatePayment + } + cloned := clonePayment(payment) + r.byID[cloned.ID] = cloned + r.byPaymentRef[refKey] = cloned.ID + r.byIdempotency[idemKey] = cloned.ID + *payment = *clonePayment(cloned) + return nil +} + +func (r *memoryRepo) UpdateCAS(_ context.Context, payment *agg.Payment, expectedVersion uint64) error { + r.mu.Lock() + defer r.mu.Unlock() + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + stored := r.byID[payment.ID] + if stored == nil { + return prepo.ErrPaymentNotFound + } + if stored.OrganizationRef != payment.OrganizationRef { + return prepo.ErrPaymentNotFound + } + if stored.Version != expectedVersion { + return prepo.ErrVersionConflict + } + next := clonePayment(payment) + next.Version = expectedVersion + 1 + next.UpdatedAt = r.now().UTC() + r.byID[next.ID] = next + *payment = *clonePayment(next) + return nil +} + +func (r *memoryRepo) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, paymentRef string) (*agg.Payment, error) { + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.byPaymentRef[repoPaymentRefKey(orgRef, paymentRef)] + if !ok { + return nil, prepo.ErrPaymentNotFound + } + return clonePayment(r.byID[id]), nil +} + +func (r *memoryRepo) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*agg.Payment, error) { + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.byIdempotency[repoIdemKey(orgRef, idempotencyKey)] + if !ok { + return nil, prepo.ErrPaymentNotFound + } + return clonePayment(r.byID[id]), nil +} + +func (r *memoryRepo) ListByQuotationRef(_ context.Context, in prepo.ListByQuotationRefInput) (*prepo.ListOutput, error) { + r.mu.Lock() + defer r.mu.Unlock() + items := make([]*agg.Payment, 0) + for _, payment := range r.byID { + if payment.OrganizationRef != in.OrganizationRef { + continue + } + if payment.QuotationRef != in.QuotationRef { + continue + } + if !isBeforeCursor(payment, in.Cursor) { + continue + } + items = append(items, clonePayment(payment)) + } + return paginatePayments(items, in.Limit), nil +} + +func (r *memoryRepo) ListByState(_ context.Context, in prepo.ListByStateInput) (*prepo.ListOutput, error) { + r.mu.Lock() + defer r.mu.Unlock() + items := make([]*agg.Payment, 0) + for _, payment := range r.byID { + if payment.OrganizationRef != in.OrganizationRef { + continue + } + if payment.State != in.State { + continue + } + if !isBeforeCursor(payment, in.Cursor) { + continue + } + items = append(items, clonePayment(payment)) + } + return paginatePayments(items, in.Limit), nil +} + +func repoPaymentRefKey(orgRef bson.ObjectID, paymentRef string) string { + return orgRef.Hex() + "|" + strings.TrimSpace(paymentRef) +} + +func repoIdemKey(orgRef bson.ObjectID, key string) string { + return orgRef.Hex() + "|" + strings.TrimSpace(key) +} + +func clonePayment(in *agg.Payment) *agg.Payment { + if in == nil { + return nil + } + data, _ := bson.Marshal(in) + out := &agg.Payment{} + _ = bson.Unmarshal(data, out) + return out +} + +func isBeforeCursor(payment *agg.Payment, cursor *prepo.ListCursor) bool { + if cursor == nil { + return true + } + if payment.CreatedAt.Before(cursor.CreatedAt) { + return true + } + if payment.CreatedAt.After(cursor.CreatedAt) { + return false + } + return bytes.Compare(payment.ID[:], cursor.ID[:]) < 0 +} + +func paginatePayments(items []*agg.Payment, limit int32) *prepo.ListOutput { + sort.Slice(items, func(i, j int) bool { + left := items[i] + right := items[j] + if !left.CreatedAt.Equal(right.CreatedAt) { + return left.CreatedAt.After(right.CreatedAt) + } + return bytes.Compare(left.ID[:], right.ID[:]) > 0 + }) + if limit <= 0 { + limit = 50 + } + max := int(limit) + if max > len(items) { + max = len(items) + } + page := items[:max] + out := &prepo.ListOutput{Items: page} + if len(items) > max && max > 0 { + last := page[len(page)-1] + out.NextCursor = &prepo.ListCursor{ + CreatedAt: last.CreatedAt.UTC(), + ID: last.ID, + } + } + return out +} + +func newExecutableQuote(orgRef bson.ObjectID, quoteRef, intentRef string, route *paymenttypes.QuoteRouteSpecification) *model.PaymentQuoteRecord { + now := time.Now().UTC() + return &model.PaymentQuoteRecord{ + Base: modelBase(now), + OrganizationBoundBase: pm.OrganizationBoundBase{ + OrganizationRef: orgRef, + }, + QuoteRef: quoteRef, + Intent: model.PaymentIntent{ + Ref: intentRef, + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: testLedgerEndpoint("ledger-dst"), + Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + SettlementCurrency: "USD", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + Route: route, + }, + StatusV2: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + ExpiresAt: now.Add(1 * time.Hour), + } +} + +func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification { + return &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "LEDGER"}, + {Index: 20, Rail: "LEDGER"}, + }, + } +} + +func buildCardRoute() *paymenttypes.QuoteRouteSpecification { + return &paymenttypes.QuoteRouteSpecification{ + Rail: "CARD_PAYOUT", + Provider: "gw-card", + Network: "visa", + } +} + +func testMeta(orgRef bson.ObjectID, idempotencyKey string) *sharedv1.RequestMeta { + meta := &sharedv1.RequestMeta{OrganizationRef: orgRef.Hex()} + if strings.TrimSpace(idempotencyKey) != "" { + meta.Trace = &tracev1.TraceContext{IdempotencyKey: idempotencyKey} + } + return meta +} + +func modelBase(at time.Time) storable.Base { + return storable.Base{ + ID: bson.NewObjectID(), + CreatedAt: at.UTC(), + UpdatedAt: at.UTC(), + } +} + +func testPayment(orgRef bson.ObjectID, paymentRef, idem, quoteRef string, state agg.State, createdAt time.Time) *agg.Payment { + return &agg.Payment{ + Base: modelBase(createdAt), + OrganizationBoundBase: pm.OrganizationBoundBase{ + OrganizationRef: orgRef, + }, + PaymentRef: paymentRef, + IdempotencyKey: idem, + QuotationRef: quoteRef, + ClientPaymentRef: "client-" + paymentRef, + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-" + paymentRef, + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: testLedgerEndpoint("ledger-dst"), + Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + SettlementCurrency: "USD", + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + Route: buildLedgerRoute(), + }, + State: state, + Version: 1, + StepExecutions: []agg.StepExecution{ + {StepRef: "step-1", StepCode: "edge.10_20.ledger.move", State: agg.StepStateCompleted, Attempt: 1}, + }, + } +} + +func mustCreatePayment(t *testing.T, repo prepo.Repository, payment *agg.Payment) { + t.Helper() + if err := repo.Create(context.Background(), payment); err != nil { + t.Fatalf("Create returned error: %v", err) + } +} + +func testLedgerEndpoint(account string) model.PaymentEndpoint { + return model.PaymentEndpoint{ + Type: model.EndpointTypeLedger, + Ledger: &model.LedgerEndpoint{ + LedgerAccountRef: strings.TrimSpace(account), + }, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/fake_store_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/fake_store_test.go new file mode 100644 index 00000000..3b792ec0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/fake_store_test.go @@ -0,0 +1,20 @@ +package qsnap + +import ( + "context" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type fakeStore struct { + getByRefFn func(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) +} + +func (f *fakeStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { + if f.getByRefFn == nil { + return nil, quotestorage.ErrQuoteNotFound + } + return f.getByRefFn(ctx, orgRef, quoteRef) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index 655d54b2..3b9082f0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -5,6 +5,7 @@ import ( "time" "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -33,8 +34,23 @@ type Output struct { QuoteSnapshot *model.PaymentQuoteSnapshot } -func New() Resolver { +// Dependencies configures quote resolver integrations. +type Dependencies struct { + Logger mlogger.Logger + Now func() time.Time +} + +func New(deps ...Dependencies) Resolver { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + now := dep.Now + if now == nil { + now = time.Now + } return &svc{ - now: time.Now, + logger: dep.Logger.Named("qsnap"), + now: now, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go new file mode 100644 index 00000000..1ce56463 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go @@ -0,0 +1,189 @@ +package qsnap + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestResolve_NotFound(t *testing.T) { + resolver := New() + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return nil, quotestorage.ErrQuoteNotFound + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + }) + if !errors.Is(err, ErrQuoteNotFound) { + t.Fatalf("expected ErrQuoteNotFound, got %v", err) + } +} + +func TestResolve_Expired(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resolver := &svc{ + now: func() time.Time { return now }, + } + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + QuoteRef: "quote-ref", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + }, + Quote: &model.PaymentQuoteSnapshot{}, + StatusV2: &model.QuoteStatusV2{ + State: model.QuoteStateExecutable, + }, + ExpiresAt: now.Add(-time.Second), + }, nil + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + }) + if !errors.Is(err, ErrQuoteExpired) { + t.Fatalf("expected ErrQuoteExpired, got %v", err) + } +} + +func TestResolve_NotExecutableState(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resolver := &svc{ + now: func() time.Time { return now }, + } + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + QuoteRef: "quote-ref", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + }, + Quote: &model.PaymentQuoteSnapshot{}, + StatusV2: &model.QuoteStatusV2{ + State: model.QuoteStateBlocked, + BlockReason: model.QuoteBlockReasonRouteUnavailable, + }, + ExpiresAt: now.Add(time.Minute), + }, nil + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + }) + if !errors.Is(err, ErrQuoteNotExecutable) { + t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) + } +} + +func TestResolve_NotExecutableExecutionNote(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resolver := &svc{ + now: func() time.Time { return now }, + } + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + QuoteRef: "quote-ref", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + }, + Quote: &model.PaymentQuoteSnapshot{}, + ExecutionNote: "quote will not be executed", + ExpiresAt: now.Add(time.Minute), + }, nil + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + }) + if !errors.Is(err, ErrQuoteNotExecutable) { + t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) + } +} + +func TestResolve_ShapeMismatch(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resolver := &svc{ + now: func() time.Time { return now }, + } + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + QuoteRef: "quote-ref", + Intents: []model.PaymentIntent{ + {Kind: model.PaymentKindPayout}, + {Kind: model.PaymentKindPayout}, + }, + Quotes: []*model.PaymentQuoteSnapshot{ + {}, + }, + ExpiresAt: now.Add(time.Minute), + }, nil + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + IntentRef: "intent-1", + }) + if !errors.Is(err, ErrQuoteShapeMismatch) { + t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err) + } +} + +func TestResolve_InputValidation(t *testing.T) { + resolver := New() + orgID := bson.NewObjectID() + + tests := []struct { + name string + store Store + in Input + }{ + { + name: "nil store", + store: nil, + in: Input{ + OrganizationID: orgID, + QuotationRef: "quote-ref", + }, + }, + { + name: "empty org id", + store: &fakeStore{}, + in: Input{ + QuotationRef: "quote-ref", + }, + }, + { + name: "empty quotation ref", + store: &fakeStore{}, + in: Input{ + OrganizationID: orgID, + QuotationRef: " ", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + _, err := resolver.Resolve(context.Background(), tt.store, tt.in) + if err == nil { + t.Fatal("expected error") + } + }) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go similarity index 58% rename from api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go rename to api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go index eb63e11c..5341566b 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -244,191 +243,3 @@ func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) { t.Fatalf("expected ErrIntentRefNotFound, got %v", err) } } - -func TestResolve_NotFound(t *testing.T) { - resolver := New() - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return nil, quotestorage.ErrQuoteNotFound - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrQuoteNotFound) { - t.Fatalf("expected ErrQuoteNotFound, got %v", err) - } -} - -func TestResolve_Expired(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, - }, - Quote: &model.PaymentQuoteSnapshot{}, - StatusV2: &model.QuoteStatusV2{ - State: model.QuoteStateExecutable, - }, - ExpiresAt: now.Add(-time.Second), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrQuoteExpired) { - t.Fatalf("expected ErrQuoteExpired, got %v", err) - } -} - -func TestResolve_NotExecutableState(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, - }, - Quote: &model.PaymentQuoteSnapshot{}, - StatusV2: &model.QuoteStatusV2{ - State: model.QuoteStateBlocked, - BlockReason: model.QuoteBlockReasonRouteUnavailable, - }, - ExpiresAt: now.Add(time.Minute), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrQuoteNotExecutable) { - t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) - } -} - -func TestResolve_NotExecutableExecutionNote(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, - }, - Quote: &model.PaymentQuoteSnapshot{}, - ExecutionNote: "quote will not be executed", - ExpiresAt: now.Add(time.Minute), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrQuoteNotExecutable) { - t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) - } -} - -func TestResolve_ShapeMismatch(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intents: []model.PaymentIntent{ - {Kind: model.PaymentKindPayout}, - {Kind: model.PaymentKindPayout}, - }, - Quotes: []*model.PaymentQuoteSnapshot{ - {}, - }, - ExpiresAt: now.Add(time.Minute), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - IntentRef: "intent-1", - }) - if !errors.Is(err, ErrQuoteShapeMismatch) { - t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err) - } -} - -func TestResolve_InputValidation(t *testing.T) { - resolver := New() - orgID := bson.NewObjectID() - - tests := []struct { - name string - store Store - in Input - }{ - { - name: "nil store", - store: nil, - in: Input{ - OrganizationID: orgID, - QuotationRef: "quote-ref", - }, - }, - { - name: "empty org id", - store: &fakeStore{}, - in: Input{ - QuotationRef: "quote-ref", - }, - }, - { - name: "empty quotation ref", - store: &fakeStore{}, - in: Input{ - OrganizationID: orgID, - QuotationRef: " ", - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - _, err := resolver.Resolve(context.Background(), tt.store, tt.in) - if err == nil { - t.Fatal("expected error") - } - }) - } -} - -type fakeStore struct { - getByRefFn func(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) -} - -func (f *fakeStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { - if f.getByRefFn == nil { - return nil, quotestorage.ErrQuoteNotFound - } - return f.getByRefFn(ctx, orgRef, quoteRef) -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go index cf5168fc..91c56a2c 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go @@ -3,18 +3,21 @@ package qsnap import ( "context" "errors" - "fmt" "strings" "time" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr" "github.com/tech/sendico/payments/storage/model" quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) type svc struct { - now func() time.Time + logger mlogger.Logger + now func() time.Time } type resolvedQuoteItem struct { @@ -27,7 +30,28 @@ func (s *svc) Resolve( ctx context.Context, store Store, in Input, -) (*Output, error) { +) (out *Output, err error) { + logger := s.logger + logger.Debug("Starting Resolve", + zap.String("organization_ref", in.OrganizationID.Hex()), + zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), + zap.String("intent_ref", strings.TrimSpace(in.IntentRef)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.String("quotation_ref", strings.TrimSpace(out.QuotationRef)), + zap.String("intent_ref", strings.TrimSpace(out.IntentRef)), + ) + } + if err != nil { + logger.Warn("Failed to resolve", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Resolve", fields...) + }(time.Now()) + if store == nil { return nil, merrors.InvalidArgument("quotes store is required") } @@ -69,12 +93,13 @@ func (s *svc) Resolve( item.Quote.QuoteRef = outputRef } - return &Output{ + out = &Output{ QuotationRef: outputRef, IntentRef: firstNonEmpty(strings.TrimSpace(item.Intent.Ref), intentRef), IntentSnapshot: item.Intent, QuoteSnapshot: item.Quote, - }, nil + } + return out, nil } func ensureExecutable( @@ -90,7 +115,7 @@ func ensureExecutable( } if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return fmt.Errorf("%w: %s", ErrQuoteNotExecutable, note) + return xerr.Wrapf(ErrQuoteNotExecutable, "%s", note) } if status == nil { @@ -106,23 +131,23 @@ func ensureExecutable( case model.QuoteStateBlocked: reason := strings.TrimSpace(string(status.BlockReason)) if reason != "" && reason != string(model.QuoteBlockReasonUnspecified) { - return fmt.Errorf("%w: blocked (%s)", ErrQuoteNotExecutable, reason) + return xerr.Wrapf(ErrQuoteNotExecutable, "blocked (%s)", reason) } - return fmt.Errorf("%w: blocked", ErrQuoteNotExecutable) + return xerr.Wrapf(ErrQuoteNotExecutable, "blocked") case model.QuoteStateIndicative: - return fmt.Errorf("%w: indicative", ErrQuoteNotExecutable) + return xerr.Wrapf(ErrQuoteNotExecutable, "indicative") default: state := strings.TrimSpace(string(status.State)) if state == "" { - return fmt.Errorf("%w: unspecified status", ErrQuoteNotExecutable) + return xerr.Wrapf(ErrQuoteNotExecutable, "unspecified status") } - return fmt.Errorf("%w: state=%s", ErrQuoteNotExecutable, state) + return xerr.Wrapf(ErrQuoteNotExecutable, "state=%s", state) } } func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { if record == nil { - return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil") } hasArrayShape := len(record.Intents) > 0 || len(record.Quotes) > 0 || len(record.StatusesV2) > 0 @@ -134,19 +159,19 @@ func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*res func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { if record == nil { - return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil") } if record.Quote == nil { - return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is empty") } if isEmptyIntentSnapshot(record.Intent) { - return nil, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intent snapshot is empty") } if intentRef != "" { recordIntentRef := strings.TrimSpace(record.Intent.Ref) if recordIntentRef == "" || recordIntentRef != intentRef { - return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) + return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) } } @@ -168,16 +193,16 @@ func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { if len(record.Intents) == 0 { - return nil, fmt.Errorf("%w: intents are empty", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents are empty") } if len(record.Quotes) == 0 { - return nil, fmt.Errorf("%w: quotes are empty", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quotes are empty") } if len(record.Intents) != len(record.Quotes) { - return nil, fmt.Errorf("%w: intents and quotes count mismatch", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents and quotes count mismatch") } if len(record.StatusesV2) > 0 && len(record.StatusesV2) != len(record.Quotes) { - return nil, fmt.Errorf("%w: statuses and quotes count mismatch", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "statuses and quotes count mismatch") } index := 0 @@ -187,18 +212,18 @@ func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) ( } selected, found := findIntentIndex(record.Intents, intentRef) if !found { - return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) + return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) } index = selected } else if intentRef != "" { if strings.TrimSpace(record.Intents[0].Ref) != intentRef { - return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) + return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) } } quoteSnapshot := record.Quotes[index] if quoteSnapshot == nil { - return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is nil") } intentSnapshot, err := cloneIntentSnapshot(record.Intents[index]) @@ -213,7 +238,7 @@ func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) ( var statusSnapshot *model.QuoteStatusV2 if len(record.StatusesV2) > 0 { if record.StatusesV2[index] == nil { - return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil") } statusSnapshot = record.StatusesV2[index] } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go index 9909033d..2bef45c4 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go @@ -1,6 +1,9 @@ package reqval -import "go.mongodb.org/mongo-driver/v2/bson" +import ( + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" +) // Validator validates execute-payment inputs and returns a normalized context. type Validator interface { @@ -37,6 +40,15 @@ type Ctx struct { ClientPaymentRef string } -func New() Validator { - return &svc{} +// Dependencies configures request validator integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) Validator { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return &svc{logger: dep.Logger.Named("reqval")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go index f801a6c6..cf323994 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go @@ -3,9 +3,12 @@ package reqval import ( "regexp" "strings" + "time" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) const ( @@ -17,9 +20,39 @@ const ( var refTokenRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]*$`) -type svc struct{} +type svc struct { + logger mlogger.Logger +} + +func (s *svc) Validate(req *Req) (out *Ctx, err error) { + logger := s.logger + orgRefIn := "" + if req != nil && req.Meta != nil { + orgRefIn = strings.TrimSpace(req.Meta.OrganizationRef) + } + logger.Debug("Starting Validate", + zap.String("organization_ref", orgRefIn), + zap.String("quotation_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.QuotationRef }))), + zap.String("intent_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.IntentRef }))), + zap.Bool("has_idempotency_key", strings.TrimSpace(traceKey(req)) != ""), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.String("organization_ref", out.OrganizationRef), + zap.String("quotation_ref", out.QuotationRef), + zap.String("intent_ref", out.IntentRef), + zap.Bool("has_client_payment_ref", out.ClientPaymentRef != ""), + ) + } + if err != nil { + logger.Warn("Failed to validate", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Validate", fields...) + }(time.Now()) -func (s *svc) Validate(req *Req) (*Ctx, error) { if req == nil { return nil, merrors.InvalidArgument("request is required") } @@ -60,14 +93,15 @@ func (s *svc) Validate(req *Req) (*Ctx, error) { return nil, err } - return &Ctx{ + out = &Ctx{ OrganizationRef: orgRef, OrganizationID: orgID, IdempotencyKey: idempotencyKey, QuotationRef: quotationRef, IntentRef: intentRef, ClientPaymentRef: clientPaymentRef, - }, nil + } + return out, nil } func validateRefToken(field, value string, maxLen int, required bool) (string, error) { @@ -86,3 +120,17 @@ func validateRefToken(field, value string, maxLen int, required bool) (string, e } return normalized, nil } + +func valueOrEmpty(req *Req, getter func(*Req) string) string { + if req == nil || getter == nil { + return "" + } + return getter(req) +} + +func traceKey(req *Req) string { + if req == nil || req.Meta == nil || req.Meta.Trace == nil { + return "" + } + return req.Meta.Trace.IdempotencyKey +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/errors.go new file mode 100644 index 00000000..24e0d4fb --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/errors.go @@ -0,0 +1,8 @@ +package sexec + +import "errors" + +var ( + ErrMissingExecutor = errors.New("missing executor") + ErrUnsupportedStep = errors.New("unsupported step") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go new file mode 100644 index 00000000..f7efc8ba --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go @@ -0,0 +1,77 @@ +package sexec + +import ( + "context" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/mlogger" +) + +// Registry dispatches orchestration steps to rail/action-specific executors. +type Registry interface { + Execute(ctx context.Context, in ExecuteInput) (*ExecuteOutput, error) +} + +// ExecuteInput is the step-execution payload. +type ExecuteInput struct { + Payment *agg.Payment + Step xplan.Step + StepExecution agg.StepExecution +} + +// ExecuteOutput is the executor result for one step. +type ExecuteOutput struct { + StepExecution agg.StepExecution + Async bool +} + +// StepRequest is the normalized request passed to concrete executors. +type StepRequest struct { + Payment *agg.Payment + Step xplan.Step + StepExecution agg.StepExecution +} + +// LedgerExecutor handles ledger-bound actions. +type LedgerExecutor interface { + ExecuteLedger(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// CryptoExecutor handles crypto rail SEND/FEE actions. +type CryptoExecutor interface { + ExecuteCrypto(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// ProviderSettlementExecutor handles provider settlement SEND actions. +type ProviderSettlementExecutor interface { + ExecuteProviderSettlement(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// CardPayoutExecutor handles card payout SEND actions. +type CardPayoutExecutor interface { + ExecuteCardPayout(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// ObserveConfirmExecutor handles OBSERVE_CONFIRM actions. +type ObserveConfirmExecutor interface { + ExecuteObserveConfirm(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// Dependencies defines concrete executors used by the registry. +type Dependencies struct { + Logger mlogger.Logger + Ledger LedgerExecutor + Crypto CryptoExecutor + ProviderSettlement ProviderSettlementExecutor + CardPayout CardPayoutExecutor + ObserveConfirm ObserveConfirmExecutor +} + +func New(deps Dependencies) Registry { + return &svc{ + logger: deps.Logger.Named("sexec"), + deps: deps, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go new file mode 100644 index 00000000..a05a0018 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go @@ -0,0 +1,112 @@ +package sexec + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" +) + +type route int + +const ( + routeUnknown route = iota + 1 + routeLedger + routeCrypto + routeProviderSettlement + routeCardPayout + routeObserveConfirm +) + +func classifyRoute(step xplan.Step) route { + action := normalizeAction(step.Action) + rail := normalizeRail(step.Rail) + + switch action { + case model.RailOperationObserveConfirm: + return routeObserveConfirm + case model.RailOperationSend: + switch rail { + case model.RailCrypto: + return routeCrypto + case model.RailProviderSettlement: + return routeProviderSettlement + case model.RailCardPayout: + return routeCardPayout + default: + return routeUnknown + } + case model.RailOperationFee: + if rail == model.RailCrypto { + return routeCrypto + } + return routeUnknown + default: + if isLedgerAction(action) { + return routeLedger + } + return routeUnknown + } +} + +func isLedgerAction(action model.RailOperation) bool { + switch action { + case model.RailOperationDebit, + model.RailOperationCredit, + model.RailOperationExternalDebit, + model.RailOperationExternalCredit, + model.RailOperationMove, + model.RailOperationBlock, + model.RailOperationRelease, + model.RailOperationFXConvert: + return true + default: + return false + } +} + +func normalizeAction(action model.RailOperation) model.RailOperation { + switch strings.ToUpper(strings.TrimSpace(string(action))) { + case string(model.RailOperationDebit): + return model.RailOperationDebit + case string(model.RailOperationCredit): + return model.RailOperationCredit + case string(model.RailOperationExternalDebit): + return model.RailOperationExternalDebit + case string(model.RailOperationExternalCredit): + return model.RailOperationExternalCredit + case string(model.RailOperationMove): + return model.RailOperationMove + case string(model.RailOperationSend): + return model.RailOperationSend + case string(model.RailOperationFee): + return model.RailOperationFee + case string(model.RailOperationObserveConfirm): + return model.RailOperationObserveConfirm + case string(model.RailOperationFXConvert): + return model.RailOperationFXConvert + case string(model.RailOperationBlock): + return model.RailOperationBlock + case string(model.RailOperationRelease): + return model.RailOperationRelease + default: + return model.RailOperationUnspecified + } +} + +func normalizeRail(rail model.Rail) model.Rail { + switch strings.ToUpper(strings.TrimSpace(string(rail))) { + case string(model.RailCrypto): + return model.RailCrypto + case string(model.RailProviderSettlement): + return model.RailProviderSettlement + case string(model.RailLedger): + return model.RailLedger + case string(model.RailCardPayout): + return model.RailCardPayout + case string(model.RailFiatOnRamp): + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go new file mode 100644 index 00000000..d70b7bf5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go @@ -0,0 +1,145 @@ +package sexec + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger + deps Dependencies +} + +func (s *svc) Execute(ctx context.Context, in ExecuteInput) (out *ExecuteOutput, err error) { + logger := s.logger + logger.Debug("Starting Execute", + zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)), + zap.String("step_code", strings.TrimSpace(in.Step.StepCode)), + zap.String("action", strings.TrimSpace(string(in.Step.Action))), + zap.String("rail", strings.TrimSpace(string(in.Step.Rail))), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.String("result_state", string(out.StepExecution.State)), + zap.Uint32("result_attempt", out.StepExecution.Attempt), + zap.Bool("async", out.Async), + ) + } + if err != nil { + logger.Warn("Failed to execute", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Execute", fields...) + }(time.Now()) + + req, err := validateInput(in) + if err != nil { + return nil, err + } + + switch classifyRoute(req.Step) { + case routeLedger: + if s.deps.Ledger == nil { + return nil, missingExecutorError("ledger") + } + out, err = s.deps.Ledger.ExecuteLedger(ctx, req) + return out, err + case routeCrypto: + if s.deps.Crypto == nil { + return nil, missingExecutorError("crypto") + } + out, err = s.deps.Crypto.ExecuteCrypto(ctx, req) + return out, err + case routeProviderSettlement: + if s.deps.ProviderSettlement == nil { + return nil, missingExecutorError("provider_settlement") + } + out, err = s.deps.ProviderSettlement.ExecuteProviderSettlement(ctx, req) + return out, err + case routeCardPayout: + if s.deps.CardPayout == nil { + return nil, missingExecutorError("card_payout") + } + out, err = s.deps.CardPayout.ExecuteCardPayout(ctx, req) + return out, err + case routeObserveConfirm: + if s.deps.ObserveConfirm == nil { + return nil, missingExecutorError("observe_confirm") + } + out, err = s.deps.ObserveConfirm.ExecuteObserveConfirm(ctx, req) + return out, err + default: + return nil, unsupportedStepError(req.Step) + } +} + +func validateInput(in ExecuteInput) (StepRequest, error) { + if in.Payment == nil { + return StepRequest{}, merrors.InvalidArgument("payment is required") + } + + step, err := normalizeStep(in.Step) + if err != nil { + return StepRequest{}, err + } + exec, err := normalizeStepExecution(in.StepExecution, step) + if err != nil { + return StepRequest{}, err + } + + return StepRequest{ + Payment: in.Payment, + Step: step, + StepExecution: exec, + }, nil +} + +func normalizeStep(step xplan.Step) (xplan.Step, error) { + step.StepRef = strings.TrimSpace(step.StepRef) + step.StepCode = strings.TrimSpace(step.StepCode) + if step.StepRef == "" { + return xplan.Step{}, merrors.InvalidArgument("step.step_ref is required") + } + if step.StepCode == "" { + step.StepCode = step.StepRef + } + return step, nil +} + +func normalizeStepExecution(exec agg.StepExecution, step xplan.Step) (agg.StepExecution, error) { + exec.StepRef = strings.TrimSpace(exec.StepRef) + exec.StepCode = strings.TrimSpace(exec.StepCode) + if exec.StepRef == "" { + exec.StepRef = step.StepRef + } + if exec.StepRef != step.StepRef { + return agg.StepExecution{}, merrors.InvalidArgument("step_execution.step_ref must match step.step_ref") + } + if exec.StepCode == "" { + exec.StepCode = step.StepCode + } + if exec.Attempt == 0 { + exec.Attempt = 1 + } + return exec, nil +} + +func missingExecutorError(kind string) error { + return xerr.Wrapf(ErrMissingExecutor, "%s", strings.TrimSpace(kind)) +} + +func unsupportedStepError(step xplan.Step) error { + msg := "action=" + strings.TrimSpace(string(step.Action)) + " rail=" + strings.TrimSpace(string(step.Rail)) + return xerr.Wrapf(ErrUnsupportedStep, "%s", msg) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go new file mode 100644 index 00000000..0d370171 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go @@ -0,0 +1,269 @@ +package sexec + +import ( + "context" + "errors" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func TestExecute_DispatchLedger(t *testing.T) { + ledger := &fakeLedgerExecutor{} + registry := New(Dependencies{Ledger: ledger}) + + out, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{StepRef: "s1", StepCode: "ledger.debit", Action: model.RailOperationDebit, Rail: model.RailLedger}, + StepExecution: agg.StepExecution{}, + }) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if ledger.calls != 1 { + t.Fatalf("expected ledger executor to be called once, got %d", ledger.calls) + } + if got, want := ledger.lastReq.StepExecution.Attempt, uint32(1); got != want { + t.Fatalf("attempt mismatch: got=%d want=%d", got, want) + } + if got, want := ledger.lastReq.StepExecution.StepRef, "s1"; got != want { + t.Fatalf("step_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestExecute_DispatchSendRailsAndObserve(t *testing.T) { + crypto := &fakeCryptoExecutor{} + provider := &fakeProviderSettlementExecutor{} + card := &fakeCardPayoutExecutor{} + observe := &fakeObserveConfirmExecutor{} + registry := New(Dependencies{ + Crypto: crypto, + ProviderSettlement: provider, + CardPayout: card, + ObserveConfirm: observe, + }) + + tests := []struct { + name string + step xplan.Step + wantCalls func(t *testing.T) + }{ + { + name: "send crypto", + step: xplan.Step{ + StepRef: "s1", StepCode: "crypto.send", Action: model.RailOperationSend, Rail: model.RailCrypto, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 1 || provider.calls != 0 || card.calls != 0 || observe.calls != 0 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + { + name: "send provider settlement", + step: xplan.Step{ + StepRef: "s2", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 1 || provider.calls != 1 || card.calls != 0 || observe.calls != 0 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + { + name: "send card payout", + step: xplan.Step{ + StepRef: "s3", StepCode: "card.send", Action: model.RailOperationSend, Rail: model.RailCardPayout, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 1 || provider.calls != 1 || card.calls != 1 || observe.calls != 0 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + { + name: "observe confirm", + step: xplan.Step{ + StepRef: "s4", StepCode: "observe", Action: model.RailOperationObserveConfirm, Rail: model.RailCardPayout, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 1 || provider.calls != 1 || card.calls != 1 || observe.calls != 1 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + { + name: "crypto fee", + step: xplan.Step{ + StepRef: "s5", StepCode: "crypto.fee", Action: model.RailOperationFee, Rail: model.RailCrypto, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 2 || provider.calls != 1 || card.calls != 1 || observe.calls != 1 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: tt.step, + StepExecution: agg.StepExecution{StepRef: tt.step.StepRef, StepCode: tt.step.StepCode, Attempt: 1}, + }) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + tt.wantCalls(t) + }) + } +} + +func TestExecute_UnsupportedStep(t *testing.T) { + registry := New(Dependencies{}) + + _, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{StepRef: "s1", StepCode: "bad.send", Action: model.RailOperationSend, Rail: model.RailLedger}, + StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "bad.send", Attempt: 1}, + }) + if !errors.Is(err, ErrUnsupportedStep) { + t.Fatalf("expected ErrUnsupportedStep, got %v", err) + } +} + +func TestExecute_MissingExecutor(t *testing.T) { + registry := New(Dependencies{}) + + _, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{StepRef: "s1", StepCode: "crypto.send", Action: model.RailOperationSend, Rail: model.RailCrypto}, + StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "crypto.send", Attempt: 1}, + }) + if !errors.Is(err, ErrMissingExecutor) { + t.Fatalf("expected ErrMissingExecutor, got %v", err) + } +} + +func TestExecute_ValidationErrors(t *testing.T) { + ledger := &fakeLedgerExecutor{} + registry := New(Dependencies{Ledger: ledger}) + + tests := []struct { + name string + in ExecuteInput + }{ + { + name: "missing payment", + in: ExecuteInput{ + Step: xplan.Step{StepRef: "s1", Action: model.RailOperationDebit}, + }, + }, + { + name: "missing step ref", + in: ExecuteInput{ + Payment: &agg.Payment{}, + Step: xplan.Step{StepRef: " ", Action: model.RailOperationDebit}, + }, + }, + { + name: "mismatched step execution ref", + in: ExecuteInput{ + Payment: &agg.Payment{}, + Step: xplan.Step{StepRef: "s1", StepCode: "s1", Action: model.RailOperationDebit}, + StepExecution: agg.StepExecution{StepRef: "s2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := registry.Execute(context.Background(), tt.in) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +type fakeLedgerExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeLedgerExecutor) ExecuteLedger(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: false, + }, nil +} + +type fakeCryptoExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeCryptoExecutor) ExecuteCrypto(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: true, + }, nil +} + +type fakeProviderSettlementExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeProviderSettlementExecutor) ExecuteProviderSettlement(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: true, + }, nil +} + +type fakeCardPayoutExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeCardPayoutExecutor) ExecuteCardPayout(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: true, + }, nil +} + +type fakeObserveConfirmExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: true, + }, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go new file mode 100644 index 00000000..9a202502 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go @@ -0,0 +1,271 @@ +package ssched + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" +) + +func (s *svc) prepareInput(in Input) (*preparedInput, error) { + if len(in.Steps) == 0 { + return nil, merrors.InvalidArgument("steps are required") + } + + stepsByRef := make(map[string]xplan.Step, len(in.Steps)) + order := make([]string, 0, len(in.Steps)) + + for i := range in.Steps { + step, err := normalizeGraphStep(in.Steps[i], i) + if err != nil { + return nil, err + } + if _, exists := stepsByRef[step.StepRef]; exists { + return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_ref must be unique") + } + stepsByRef[step.StepRef] = step + order = append(order, step.StepRef) + } + + for i := range order { + step := stepsByRef[order[i]] + for _, dep := range step.DependsOn { + if _, ok := stepsByRef[dep]; !ok { + return nil, merrors.InvalidArgument("step dependency is unknown: " + dep) + } + } + for _, dep := range step.CommitAfter { + if _, ok := stepsByRef[dep]; !ok { + return nil, merrors.InvalidArgument("step commit_after dependency is unknown: " + dep) + } + } + } + + maxAttemptsByRef := buildMaxAttemptsByRef(order, in.Retry) + executionsByRef, err := s.normalizeStepExecutions(in.StepExecutions, stepsByRef, maxAttemptsByRef) + if err != nil { + return nil, err + } + seedMissingExecutions(order, stepsByRef, executionsByRef, maxAttemptsByRef) + + return &preparedInput{ + stepsByRef: stepsByRef, + order: order, + executionsByRef: executionsByRef, + maxAttemptsByRef: maxAttemptsByRef, + }, nil +} + +func normalizeGraphStep(step xplan.Step, index int) (xplan.Step, error) { + step.StepRef = strings.TrimSpace(step.StepRef) + step.StepCode = strings.TrimSpace(step.StepCode) + if step.StepRef == "" { + return xplan.Step{}, merrors.InvalidArgument("steps[" + itoa(index) + "].step_ref is required") + } + if step.StepCode == "" { + step.StepCode = step.StepRef + } + step.DependsOn = normalizeRefList(step.DependsOn) + step.CommitAfter = normalizeRefList(step.CommitAfter) + return step, nil +} + +func normalizeRefList(refs []string) []string { + if len(refs) == 0 { + return nil + } + out := make([]string, 0, len(refs)) + seen := make(map[string]struct{}, len(refs)) + for i := range refs { + ref := strings.TrimSpace(refs[i]) + if ref == "" { + continue + } + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + out = append(out, ref) + } + return out +} + +func buildMaxAttemptsByRef(order []string, retry RetryPolicy) map[string]uint32 { + defaultMax := retry.MaxAttempts + if defaultMax == 0 { + defaultMax = 1 + } + out := make(map[string]uint32, len(order)) + for i := range order { + out[order[i]] = defaultMax + } + for stepRef, maxAttempts := range retry.MaxAttemptsByStepRef { + stepRef = strings.TrimSpace(stepRef) + if stepRef == "" || maxAttempts == 0 { + continue + } + out[stepRef] = maxAttempts + } + return out +} + +func (s *svc) normalizeStepExecutions( + steps []agg.StepExecution, + stepsByRef map[string]xplan.Step, + maxAttemptsByRef map[string]uint32, +) (map[string]*agg.StepExecution, error) { + if len(steps) == 0 { + return map[string]*agg.StepExecution{}, nil + } + + out := make(map[string]*agg.StepExecution, len(steps)) + for i := range steps { + exec, err := s.normalizeStepExecution(steps[i], i) + if err != nil { + return nil, err + } + stepRef := exec.StepRef + if _, ok := stepsByRef[stepRef]; !ok { + return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref is unknown: " + stepRef) + } + if _, exists := out[stepRef]; exists { + return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref must be unique") + } + if exec.Attempt == 0 { + exec.Attempt = 1 + } + if maxAttemptsByRef[stepRef] == 0 { + maxAttemptsByRef[stepRef] = 1 + } + stepCode := strings.TrimSpace(exec.StepCode) + if stepCode == "" { + stepCode = stepsByRef[stepRef].StepCode + } + exec.StepCode = stepCode + cloned := cloneStepExecution(exec) + out[stepRef] = &cloned + } + return out, nil +} + +func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.StepExecution, error) { + exec.StepRef = strings.TrimSpace(exec.StepRef) + exec.StepCode = strings.TrimSpace(exec.StepCode) + exec.FailureCode = strings.TrimSpace(exec.FailureCode) + exec.FailureMsg = strings.TrimSpace(exec.FailureMsg) + exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs) + if exec.StepRef == "" { + return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].step_ref is required") + } + + state, ok := normalizeStepState(exec.State) + if !ok { + return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].state is invalid") + } + exec.State = state + if err := s.stateMachine.EnsureStepTransition(exec.State, exec.State); err != nil { + return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].state is invalid") + } + return exec, nil +} + +func seedMissingExecutions( + order []string, + stepsByRef map[string]xplan.Step, + executionsByRef map[string]*agg.StepExecution, + maxAttemptsByRef map[string]uint32, +) { + for i := range order { + stepRef := order[i] + if _, ok := executionsByRef[stepRef]; ok { + continue + } + step := stepsByRef[stepRef] + attempt := uint32(1) + if maxAttemptsByRef[stepRef] == 0 { + maxAttemptsByRef[stepRef] = 1 + } + executionsByRef[stepRef] = &agg.StepExecution{ + StepRef: step.StepRef, + StepCode: step.StepCode, + State: agg.StepStatePending, + Attempt: attempt, + } + } +} + +func normalizeStepState(state agg.StepState) (agg.StepState, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case "": + return agg.StepStateUnspecified, true + case string(agg.StepStateUnspecified): + return agg.StepStateUnspecified, true + case string(agg.StepStatePending): + return agg.StepStatePending, true + case string(agg.StepStateRunning): + return agg.StepStateRunning, true + case string(agg.StepStateCompleted): + return agg.StepStateCompleted, true + case string(agg.StepStateFailed): + return agg.StepStateFailed, true + case string(agg.StepStateNeedsAttention): + return agg.StepStateNeedsAttention, true + case string(agg.StepStateSkipped): + return agg.StepStateSkipped, true + default: + return agg.StepStateUnspecified, false + } +} + +func cloneStepExecution(exec agg.StepExecution) agg.StepExecution { + out := exec + out.ExternalRefs = cloneExternalRefs(exec.ExternalRefs) + return out +} + +func cloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef { + if len(refs) == 0 { + return nil + } + out := make([]agg.ExternalRef, 0, len(refs)) + for i := range refs { + ref := refs[i] + ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID) + ref.Kind = strings.TrimSpace(ref.Kind) + ref.Ref = strings.TrimSpace(ref.Ref) + out = append(out, ref) + } + return out +} + +func firstNonEmpty(values ...string) string { + for i := range values { + val := strings.TrimSpace(values[i]) + if val != "" { + return val + } + } + return "" +} + +func max(left, right uint32) uint32 { + if left > right { + return left + } + return right +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go new file mode 100644 index 00000000..7197e56b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go @@ -0,0 +1,91 @@ +package ssched + +import ( + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/mlogger" +) + +// Runtime selects runnable orchestration steps and reconciles step runtime states. +type Runtime interface { + Schedule(in Input) (*Output, error) +} + +// Input is the scheduler payload. +type Input struct { + Steps []xplan.Step + StepExecutions []agg.StepExecution + Retry RetryPolicy +} + +// RetryPolicy configures per-step retry limits. +type RetryPolicy struct { + MaxAttempts uint32 + MaxAttemptsByStepRef map[string]uint32 +} + +// RunnableStep is a step selected for execution. +type RunnableStep struct { + StepRef string + StepCode string + Attempt uint32 +} + +// BlockedReason classifies why a step is not runnable. +type BlockedReason string + +const ( + BlockedWaitingDependencies BlockedReason = "waiting_dependencies" + BlockedInProgress BlockedReason = "in_progress" + BlockedNeedsAttention BlockedReason = "needs_attention" + BlockedRetryExhausted BlockedReason = "retry_exhausted" + BlockedDependencyMismatch BlockedReason = "dependency_mismatch" +) + +// BlockedStep is a step that cannot run in the current scheduling tick. +type BlockedStep struct { + StepRef string + StepCode string + Reason BlockedReason +} + +// Output is the scheduler decision for one tick. +type Output struct { + StepExecutions []agg.StepExecution + Runnable []RunnableStep + Blocked []BlockedStep + Skipped []string +} + +// Dependencies configures scheduler integrations. +type Dependencies struct { + Logger mlogger.Logger + StateMachine ostate.StateMachine + Now func() time.Time +} + +func New(deps ...Dependencies) Runtime { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + stateMachine := dep.StateMachine + if stateMachine == nil { + stateMachine = ostate.New(ostate.Dependencies{Logger: dep.Logger.Named("ssched.ostate")}) + } + now := dep.Now + if now == nil { + now = func() time.Time { + return time.Now().UTC() + } + } + return &svc{ + logger: dep.Logger.Named("ssched"), + stateMachine: stateMachine, + now: now, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go new file mode 100644 index 00000000..5f94a778 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go @@ -0,0 +1,324 @@ +package ssched + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger + stateMachine ostate.StateMachine + now func() time.Time +} + +type preparedInput struct { + stepsByRef map[string]xplan.Step + order []string + executionsByRef map[string]*agg.StepExecution + maxAttemptsByRef map[string]uint32 +} + +type gate int + +const ( + gateReady gate = iota + 1 + gateWaiting + gateImpossible +) + +type stepOutcome int + +const ( + outcomeUnknown stepOutcome = iota + 1 + outcomeSuccess + outcomeFailure + outcomeSkipped +) + +func (s *svc) Schedule(in Input) (out *Output, err error) { + logger := s.logger + logger.Debug("Starting Schedule", + zap.Int("steps_count", len(in.Steps)), + zap.Int("step_executions_count", len(in.StepExecutions)), + zap.Uint32("retry_max_attempts", in.Retry.MaxAttempts), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.Int("runnable_count", len(out.Runnable)), + zap.Int("blocked_count", len(out.Blocked)), + zap.Int("skipped_count", len(out.Skipped)), + ) + } + if err != nil { + logger.Warn("Failed to schedule", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Schedule", fields...) + }(time.Now()) + + prep, err := s.prepareInput(in) + if err != nil { + return nil, err + } + + skipped := map[string]struct{}{} + s.reconcileStates(prep, skipped) + + out = &Output{ + StepExecutions: make([]agg.StepExecution, 0, len(prep.order)), + } + for _, stepRef := range prep.order { + step := prep.stepsByRef[stepRef] + exec := prep.executionsByRef[stepRef] + if exec == nil { + return nil, merrors.InvalidArgument("execution is required for step_ref " + stepRef) + } + + switch exec.State { + case agg.StepStatePending: + switch evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) { + case gateReady: + out.Runnable = append(out.Runnable, RunnableStep{ + StepRef: exec.StepRef, + StepCode: firstNonEmpty(exec.StepCode, step.StepCode), + Attempt: max(exec.Attempt, 1), + }) + case gateWaiting: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedWaitingDependencies)) + default: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedDependencyMismatch)) + } + + case agg.StepStateFailed: + maxAttempts := prep.maxAttemptsByRef[stepRef] + if exec.Attempt >= maxAttempts { + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedRetryExhausted)) + break + } + switch evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) { + case gateReady: + out.Runnable = append(out.Runnable, RunnableStep{ + StepRef: exec.StepRef, + StepCode: firstNonEmpty(exec.StepCode, step.StepCode), + Attempt: exec.Attempt + 1, + }) + case gateWaiting: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedWaitingDependencies)) + default: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedDependencyMismatch)) + } + + case agg.StepStateNeedsAttention: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedNeedsAttention)) + + case agg.StepStateRunning: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedInProgress)) + } + + out.StepExecutions = append(out.StepExecutions, cloneStepExecution(*exec)) + } + + if len(skipped) > 0 { + out.Skipped = make([]string, 0, len(skipped)) + for _, stepRef := range prep.order { + if _, ok := skipped[stepRef]; ok { + out.Skipped = append(out.Skipped, stepRef) + } + } + } + + return out, nil +} + +func (s *svc) reconcileStates(prep *preparedInput, skipped map[string]struct{}) { + for pass := 0; pass < len(prep.order)+1; pass++ { + changed := false + for _, stepRef := range prep.order { + step := prep.stepsByRef[stepRef] + exec := prep.executionsByRef[stepRef] + if exec == nil { + continue + } + + if s.promoteRetryExhaustedToNeedsAttention(exec, prep.maxAttemptsByRef[stepRef]) { + changed = true + } + + if s.skipImpossiblePending(step, exec, prep, skipped) { + changed = true + } + } + if !changed { + return + } + } +} + +func (s *svc) promoteRetryExhaustedToNeedsAttention(exec *agg.StepExecution, maxAttempts uint32) bool { + if exec == nil || exec.State != agg.StepStateFailed { + return false + } + if exec.Attempt < maxAttempts { + return false + } + if err := s.stateMachine.EnsureStepTransition(exec.State, agg.StepStateNeedsAttention); err != nil { + return false + } + exec.State = agg.StepStateNeedsAttention + return true +} + +func (s *svc) skipImpossiblePending(step xplan.Step, exec *agg.StepExecution, prep *preparedInput, skipped map[string]struct{}) bool { + if exec == nil || exec.State != agg.StepStatePending { + return false + } + if evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) != gateImpossible { + return false + } + if err := s.stateMachine.EnsureStepTransition(exec.State, agg.StepStateSkipped); err != nil { + return false + } + now := s.now().UTC() + exec.State = agg.StepStateSkipped + exec.FailureCode = "" + exec.FailureMsg = "" + exec.CompletedAt = &now + skipped[exec.StepRef] = struct{}{} + return true +} + +func evaluateGate(step xplan.Step, executionsByRef map[string]*agg.StepExecution, maxAttemptsByRef map[string]uint32) gate { + depOutcomes := make(map[string]stepOutcome, len(step.DependsOn)) + for _, dep := range step.DependsOn { + depExec := executionsByRef[dep] + if depExec == nil { + return gateWaiting + } + outcome := outcomeForStep(depExec, maxAttemptsByRef[dep]) + if outcome == outcomeUnknown { + return gateWaiting + } + depOutcomes[dep] = outcome + } + + policy := normalizeCommitPolicy(step.CommitPolicy) + switch policy { + case model.CommitPolicyAfterSuccess: + return evaluateAll(stepCommitTargets(step), executionsByRef, maxAttemptsByRef, outcomeSuccess) + case model.CommitPolicyAfterFailure: + return evaluateAll(stepCommitTargets(step), executionsByRef, maxAttemptsByRef, outcomeFailure) + case model.CommitPolicyAfterCanceled: + return evaluateTerminal(stepCommitTargets(step), executionsByRef, maxAttemptsByRef) + default: + for _, outcome := range depOutcomes { + if outcome == outcomeFailure { + return gateImpossible + } + } + return gateReady + } +} + +func evaluateAll( + refs []string, + executionsByRef map[string]*agg.StepExecution, + maxAttemptsByRef map[string]uint32, + want stepOutcome, +) gate { + for _, ref := range refs { + exec := executionsByRef[ref] + if exec == nil { + return gateWaiting + } + outcome := outcomeForStep(exec, maxAttemptsByRef[ref]) + if outcome == outcomeUnknown { + return gateWaiting + } + if outcome != want { + return gateImpossible + } + } + return gateReady +} + +func evaluateTerminal(refs []string, executionsByRef map[string]*agg.StepExecution, maxAttemptsByRef map[string]uint32) gate { + for _, ref := range refs { + exec := executionsByRef[ref] + if exec == nil { + return gateWaiting + } + if outcomeForStep(exec, maxAttemptsByRef[ref]) == outcomeUnknown { + return gateWaiting + } + } + return gateReady +} + +func outcomeForStep(exec *agg.StepExecution, maxAttempts uint32) stepOutcome { + if exec == nil { + return outcomeUnknown + } + if maxAttempts == 0 { + maxAttempts = 1 + } + switch exec.State { + case agg.StepStateCompleted: + return outcomeSuccess + case agg.StepStateSkipped: + return outcomeSkipped + case agg.StepStateNeedsAttention: + return outcomeFailure + case agg.StepStateFailed: + if exec.Attempt < maxAttempts { + return outcomeUnknown + } + return outcomeFailure + default: + return outcomeUnknown + } +} + +func stepCommitTargets(step xplan.Step) []string { + if len(step.CommitAfter) > 0 { + return step.CommitAfter + } + return step.DependsOn +} + +func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy { + switch strings.ToUpper(strings.TrimSpace(string(policy))) { + case string(model.CommitPolicyImmediate): + return model.CommitPolicyImmediate + case string(model.CommitPolicyAfterSuccess): + return model.CommitPolicyAfterSuccess + case string(model.CommitPolicyAfterFailure): + return model.CommitPolicyAfterFailure + case string(model.CommitPolicyAfterCanceled): + return model.CommitPolicyAfterCanceled + default: + return model.CommitPolicyUnspecified + } +} + +func blockedStep(exec *agg.StepExecution, step xplan.Step, reason BlockedReason) BlockedStep { + stepCode := step.StepCode + if exec != nil && strings.TrimSpace(exec.StepCode) != "" { + stepCode = exec.StepCode + } + return BlockedStep{ + StepRef: step.StepRef, + StepCode: strings.TrimSpace(stepCode), + Reason: reason, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go new file mode 100644 index 00000000..7128937b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go @@ -0,0 +1,327 @@ +package ssched + +import ( + "errors" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func TestSchedule_LinearFlowPicksFirstRunnable(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("a", nil), + step("b", []string{"a"}), + }, + StepExecutions: []agg.StepExecution{ + exec("a", agg.StepStatePending, 1), + exec("b", agg.StepStatePending, 1), + }, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + assertRunnableRefs(t, out, []string{"a"}) + assertRunnableAttempt(t, out, "a", 1) + assertBlockedReason(t, out, "b", BlockedWaitingDependencies) + if len(out.Skipped) != 0 { + t.Fatalf("expected no skipped steps, got %v", out.Skipped) + } +} + +func TestSchedule_SuccessBranchSkipsFailureBranch(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("observe", nil), + successStep("debit", "observe"), + failureStep("release", "observe"), + }, + StepExecutions: []agg.StepExecution{ + exec("observe", agg.StepStateCompleted, 1), + exec("debit", agg.StepStatePending, 1), + exec("release", agg.StepStatePending, 1), + }, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + assertRunnableRefs(t, out, []string{"debit"}) + assertSkippedRefs(t, out, []string{"release"}) + release := mustExecution(t, out, "release") + if release.State != agg.StepStateSkipped { + t.Fatalf("release state mismatch: got=%q want=%q", release.State, agg.StepStateSkipped) + } + if release.CompletedAt == nil { + t.Fatal("expected skipped step to have completed_at") + } +} + +func TestSchedule_AfterFailureWaitsWhenDependencyCanRetry(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("observe", nil), + failureStep("release", "observe"), + }, + StepExecutions: []agg.StepExecution{ + exec("observe", agg.StepStateFailed, 1), + exec("release", agg.StepStatePending, 1), + }, + Retry: RetryPolicy{MaxAttempts: 2}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + assertRunnableRefs(t, out, []string{"observe"}) + assertRunnableAttempt(t, out, "observe", 2) + assertBlockedReason(t, out, "release", BlockedWaitingDependencies) + if len(out.Skipped) != 0 { + t.Fatalf("expected no skipped steps, got %v", out.Skipped) + } +} + +func TestSchedule_AfterFailureRunsWhenDependencyExhausted(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("observe", nil), + successStep("debit", "observe"), + failureStep("release", "observe"), + }, + StepExecutions: []agg.StepExecution{ + exec("observe", agg.StepStateFailed, 2), + exec("debit", agg.StepStatePending, 1), + exec("release", agg.StepStatePending, 1), + }, + Retry: RetryPolicy{MaxAttempts: 2}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + observe := mustExecution(t, out, "observe") + if observe.State != agg.StepStateNeedsAttention { + t.Fatalf("observe state mismatch: got=%q want=%q", observe.State, agg.StepStateNeedsAttention) + } + assertRunnableRefs(t, out, []string{"release"}) + assertSkippedRefs(t, out, []string{"debit"}) + assertBlockedReason(t, out, "observe", BlockedNeedsAttention) +} + +func TestSchedule_RetryExhaustedPromotesNeedsAttention(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("single", nil), + }, + StepExecutions: []agg.StepExecution{ + exec("single", agg.StepStateFailed, 1), + }, + Retry: RetryPolicy{MaxAttempts: 1}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + single := mustExecution(t, out, "single") + if single.State != agg.StepStateNeedsAttention { + t.Fatalf("single state mismatch: got=%q want=%q", single.State, agg.StepStateNeedsAttention) + } + assertBlockedReason(t, out, "single", BlockedNeedsAttention) + if len(out.Runnable) != 0 { + t.Fatalf("expected no runnable steps, got %d", len(out.Runnable)) + } +} + +func TestSchedule_FailedDependencySkipsImmediateDependents(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("a", nil), + step("b", []string{"a"}), + }, + StepExecutions: []agg.StepExecution{ + exec("a", agg.StepStateFailed, 1), + exec("b", agg.StepStatePending, 1), + }, + Retry: RetryPolicy{MaxAttempts: 1}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + a := mustExecution(t, out, "a") + if a.State != agg.StepStateNeedsAttention { + t.Fatalf("a state mismatch: got=%q want=%q", a.State, agg.StepStateNeedsAttention) + } + assertSkippedRefs(t, out, []string{"b"}) + assertBlockedReason(t, out, "a", BlockedNeedsAttention) +} + +func TestSchedule_ValidationErrors(t *testing.T) { + runtime := New() + + tests := []struct { + name string + in Input + }{ + { + name: "missing steps", + in: Input{ + StepExecutions: []agg.StepExecution{exec("a", agg.StepStatePending, 1)}, + }, + }, + { + name: "step dependency unknown", + in: Input{ + Steps: []xplan.Step{ + step("a", []string{"missing"}), + }, + }, + }, + { + name: "unknown execution state", + in: Input{ + Steps: []xplan.Step{ + step("a", nil), + }, + StepExecutions: []agg.StepExecution{ + {StepRef: "a", StepCode: "a", State: agg.StepState("bad_state"), Attempt: 1}, + }, + }, + }, + { + name: "execution ref unknown in graph", + in: Input{ + Steps: []xplan.Step{ + step("a", nil), + }, + StepExecutions: []agg.StepExecution{ + exec("x", agg.StepStatePending, 1), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runtime.Schedule(tt.in) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +func step(ref string, deps []string) xplan.Step { + return xplan.Step{ + StepRef: ref, + StepCode: ref, + DependsOn: deps, + } +} + +func successStep(ref, dep string) xplan.Step { + return xplan.Step{ + StepRef: ref, + StepCode: ref, + DependsOn: []string{dep}, + CommitPolicy: model.CommitPolicyAfterSuccess, + CommitAfter: []string{dep}, + } +} + +func failureStep(ref, dep string) xplan.Step { + return xplan.Step{ + StepRef: ref, + StepCode: ref, + DependsOn: []string{dep}, + CommitPolicy: model.CommitPolicyAfterFailure, + CommitAfter: []string{dep}, + } +} + +func exec(ref string, state agg.StepState, attempt uint32) agg.StepExecution { + return agg.StepExecution{ + StepRef: ref, + StepCode: ref, + State: state, + Attempt: attempt, + } +} + +func mustExecution(t *testing.T, out *Output, stepRef string) agg.StepExecution { + t.Helper() + for i := range out.StepExecutions { + if out.StepExecutions[i].StepRef == stepRef { + return out.StepExecutions[i] + } + } + t.Fatalf("missing execution for step_ref %q", stepRef) + return agg.StepExecution{} +} + +func assertRunnableRefs(t *testing.T, out *Output, want []string) { + t.Helper() + if len(out.Runnable) != len(want) { + t.Fatalf("runnable count mismatch: got=%d want=%d", len(out.Runnable), len(want)) + } + for i := range want { + if out.Runnable[i].StepRef != want[i] { + t.Fatalf("runnable[%d] mismatch: got=%q want=%q", i, out.Runnable[i].StepRef, want[i]) + } + } +} + +func assertRunnableAttempt(t *testing.T, out *Output, stepRef string, want uint32) { + t.Helper() + for i := range out.Runnable { + if out.Runnable[i].StepRef == stepRef { + if out.Runnable[i].Attempt != want { + t.Fatalf("runnable attempt mismatch for %q: got=%d want=%d", stepRef, out.Runnable[i].Attempt, want) + } + return + } + } + t.Fatalf("runnable step %q not found", stepRef) +} + +func assertBlockedReason(t *testing.T, out *Output, stepRef string, want BlockedReason) { + t.Helper() + for i := range out.Blocked { + if out.Blocked[i].StepRef != stepRef { + continue + } + if out.Blocked[i].Reason != want { + t.Fatalf("blocked reason mismatch for %q: got=%q want=%q", stepRef, out.Blocked[i].Reason, want) + } + return + } + t.Fatalf("blocked step %q not found", stepRef) +} + +func assertSkippedRefs(t *testing.T, out *Output, want []string) { + t.Helper() + if len(out.Skipped) != len(want) { + t.Fatalf("skipped count mismatch: got=%d want=%d", len(out.Skipped), len(want)) + } + for i := range want { + if out.Skipped[i] != want[i] { + t.Fatalf("skipped[%d] mismatch: got=%q want=%q", i, out.Skipped[i], want[i]) + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xerr/wrap.go b/api/payments/orchestrator/internal/service/orchestrationv2/xerr/wrap.go new file mode 100644 index 00000000..e2476417 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xerr/wrap.go @@ -0,0 +1,37 @@ +package xerr + +import ( + "fmt" + "strings" +) + +type wrappedError struct { + base error + msg string +} + +func (e wrappedError) Error() string { + msg := strings.TrimSpace(e.msg) + if e.base == nil { + return msg + } + if msg == "" { + return e.base.Error() + } + return e.base.Error() + ": " + msg +} + +func (e wrappedError) Unwrap() error { + return e.base +} + +func Wrap(base error, msg string) error { + return wrappedError{ + base: base, + msg: strings.TrimSpace(msg), + } +} + +func Wrapf(base error, format string, args ...any) error { + return Wrap(base, fmt.Sprintf(format, args...)) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go similarity index 56% rename from api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go rename to api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go index bff3e3d5..385e8a3e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go @@ -1,11 +1,9 @@ package xplan import ( - "errors" "testing" "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) @@ -233,261 +231,3 @@ func TestCompile_SingleExternalFallback(t *testing.T) { t.Fatalf("observe dependency mismatch: got=%v want=%v", got, want) } } - -func TestCompile_PolicyOverrideByRailPair(t *testing.T) { - compiler := New() - - cardRail := model.RailCardPayout - ledgerRail := model.RailLedger - - graph, err := compiler.Compile(Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Hops: []*paymenttypes.QuoteRouteHop{ - {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, - }, - }, - }, - Policies: []Policy{ - { - ID: "crypto-to-card-override", - Match: EdgeMatch{ - Source: EndpointMatch{Rail: railPtr(model.RailCrypto)}, - Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)}, - }, - Steps: []PolicyStep{ - {Code: "custom.review", Action: model.RailOperationMove, Rail: &ledgerRail}, - {Code: "custom.submit", Action: model.RailOperationSend, Rail: &cardRail, Visibility: model.ReportVisibilityUser}, - }, - Success: []PolicyStep{ - {Code: "custom.finalize", Action: model.RailOperationDebit, Rail: &ledgerRail}, - }, - Failure: []PolicyStep{ - {Code: "custom.release", Action: model.RailOperationRelease, Rail: &ledgerRail}, - }, - }, - }, - }) - if err != nil { - t.Fatalf("Compile returned error: %v", err) - } - - if len(graph.Steps) != 4 { - t.Fatalf("expected 4 steps, got %d", len(graph.Steps)) - } - assertStep(t, graph.Steps[0], "custom.review", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden) - assertStep(t, graph.Steps[1], "custom.submit", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser) - assertStep(t, graph.Steps[2], "custom.finalize", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden) - assertStep(t, graph.Steps[3], "custom.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) - - if graph.Steps[2].CommitPolicy != model.CommitPolicyAfterSuccess { - t.Fatalf("expected custom.finalize AFTER_SUCCESS, got %q", graph.Steps[2].CommitPolicy) - } - if graph.Steps[3].CommitPolicy != model.CommitPolicyAfterFailure { - t.Fatalf("expected custom.release AFTER_FAILURE, got %q", graph.Steps[3].CommitPolicy) - } -} - -func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) { - compiler := New() - cardRail := model.RailCardPayout - - on := true - external := CustodyExternal - - graph, err := compiler.Compile(Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Hops: []*paymenttypes.QuoteRouteHop{ - {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, - }, - }, - }, - Policies: []Policy{ - { - ID: "generic-external", - Enabled: &on, - Priority: 1, - Match: EdgeMatch{ - Source: EndpointMatch{Custody: &external}, - Target: EndpointMatch{Custody: &external}, - }, - Steps: []PolicyStep{{Code: "generic.submit", Action: model.RailOperationSend, Rail: &cardRail}}, - }, - { - ID: "specific-crypto-card", - Enabled: &on, - Priority: 10, - Match: EdgeMatch{ - Source: EndpointMatch{Rail: railPtr(model.RailCrypto), Custody: &external}, - Target: EndpointMatch{Rail: railPtr(model.RailCardPayout), Custody: &external}, - }, - Steps: []PolicyStep{{Code: "specific.submit", Action: model.RailOperationSend, Rail: &cardRail}}, - }, - }, - }) - if err != nil { - t.Fatalf("Compile returned error: %v", err) - } - - if len(graph.Steps) != 1 { - t.Fatalf("expected 1 policy step, got %d", len(graph.Steps)) - } - if got, want := graph.Steps[0].StepCode, "specific.submit"; got != want { - t.Fatalf("expected high-priority specific policy, got %q", got) - } -} - -func TestCompile_IndicativeRejected(t *testing.T) { - compiler := New() - - _, err := compiler.Compile(Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Rail: "CRYPTO", - }, - ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ - Readiness: paymenttypes.QuoteExecutionReadinessIndicative, - }, - }, - }) - if !errors.Is(err, ErrNotExecutable) { - t.Fatalf("expected ErrNotExecutable, got %v", err) - } -} - -func TestCompile_ValidationErrors(t *testing.T) { - compiler := New() - - enabled := true - - tests := []struct { - name string - in Input - }{ - { - name: "missing intent", - in: Input{ - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{Rail: "CRYPTO"}, - }, - }, - }, - { - name: "missing quote", - in: Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - }, - }, - { - name: "missing route", - in: Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{}, - }, - }, - { - name: "unknown hop rail", - in: Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Hops: []*paymenttypes.QuoteRouteHop{{Index: 1, Rail: "UNKNOWN"}}, - }, - }, - }, - }, - { - name: "invalid policy step action", - in: Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Hops: []*paymenttypes.QuoteRouteHop{ - {Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, - }, - }, - }, - Policies: []Policy{ - { - ID: "bad-policy", - Enabled: &enabled, - Priority: 1, - Match: EdgeMatch{ - Source: EndpointMatch{Rail: railPtr(model.RailLedger)}, - Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)}, - }, - Steps: []PolicyStep{ - {Code: "bad.step", Action: model.RailOperationUnspecified}, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - _, err := compiler.Compile(tt.in) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } - }) - } -} - -func assertStep( - t *testing.T, - step Step, - code string, - action model.RailOperation, - rail model.Rail, - visibility model.ReportVisibility, -) { - t.Helper() - if got, want := step.StepCode, code; got != want { - t.Fatalf("step code mismatch: got=%q want=%q", got, want) - } - if got, want := step.Action, action; got != want { - t.Fatalf("step action mismatch: got=%q want=%q", got, want) - } - if got, want := step.Rail, rail; got != want { - t.Fatalf("step rail mismatch: got=%q want=%q", got, want) - } - if got, want := step.Visibility, visibility; got != want { - t.Fatalf("step visibility mismatch: got=%q want=%q", got, want) - } -} - -func testIntent(kind model.PaymentKind) model.PaymentIntent { - return model.PaymentIntent{ - Kind: kind, - Amount: &paymenttypes.Money{ - Amount: "10", - Currency: "USD", - }, - } -} - -func railPtr(v model.Rail) *model.Rail { - return &v -} - -func equalStringSlice(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go new file mode 100644 index 00000000..1d90930a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go @@ -0,0 +1,219 @@ +package xplan + +import ( + "errors" + "testing" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func TestCompile_PolicyOverrideByRailPair(t *testing.T) { + compiler := New() + + cardRail := model.RailCardPayout + ledgerRail := model.RailLedger + + graph, err := compiler.Compile(Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Policies: []Policy{ + { + ID: "crypto-to-card-override", + Match: EdgeMatch{ + Source: EndpointMatch{Rail: railPtr(model.RailCrypto)}, + Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)}, + }, + Steps: []PolicyStep{ + {Code: "custom.review", Action: model.RailOperationMove, Rail: &ledgerRail}, + {Code: "custom.submit", Action: model.RailOperationSend, Rail: &cardRail, Visibility: model.ReportVisibilityUser}, + }, + Success: []PolicyStep{ + {Code: "custom.finalize", Action: model.RailOperationDebit, Rail: &ledgerRail}, + }, + Failure: []PolicyStep{ + {Code: "custom.release", Action: model.RailOperationRelease, Rail: &ledgerRail}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Compile returned error: %v", err) + } + + if len(graph.Steps) != 4 { + t.Fatalf("expected 4 steps, got %d", len(graph.Steps)) + } + assertStep(t, graph.Steps[0], "custom.review", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[1], "custom.submit", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser) + assertStep(t, graph.Steps[2], "custom.finalize", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[3], "custom.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) + + if graph.Steps[2].CommitPolicy != model.CommitPolicyAfterSuccess { + t.Fatalf("expected custom.finalize AFTER_SUCCESS, got %q", graph.Steps[2].CommitPolicy) + } + if graph.Steps[3].CommitPolicy != model.CommitPolicyAfterFailure { + t.Fatalf("expected custom.release AFTER_FAILURE, got %q", graph.Steps[3].CommitPolicy) + } +} + +func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) { + compiler := New() + cardRail := model.RailCardPayout + + on := true + external := CustodyExternal + + graph, err := compiler.Compile(Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Policies: []Policy{ + { + ID: "generic-external", + Enabled: &on, + Priority: 1, + Match: EdgeMatch{ + Source: EndpointMatch{Custody: &external}, + Target: EndpointMatch{Custody: &external}, + }, + Steps: []PolicyStep{{Code: "generic.submit", Action: model.RailOperationSend, Rail: &cardRail}}, + }, + { + ID: "specific-crypto-card", + Enabled: &on, + Priority: 10, + Match: EdgeMatch{ + Source: EndpointMatch{Rail: railPtr(model.RailCrypto), Custody: &external}, + Target: EndpointMatch{Rail: railPtr(model.RailCardPayout), Custody: &external}, + }, + Steps: []PolicyStep{{Code: "specific.submit", Action: model.RailOperationSend, Rail: &cardRail}}, + }, + }, + }) + if err != nil { + t.Fatalf("Compile returned error: %v", err) + } + + if len(graph.Steps) != 1 { + t.Fatalf("expected 1 policy step, got %d", len(graph.Steps)) + } + if got, want := graph.Steps[0].StepCode, "specific.submit"; got != want { + t.Fatalf("expected high-priority specific policy, got %q", got) + } +} + +func TestCompile_IndicativeRejected(t *testing.T) { + compiler := New() + + _, err := compiler.Compile(Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Rail: "CRYPTO", + }, + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: paymenttypes.QuoteExecutionReadinessIndicative, + }, + }, + }) + if !errors.Is(err, ErrNotExecutable) { + t.Fatalf("expected ErrNotExecutable, got %v", err) + } +} + +func TestCompile_ValidationErrors(t *testing.T) { + compiler := New() + + enabled := true + + tests := []struct { + name string + in Input + }{ + { + name: "missing intent", + in: Input{ + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{Rail: "CRYPTO"}, + }, + }, + }, + { + name: "missing quote", + in: Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + }, + }, + { + name: "missing route", + in: Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{}, + }, + }, + { + name: "unknown hop rail", + in: Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{{Index: 1, Rail: "UNKNOWN"}}, + }, + }, + }, + }, + { + name: "invalid policy step action", + in: Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Policies: []Policy{ + { + ID: "bad-policy", + Enabled: &enabled, + Priority: 1, + Match: EdgeMatch{ + Source: EndpointMatch{Rail: railPtr(model.RailLedger)}, + Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)}, + }, + Steps: []PolicyStep{ + {Code: "bad.step", Action: model.RailOperationUnspecified}, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + _, err := compiler.Compile(tt.in) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } + }) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go new file mode 100644 index 00000000..3efcc9a0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go @@ -0,0 +1,117 @@ +package xplan + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func (e *expansion) appendMain(step Step) string { + step = normalizeStep(step) + if len(step.DependsOn) == 0 && strings.TrimSpace(e.lastMainRef) != "" { + step.DependsOn = []string{e.lastMainRef} + } + if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified { + step.CommitAfter = cloneStringSlice(step.DependsOn) + } + step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode)) + if strings.TrimSpace(step.StepCode) == "" { + step.StepCode = step.StepRef + } + e.steps = append(e.steps, step) + e.lastMainRef = step.StepRef + return step.StepRef +} + +func (e *expansion) appendBranch(step Step) string { + step = normalizeStep(step) + if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified { + step.CommitAfter = cloneStringSlice(step.DependsOn) + } + step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode)) + if strings.TrimSpace(step.StepCode) == "" { + step.StepCode = step.StepRef + } + e.steps = append(e.steps, step) + return step.StepRef +} + +func (e *expansion) nextRef(base string) string { + token := sanitizeToken(base) + if token == "" { + token = "step" + } + count := e.refSeq[token] + e.refSeq[token] = count + 1 + if count == 0 { + return token + } + return token + "_" + itoa(count+1) +} + +func normalizeStep(step Step) Step { + step.StepRef = strings.TrimSpace(step.StepRef) + step.StepCode = strings.TrimSpace(step.StepCode) + step.Gateway = strings.TrimSpace(step.Gateway) + step.InstanceID = strings.TrimSpace(step.InstanceID) + step.UserLabel = strings.TrimSpace(step.UserLabel) + step.Visibility = model.NormalizeReportVisibility(step.Visibility) + step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) + step.DependsOn = normalizeStringList(step.DependsOn) + step.CommitAfter = normalizeStringList(step.CommitAfter) + step.Metadata = normalizeMetadata(step.Metadata) + return step +} + +func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy { + switch strings.ToUpper(strings.TrimSpace(string(policy))) { + case string(model.CommitPolicyImmediate): + return model.CommitPolicyImmediate + case string(model.CommitPolicyAfterSuccess): + return model.CommitPolicyAfterSuccess + case string(model.CommitPolicyAfterFailure): + return model.CommitPolicyAfterFailure + case string(model.CommitPolicyAfterCanceled): + return model.CommitPolicyAfterCanceled + default: + return model.CommitPolicyUnspecified + } +} + +func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility { + switch action { + case model.RailOperationSend, model.RailOperationObserveConfirm: + if role == paymenttypes.QuoteRouteHopRoleDestination { + return model.ReportVisibilityUser + } + return model.ReportVisibilityBackoffice + default: + return model.ReportVisibilityHidden + } +} + +func defaultUserLabel( + action model.RailOperation, + rail model.Rail, + role paymenttypes.QuoteRouteHopRole, + kind model.PaymentKind, +) string { + if role != paymenttypes.QuoteRouteHopRoleDestination { + return "" + } + switch action { + case model.RailOperationSend: + if kind == model.PaymentKindPayout && rail == model.RailCardPayout { + return "Card payout submitted" + } + return "Transfer submitted" + case model.RailOperationObserveConfirm: + if kind == model.PaymentKindPayout && rail == model.RailCardPayout { + return "Card payout confirmed" + } + return "Transfer confirmed" + default: + return "" + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/helpers.go new file mode 100644 index 00000000..167cbb1c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/helpers.go @@ -0,0 +1,121 @@ +package xplan + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" +) + +func isEmptyIntentSnapshot(intent model.PaymentIntent) bool { + return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func sanitizeToken(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + + var b strings.Builder + prevUnderscore := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevUnderscore = false + continue + } + if !prevUnderscore { + b.WriteByte('_') + prevUnderscore = true + } + } + + return strings.Trim(b.String(), "_") +} + +func normalizeStringList(items []string) []string { + if len(items) == 0 { + return nil + } + seen := make(map[string]struct{}, len(items)) + out := make([]string, 0, len(items)) + for _, item := range items { + token := strings.TrimSpace(item) + if token == "" { + continue + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + out = append(out, token) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeMetadata(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + out := make(map[string]string, len(input)) + for key, value := range input { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneMetadata(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + out := make(map[string]string, len(input)) + for key, value := range input { + out[key] = value + } + return out +} + +func cloneStringSlice(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, len(values)) + copy(out, values) + return out +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + if v < 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go index f2d8411f..f1295002 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go @@ -2,6 +2,7 @@ package xplan import ( "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) @@ -105,6 +106,17 @@ type Policy struct { Failure []PolicyStep `json:"failure,omitempty" bson:"failure,omitempty"` } -func New() Compiler { - return &svc{} +// Dependencies configures execution graph compiler integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) Compiler { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return &svc{ + logger: dep.Logger.Named("xplan"), + } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go new file mode 100644 index 00000000..81bb0346 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go @@ -0,0 +1,182 @@ +package xplan + +import ( + "fmt" + "slices" + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func internalRailForBoundary(from normalizedHop, to normalizedHop) model.Rail { + if isInternalRail(from.rail) { + return from.rail + } + if isInternalRail(to.rail) { + return to.rail + } + return model.RailLedger +} + +func isInternalRail(rail model.Rail) bool { + return rail == model.RailLedger +} + +func isExternalRail(rail model.Rail) bool { + switch rail { + case model.RailCrypto, model.RailProviderSettlement, model.RailCardPayout, model.RailFiatOnRamp: + return true + default: + return false + } +} + +func custodyForRail(rail model.Rail) Custody { + if isInternalRail(rail) { + return CustodyInternal + } + if isExternalRail(rail) { + return CustodyExternal + } + return CustodyUnspecified +} + +func singleHopCode(hop normalizedHop, op string) string { + return fmt.Sprintf("hop.%d.%s.%s", hop.index, railToken(hop.rail), strings.TrimSpace(op)) +} + +func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string) string { + return fmt.Sprintf( + "edge.%d_%d.%s.%s", + from.index, + to.index, + railToken(rail), + strings.TrimSpace(op), + ) +} + +func railToken(rail model.Rail) string { + return strings.ToLower(strings.TrimSpace(string(rail))) +} + +func observedKey(hop normalizedHop) string { + return fmt.Sprintf("%d:%d:%s:%s", hop.pos, hop.index, strings.TrimSpace(string(hop.rail)), hop.instanceID) +} + +func normalizeRouteHops(route *paymenttypes.QuoteRouteSpecification, intent model.PaymentIntent) ([]normalizedHop, error) { + if route == nil { + return nil, merrors.InvalidArgument("quote_snapshot.route is required") + } + + if len(route.Hops) == 0 { + rail := normalizeRail(route.Rail) + if rail == model.RailUnspecified { + return nil, merrors.InvalidArgument("quote_snapshot.route.rail is required") + } + return []normalizedHop{ + { + index: 0, + rail: rail, + gateway: strings.TrimSpace(route.Provider), + instanceID: "", + network: strings.TrimSpace(route.Network), + role: paymenttypes.QuoteRouteHopRoleDestination, + pos: 0, + }, + }, nil + } + + hops := make([]normalizedHop, 0, len(route.Hops)) + for i, hop := range route.Hops { + if hop == nil { + continue + } + + rail := normalizeRail(firstNonEmpty(hop.Rail, route.Rail)) + if rail == model.RailUnspecified { + return nil, merrors.InvalidArgument("quote_snapshot.route.hops[" + itoa(i) + "].rail is required") + } + + hops = append(hops, normalizedHop{ + index: hop.Index, + rail: rail, + gateway: strings.TrimSpace(firstNonEmpty(hop.Gateway, route.Provider)), + instanceID: strings.TrimSpace(hop.InstanceID), + network: strings.TrimSpace(firstNonEmpty(hop.Network, route.Network)), + role: normalizeHopRole(hop.Role, i, len(route.Hops), intent), + pos: i, + }) + } + + if len(hops) == 0 { + return nil, merrors.InvalidArgument("quote_snapshot.route.hops are empty") + } + + slices.SortFunc(hops, func(a, b normalizedHop) int { + switch { + case a.index < b.index: + return -1 + case a.index > b.index: + return 1 + case a.pos < b.pos: + return -1 + case a.pos > b.pos: + return 1 + default: + return 0 + } + }) + + return hops, nil +} + +func normalizeHopRole( + role paymenttypes.QuoteRouteHopRole, + position int, + total int, + _ model.PaymentIntent, +) paymenttypes.QuoteRouteHopRole { + switch role { + case paymenttypes.QuoteRouteHopRoleSource, + paymenttypes.QuoteRouteHopRoleTransit, + paymenttypes.QuoteRouteHopRoleDestination: + return role + } + + if total <= 1 { + return paymenttypes.QuoteRouteHopRoleDestination + } + if position == 0 { + return paymenttypes.QuoteRouteHopRoleSource + } + if position == total-1 { + return paymenttypes.QuoteRouteHopRoleDestination + } + return paymenttypes.QuoteRouteHopRoleTransit +} + +func normalizeRail(raw string) model.Rail { + token := strings.ToUpper(strings.TrimSpace(raw)) + token = strings.ReplaceAll(token, "-", "_") + token = strings.ReplaceAll(token, " ", "_") + for strings.Contains(token, "__") { + token = strings.ReplaceAll(token, "__", "_") + } + + switch token { + case "CRYPTO": + return model.RailCrypto + case "PROVIDER_SETTLEMENT", "PROVIDER": + return model.RailProviderSettlement + case "LEDGER": + return model.RailLedger + case "CARD_PAYOUT", "CARD": + return model.RailCardPayout + case "FIAT_ONRAMP", "FIAT_ON_RAMP": + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go index c1cc2b8b..405b9d9b 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go @@ -1,16 +1,19 @@ package xplan import ( - "fmt" - "slices" "strings" + "time" "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" ) -type svc struct{} +type svc struct { + logger mlogger.Logger +} type normalizedHop struct { index uint32 @@ -36,7 +39,29 @@ func newExpansion() *expansion { } } -func (s *svc) Compile(in Input) (*Graph, error) { +func (s *svc) Compile(in Input) (graph *Graph, err error) { + logger := s.logger + logger.Debug("Starting Compile", + zap.String("intent_ref", strings.TrimSpace(in.IntentSnapshot.Ref)), + zap.String("quote_ref", quoteRef(in.QuoteSnapshot)), + zap.Int("policies_count", len(in.Policies)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if graph != nil { + fields = append(fields, + zap.String("route_ref", strings.TrimSpace(graph.RouteRef)), + zap.String("readiness", strings.TrimSpace(string(graph.Readiness))), + zap.Int("steps_count", len(graph.Steps)), + ) + } + if err != nil { + logger.Warn("Failed to compile", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Compile", fields...) + }(time.Now()) + if isEmptyIntentSnapshot(in.IntentSnapshot) { return nil, merrors.InvalidArgument("intent_snapshot is required") } @@ -91,899 +116,17 @@ func (s *svc) Compile(in Input) (*Graph, error) { return nil, merrors.InvalidArgument("compiled graph is empty") } - return &Graph{ + graph = &Graph{ RouteRef: strings.TrimSpace(in.QuoteSnapshot.Route.RouteRef), Readiness: readiness, Steps: ex.steps, - }, nil + } + return graph, nil } -func (s *svc) expandSingleHop(ex *expansion, hop normalizedHop, intent model.PaymentIntent) error { - if isExternalRail(hop.rail) { - _, err := s.ensureExternalObserved(ex, hop, intent) - return err - } - - switch hop.role { - case paymenttypes.QuoteRouteHopRoleSource: - ex.appendMain(Step{ - StepCode: singleHopCode(hop, "debit"), - Kind: StepKindFundsDebit, - Action: model.RailOperationDebit, - Rail: hop.rail, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: model.ReportVisibilityHidden, - }) - case paymenttypes.QuoteRouteHopRoleDestination: - ex.appendMain(Step{ - StepCode: singleHopCode(hop, "credit"), - Kind: StepKindFundsCredit, - Action: model.RailOperationCredit, - Rail: hop.rail, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: model.ReportVisibilityHidden, - }) - default: - ex.appendMain(Step{ - StepCode: singleHopCode(hop, "move"), - Kind: StepKindFundsMove, - Action: model.RailOperationMove, - Rail: hop.rail, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: model.ReportVisibilityHidden, - }) - } - return nil -} - -func (s *svc) applyDefaultBoundary( - ex *expansion, - from normalizedHop, - to normalizedHop, - intent model.PaymentIntent, -) error { - switch { - case isExternalRail(from.rail) && isInternalRail(to.rail): - if _, err := s.ensureExternalObserved(ex, from, intent); err != nil { - return err - } - ex.appendMain(makeFundsCreditStep(from, to, internalRailForBoundary(from, to))) - return nil - - case isInternalRail(from.rail) && isExternalRail(to.rail): - internalRail := internalRailForBoundary(from, to) - ex.appendMain(makeFundsBlockStep(from, to, internalRail)) - observeRef, err := s.ensureExternalObserved(ex, to, intent) - if err != nil { - return err - } - appendSettlementBranches(ex, from, to, internalRail, observeRef) - return nil - - case isExternalRail(from.rail) && isExternalRail(to.rail): - if _, err := s.ensureExternalObserved(ex, from, intent); err != nil { - return err - } - internalRail := internalRailForBoundary(from, to) - ex.appendMain(makeFundsCreditStep(from, to, internalRail)) - ex.appendMain(makeFundsBlockStep(from, to, internalRail)) - observeRef, err := s.ensureExternalObserved(ex, to, intent) - if err != nil { - return err - } - appendSettlementBranches(ex, from, to, internalRail, observeRef) - return nil - - case isInternalRail(from.rail) && isInternalRail(to.rail): - ex.appendMain(makeFundsMoveStep(from, to, internalRailForBoundary(from, to))) - return nil - - default: - return merrors.InvalidArgument("unsupported rail boundary") - } -} - -func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) { - key := observedKey(hop) - if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" { - return ref, nil - } - - sendStep := makeRailSendStep(hop, intent) - sendRef := ex.appendMain(sendStep) - - observeStep := makeRailObserveStep(hop, intent) - if sendRef != "" { - observeStep.DependsOn = []string{sendRef} - } - observeRef := ex.appendMain(observeStep) - - ex.externalObserved[key] = observeRef - return observeRef, nil -} - -func (s *svc) applyPolicy( - ex *expansion, - policy Policy, - from normalizedHop, - to normalizedHop, - intent model.PaymentIntent, -) error { - if len(policy.Steps) == 0 { - return merrors.InvalidArgument("policy.steps are required") - } - - anchorRef := "" - for i := range policy.Steps { - step, err := policyStepToStep(policy.Steps[i], from, to, intent) - if err != nil { - return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " step[" + itoa(i) + "]: " + err.Error()) - } - anchorRef = ex.appendMain(step) - } - if strings.TrimSpace(anchorRef) == "" { - return merrors.InvalidArgument("policy produced no anchor step") - } - - for i := range policy.Success { - step, err := policyStepToStep(policy.Success[i], from, to, intent) - if err != nil { - return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " success[" + itoa(i) + "]: " + err.Error()) - } - if len(step.DependsOn) == 0 { - step.DependsOn = []string{anchorRef} - } - if len(step.CommitAfter) == 0 { - step.CommitAfter = cloneStringSlice(step.DependsOn) - } - step.CommitPolicy = model.CommitPolicyAfterSuccess - ex.appendBranch(step) - } - - for i := range policy.Failure { - step, err := policyStepToStep(policy.Failure[i], from, to, intent) - if err != nil { - return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " failure[" + itoa(i) + "]: " + err.Error()) - } - if len(step.DependsOn) == 0 { - step.DependsOn = []string{anchorRef} - } - if len(step.CommitAfter) == 0 { - step.CommitAfter = cloneStringSlice(step.DependsOn) - } - step.CommitPolicy = model.CommitPolicyAfterFailure - ex.appendBranch(step) - } - - return nil -} - -func selectPolicy(from normalizedHop, to normalizedHop, policies []Policy) *Policy { - best := -1 - bestPriority := 0 - - for i := range policies { - policy := &policies[i] - if !policyEnabled(*policy) { - continue - } - if !policyMatches(policy.Match, from, to) { - continue - } - - if best == -1 || policy.Priority > bestPriority { - best = i - bestPriority = policy.Priority - } - } - - if best == -1 { - return nil - } - return &policies[best] -} - -func policyEnabled(policy Policy) bool { - if policy.Enabled == nil { - return true - } - return *policy.Enabled -} - -func policyMatches(match EdgeMatch, from normalizedHop, to normalizedHop) bool { - return endpointMatches(match.Source, from) && endpointMatches(match.Target, to) -} - -func endpointMatches(match EndpointMatch, hop normalizedHop) bool { - if match.Rail != nil && normalizeRail(string(*match.Rail)) != hop.rail { - return false - } - if match.Custody != nil && *match.Custody != custodyForRail(hop.rail) { - return false - } - if gateway := strings.TrimSpace(match.Gateway); gateway != "" && !strings.EqualFold(gateway, hop.gateway) { - return false - } - if network := strings.TrimSpace(match.Network); network != "" && !strings.EqualFold(network, hop.network) { - return false - } - if strings.TrimSpace(match.Method) != "" { - // Method-matching is reserved for the next phase once method is passed in intent/route context. - return false - } - return true -} - -func policyStepToStep(spec PolicyStep, from normalizedHop, to normalizedHop, intent model.PaymentIntent) (Step, error) { - code := strings.TrimSpace(spec.Code) - if code == "" { - return Step{}, merrors.InvalidArgument("code is required") - } - - action := normalizeAction(spec.Action) - if action == model.RailOperationUnspecified { - return Step{}, merrors.InvalidArgument("action is required") - } - - rail := inferPolicyRail(spec, action, from, to) - if rail == model.RailUnspecified { - return Step{}, merrors.InvalidArgument("rail could not be inferred") - } - - hopIndex, hopRole, gateway, instanceID := resolveStepContext(rail, action, from, to) - - visibility := model.NormalizeReportVisibility(spec.Visibility) - if visibility == model.ReportVisibilityUnspecified { - visibility = defaultVisibilityForAction(action, hopRole) - } - - userLabel := strings.TrimSpace(spec.UserLabel) - if userLabel == "" && visibility == model.ReportVisibilityUser { - userLabel = defaultUserLabel(action, rail, hopRole, intent.Kind) - } - - return Step{ - StepCode: code, - Kind: kindForAction(action), - Action: action, - DependsOn: cloneStringSlice(spec.DependsOn), - Rail: rail, - Gateway: gateway, - InstanceID: instanceID, - HopIndex: hopIndex, - HopRole: hopRole, - Visibility: visibility, - UserLabel: userLabel, - Metadata: cloneMetadata(spec.Metadata), - }, nil -} - -func normalizeAction(action model.RailOperation) model.RailOperation { - switch strings.ToUpper(strings.TrimSpace(string(action))) { - case string(model.RailOperationDebit): - return model.RailOperationDebit - case string(model.RailOperationCredit): - return model.RailOperationCredit - case string(model.RailOperationExternalDebit): - return model.RailOperationExternalDebit - case string(model.RailOperationExternalCredit): - return model.RailOperationExternalCredit - case string(model.RailOperationMove): - return model.RailOperationMove - case string(model.RailOperationSend): - return model.RailOperationSend - case string(model.RailOperationFee): - return model.RailOperationFee - case string(model.RailOperationObserveConfirm): - return model.RailOperationObserveConfirm - case string(model.RailOperationFXConvert): - return model.RailOperationFXConvert - case string(model.RailOperationBlock): - return model.RailOperationBlock - case string(model.RailOperationRelease): - return model.RailOperationRelease - default: - return model.RailOperationUnspecified - } -} - -func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalizedHop, to normalizedHop) model.Rail { - if spec.Rail != nil { - return normalizeRail(string(*spec.Rail)) - } - - switch action { - case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee: - return to.rail - case model.RailOperationBlock, - model.RailOperationRelease, - model.RailOperationDebit, - model.RailOperationCredit, - model.RailOperationExternalDebit, - model.RailOperationExternalCredit, - model.RailOperationMove: - return internalRailForBoundary(from, to) - default: - return model.RailUnspecified - } -} - -func resolveStepContext( - rail model.Rail, - action model.RailOperation, - from normalizedHop, - to normalizedHop, -) (uint32, paymenttypes.QuoteRouteHopRole, string, string) { - if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) { - return to.index, to.role, to.gateway, to.instanceID - } - if rail == from.rail { - return from.index, from.role, from.gateway, from.instanceID - } - if rail == to.rail { - return to.index, to.role, to.gateway, to.instanceID - } - return to.index, paymenttypes.QuoteRouteHopRoleTransit, "", "" -} - -func kindForAction(action model.RailOperation) StepKind { - switch action { - case model.RailOperationSend: - return StepKindRailSend - case model.RailOperationObserveConfirm: - return StepKindRailObserve - case model.RailOperationCredit, model.RailOperationExternalCredit: - return StepKindFundsCredit - case model.RailOperationDebit, model.RailOperationExternalDebit: - return StepKindFundsDebit - case model.RailOperationMove: - return StepKindFundsMove - case model.RailOperationBlock: - return StepKindFundsBlock - case model.RailOperationRelease: - return StepKindFundsRelease - default: - return StepKindUnspecified - } -} - -func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditions) { - if conditions == nil { - return - } - - if conditions.LiquidityCheckRequiredAtExecution { - ex.appendMain(Step{ - StepCode: "liquidity.check", - Kind: StepKindLiquidityCheck, - Action: model.RailOperationUnspecified, - Rail: model.RailUnspecified, - Visibility: model.ReportVisibilityHidden, - }) - } - - if conditions.PrefundingRequired { - ex.appendMain(Step{ - StepCode: "prefunding.ensure", - Kind: StepKindPrefunding, - Action: model.RailOperationUnspecified, - Rail: model.RailUnspecified, - Visibility: model.ReportVisibilityHidden, - }) - } -} - -func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { - visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role) - userLabel := "" - if visibility == model.ReportVisibilityUser { - userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind) - } - return Step{ - StepCode: singleHopCode(hop, "send"), - Kind: StepKindRailSend, - Action: model.RailOperationSend, - Rail: hop.rail, - Gateway: hop.gateway, - InstanceID: hop.instanceID, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: visibility, - UserLabel: userLabel, - } -} - -func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step { - visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role) - userLabel := "" - if visibility == model.ReportVisibilityUser { - userLabel = defaultUserLabel(model.RailOperationObserveConfirm, hop.rail, hop.role, intent.Kind) - } - return Step{ - StepCode: singleHopCode(hop, "observe"), - Kind: StepKindRailObserve, - Action: model.RailOperationObserveConfirm, - Rail: hop.rail, - Gateway: hop.gateway, - InstanceID: hop.instanceID, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: visibility, - UserLabel: userLabel, - } -} - -func makeFundsCreditStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { - return Step{ - StepCode: edgeCode(from, to, rail, "credit"), - Kind: StepKindFundsCredit, - Action: model.RailOperationCredit, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - } -} - -func makeFundsBlockStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { - return Step{ - StepCode: edgeCode(from, to, rail, "block"), - Kind: StepKindFundsBlock, - Action: model.RailOperationBlock, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - } -} - -func makeFundsMoveStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { - return Step{ - StepCode: edgeCode(from, to, rail, "move"), - Kind: StepKindFundsMove, - Action: model.RailOperationMove, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - } -} - -func appendSettlementBranches( - ex *expansion, - from normalizedHop, - to normalizedHop, - rail model.Rail, - anchorObserveRef string, -) { - if strings.TrimSpace(anchorObserveRef) == "" { - return - } - - successStep := Step{ - StepCode: edgeCode(from, to, rail, "debit"), - Kind: StepKindFundsDebit, - Action: model.RailOperationDebit, - DependsOn: []string{anchorObserveRef}, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - CommitPolicy: model.CommitPolicyAfterSuccess, - CommitAfter: []string{anchorObserveRef}, - Metadata: map[string]string{"mode": "finalize_debit"}, - } - ex.appendBranch(successStep) - - failureStep := Step{ - StepCode: edgeCode(from, to, rail, "release"), - Kind: StepKindFundsRelease, - Action: model.RailOperationRelease, - DependsOn: []string{anchorObserveRef}, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - CommitPolicy: model.CommitPolicyAfterFailure, - CommitAfter: []string{anchorObserveRef}, - Metadata: map[string]string{"mode": "unlock_hold"}, - } - ex.appendBranch(failureStep) -} - -func (e *expansion) appendMain(step Step) string { - step = normalizeStep(step) - if len(step.DependsOn) == 0 && strings.TrimSpace(e.lastMainRef) != "" { - step.DependsOn = []string{e.lastMainRef} - } - if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified { - step.CommitAfter = cloneStringSlice(step.DependsOn) - } - step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode)) - if strings.TrimSpace(step.StepCode) == "" { - step.StepCode = step.StepRef - } - e.steps = append(e.steps, step) - e.lastMainRef = step.StepRef - return step.StepRef -} - -func (e *expansion) appendBranch(step Step) string { - step = normalizeStep(step) - if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified { - step.CommitAfter = cloneStringSlice(step.DependsOn) - } - step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode)) - if strings.TrimSpace(step.StepCode) == "" { - step.StepCode = step.StepRef - } - e.steps = append(e.steps, step) - return step.StepRef -} - -func (e *expansion) nextRef(base string) string { - token := sanitizeToken(base) - if token == "" { - token = "step" - } - count := e.refSeq[token] - e.refSeq[token] = count + 1 - if count == 0 { - return token - } - return token + "_" + itoa(count+1) -} - -func normalizeStep(step Step) Step { - step.StepRef = strings.TrimSpace(step.StepRef) - step.StepCode = strings.TrimSpace(step.StepCode) - step.Gateway = strings.TrimSpace(step.Gateway) - step.InstanceID = strings.TrimSpace(step.InstanceID) - step.UserLabel = strings.TrimSpace(step.UserLabel) - step.Visibility = model.NormalizeReportVisibility(step.Visibility) - step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) - step.DependsOn = normalizeStringList(step.DependsOn) - step.CommitAfter = normalizeStringList(step.CommitAfter) - step.Metadata = normalizeMetadata(step.Metadata) - return step -} - -func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy { - switch strings.ToUpper(strings.TrimSpace(string(policy))) { - case string(model.CommitPolicyImmediate): - return model.CommitPolicyImmediate - case string(model.CommitPolicyAfterSuccess): - return model.CommitPolicyAfterSuccess - case string(model.CommitPolicyAfterFailure): - return model.CommitPolicyAfterFailure - case string(model.CommitPolicyAfterCanceled): - return model.CommitPolicyAfterCanceled - default: - return model.CommitPolicyUnspecified - } -} - -func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility { - switch action { - case model.RailOperationSend, model.RailOperationObserveConfirm: - if role == paymenttypes.QuoteRouteHopRoleDestination { - return model.ReportVisibilityUser - } - return model.ReportVisibilityBackoffice - default: - return model.ReportVisibilityHidden - } -} - -func defaultUserLabel( - action model.RailOperation, - rail model.Rail, - role paymenttypes.QuoteRouteHopRole, - kind model.PaymentKind, -) string { - if role != paymenttypes.QuoteRouteHopRoleDestination { - return "" - } - switch action { - case model.RailOperationSend: - if kind == model.PaymentKindPayout && rail == model.RailCardPayout { - return "Card payout submitted" - } - return "Transfer submitted" - case model.RailOperationObserveConfirm: - if kind == model.PaymentKindPayout && rail == model.RailCardPayout { - return "Card payout confirmed" - } - return "Transfer confirmed" - default: +func quoteRef(snapshot *model.PaymentQuoteSnapshot) string { + if snapshot == nil { return "" } -} - -func internalRailForBoundary(from normalizedHop, to normalizedHop) model.Rail { - if isInternalRail(from.rail) { - return from.rail - } - if isInternalRail(to.rail) { - return to.rail - } - return model.RailLedger -} - -func isInternalRail(rail model.Rail) bool { - return rail == model.RailLedger -} - -func isExternalRail(rail model.Rail) bool { - switch rail { - case model.RailCrypto, model.RailProviderSettlement, model.RailCardPayout, model.RailFiatOnRamp: - return true - default: - return false - } -} - -func custodyForRail(rail model.Rail) Custody { - if isInternalRail(rail) { - return CustodyInternal - } - if isExternalRail(rail) { - return CustodyExternal - } - return CustodyUnspecified -} - -func singleHopCode(hop normalizedHop, op string) string { - return fmt.Sprintf("hop.%d.%s.%s", hop.index, railToken(hop.rail), strings.TrimSpace(op)) -} - -func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string) string { - return fmt.Sprintf( - "edge.%d_%d.%s.%s", - from.index, - to.index, - railToken(rail), - strings.TrimSpace(op), - ) -} - -func railToken(rail model.Rail) string { - return strings.ToLower(strings.TrimSpace(string(rail))) -} - -func observedKey(hop normalizedHop) string { - return fmt.Sprintf("%d:%d:%s:%s", hop.pos, hop.index, strings.TrimSpace(string(hop.rail)), hop.instanceID) -} - -func normalizeRouteHops(route *paymenttypes.QuoteRouteSpecification, intent model.PaymentIntent) ([]normalizedHop, error) { - if route == nil { - return nil, merrors.InvalidArgument("quote_snapshot.route is required") - } - - if len(route.Hops) == 0 { - rail := normalizeRail(route.Rail) - if rail == model.RailUnspecified { - return nil, merrors.InvalidArgument("quote_snapshot.route.rail is required") - } - return []normalizedHop{ - { - index: 0, - rail: rail, - gateway: strings.TrimSpace(route.Provider), - instanceID: "", - network: strings.TrimSpace(route.Network), - role: paymenttypes.QuoteRouteHopRoleDestination, - pos: 0, - }, - }, nil - } - - hops := make([]normalizedHop, 0, len(route.Hops)) - for i, hop := range route.Hops { - if hop == nil { - continue - } - - rail := normalizeRail(firstNonEmpty(hop.Rail, route.Rail)) - if rail == model.RailUnspecified { - return nil, merrors.InvalidArgument("quote_snapshot.route.hops[" + itoa(i) + "].rail is required") - } - - hops = append(hops, normalizedHop{ - index: hop.Index, - rail: rail, - gateway: strings.TrimSpace(firstNonEmpty(hop.Gateway, route.Provider)), - instanceID: strings.TrimSpace(hop.InstanceID), - network: strings.TrimSpace(firstNonEmpty(hop.Network, route.Network)), - role: normalizeHopRole(hop.Role, i, len(route.Hops), intent), - pos: i, - }) - } - - if len(hops) == 0 { - return nil, merrors.InvalidArgument("quote_snapshot.route.hops are empty") - } - - slices.SortFunc(hops, func(a, b normalizedHop) int { - switch { - case a.index < b.index: - return -1 - case a.index > b.index: - return 1 - case a.pos < b.pos: - return -1 - case a.pos > b.pos: - return 1 - default: - return 0 - } - }) - - return hops, nil -} - -func normalizeHopRole( - role paymenttypes.QuoteRouteHopRole, - position int, - total int, - _ model.PaymentIntent, -) paymenttypes.QuoteRouteHopRole { - switch role { - case paymenttypes.QuoteRouteHopRoleSource, - paymenttypes.QuoteRouteHopRoleTransit, - paymenttypes.QuoteRouteHopRoleDestination: - return role - } - - if total <= 1 { - return paymenttypes.QuoteRouteHopRoleDestination - } - if position == 0 { - return paymenttypes.QuoteRouteHopRoleSource - } - if position == total-1 { - return paymenttypes.QuoteRouteHopRoleDestination - } - return paymenttypes.QuoteRouteHopRoleTransit -} - -func normalizeRail(raw string) model.Rail { - token := strings.ToUpper(strings.TrimSpace(raw)) - token = strings.ReplaceAll(token, "-", "_") - token = strings.ReplaceAll(token, " ", "_") - for strings.Contains(token, "__") { - token = strings.ReplaceAll(token, "__", "_") - } - - switch token { - case "CRYPTO": - return model.RailCrypto - case "PROVIDER_SETTLEMENT", "PROVIDER": - return model.RailProviderSettlement - case "LEDGER": - return model.RailLedger - case "CARD_PAYOUT", "CARD": - return model.RailCardPayout - case "FIAT_ONRAMP", "FIAT_ON_RAMP": - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } -} - -func isEmptyIntentSnapshot(intent model.PaymentIntent) bool { - return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func sanitizeToken(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "" - } - - var b strings.Builder - prevUnderscore := false - for _, r := range value { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - prevUnderscore = false - continue - } - if !prevUnderscore { - b.WriteByte('_') - prevUnderscore = true - } - } - - return strings.Trim(b.String(), "_") -} - -func normalizeStringList(items []string) []string { - if len(items) == 0 { - return nil - } - seen := make(map[string]struct{}, len(items)) - out := make([]string, 0, len(items)) - for _, item := range items { - token := strings.TrimSpace(item) - if token == "" { - continue - } - if _, exists := seen[token]; exists { - continue - } - seen[token] = struct{}{} - out = append(out, token) - } - if len(out) == 0 { - return nil - } - return out -} - -func normalizeMetadata(input map[string]string) map[string]string { - if len(input) == 0 { - return nil - } - out := make(map[string]string, len(input)) - for key, value := range input { - k := strings.TrimSpace(key) - if k == "" { - continue - } - out[k] = strings.TrimSpace(value) - } - if len(out) == 0 { - return nil - } - return out -} - -func cloneMetadata(input map[string]string) map[string]string { - if len(input) == 0 { - return nil - } - out := make(map[string]string, len(input)) - for key, value := range input { - out[key] = value - } - return out -} - -func cloneStringSlice(values []string) []string { - if len(values) == 0 { - return nil - } - out := make([]string, len(values)) - copy(out, values) - return out -} - -func itoa(v int) string { - if v == 0 { - return "0" - } - if v < 0 { - return "0" - } - var buf [20]byte - i := len(buf) - for v > 0 { - i-- - buf[i] = byte('0' + v%10) - v /= 10 - } - return string(buf[i:]) + return strings.TrimSpace(snapshot.QuoteRef) } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go new file mode 100644 index 00000000..1318404b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -0,0 +1,260 @@ +package xplan + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func (s *svc) expandSingleHop(ex *expansion, hop normalizedHop, intent model.PaymentIntent) error { + if isExternalRail(hop.rail) { + _, err := s.ensureExternalObserved(ex, hop, intent) + return err + } + + switch hop.role { + case paymenttypes.QuoteRouteHopRoleSource: + ex.appendMain(Step{ + StepCode: singleHopCode(hop, "debit"), + Kind: StepKindFundsDebit, + Action: model.RailOperationDebit, + Rail: hop.rail, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: model.ReportVisibilityHidden, + }) + case paymenttypes.QuoteRouteHopRoleDestination: + ex.appendMain(Step{ + StepCode: singleHopCode(hop, "credit"), + Kind: StepKindFundsCredit, + Action: model.RailOperationCredit, + Rail: hop.rail, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: model.ReportVisibilityHidden, + }) + default: + ex.appendMain(Step{ + StepCode: singleHopCode(hop, "move"), + Kind: StepKindFundsMove, + Action: model.RailOperationMove, + Rail: hop.rail, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: model.ReportVisibilityHidden, + }) + } + return nil +} + +func (s *svc) applyDefaultBoundary( + ex *expansion, + from normalizedHop, + to normalizedHop, + intent model.PaymentIntent, +) error { + switch { + case isExternalRail(from.rail) && isInternalRail(to.rail): + if _, err := s.ensureExternalObserved(ex, from, intent); err != nil { + return err + } + ex.appendMain(makeFundsCreditStep(from, to, internalRailForBoundary(from, to))) + return nil + + case isInternalRail(from.rail) && isExternalRail(to.rail): + internalRail := internalRailForBoundary(from, to) + ex.appendMain(makeFundsBlockStep(from, to, internalRail)) + observeRef, err := s.ensureExternalObserved(ex, to, intent) + if err != nil { + return err + } + appendSettlementBranches(ex, from, to, internalRail, observeRef) + return nil + + case isExternalRail(from.rail) && isExternalRail(to.rail): + if _, err := s.ensureExternalObserved(ex, from, intent); err != nil { + return err + } + internalRail := internalRailForBoundary(from, to) + ex.appendMain(makeFundsCreditStep(from, to, internalRail)) + ex.appendMain(makeFundsBlockStep(from, to, internalRail)) + observeRef, err := s.ensureExternalObserved(ex, to, intent) + if err != nil { + return err + } + appendSettlementBranches(ex, from, to, internalRail, observeRef) + return nil + + case isInternalRail(from.rail) && isInternalRail(to.rail): + ex.appendMain(makeFundsMoveStep(from, to, internalRailForBoundary(from, to))) + return nil + + default: + return merrors.InvalidArgument("unsupported rail boundary") + } +} + +func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) { + key := observedKey(hop) + if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" { + return ref, nil + } + + sendStep := makeRailSendStep(hop, intent) + sendRef := ex.appendMain(sendStep) + + observeStep := makeRailObserveStep(hop, intent) + if sendRef != "" { + observeStep.DependsOn = []string{sendRef} + } + observeRef := ex.appendMain(observeStep) + + ex.externalObserved[key] = observeRef + return observeRef, nil +} + +func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditions) { + if conditions == nil { + return + } + + if conditions.LiquidityCheckRequiredAtExecution { + ex.appendMain(Step{ + StepCode: "liquidity.check", + Kind: StepKindLiquidityCheck, + Action: model.RailOperationUnspecified, + Rail: model.RailUnspecified, + Visibility: model.ReportVisibilityHidden, + }) + } + + if conditions.PrefundingRequired { + ex.appendMain(Step{ + StepCode: "prefunding.ensure", + Kind: StepKindPrefunding, + Action: model.RailOperationUnspecified, + Rail: model.RailUnspecified, + Visibility: model.ReportVisibilityHidden, + }) + } +} + +func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { + visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role) + userLabel := "" + if visibility == model.ReportVisibilityUser { + userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind) + } + return Step{ + StepCode: singleHopCode(hop, "send"), + Kind: StepKindRailSend, + Action: model.RailOperationSend, + Rail: hop.rail, + Gateway: hop.gateway, + InstanceID: hop.instanceID, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: visibility, + UserLabel: userLabel, + } +} + +func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step { + visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role) + userLabel := "" + if visibility == model.ReportVisibilityUser { + userLabel = defaultUserLabel(model.RailOperationObserveConfirm, hop.rail, hop.role, intent.Kind) + } + return Step{ + StepCode: singleHopCode(hop, "observe"), + Kind: StepKindRailObserve, + Action: model.RailOperationObserveConfirm, + Rail: hop.rail, + Gateway: hop.gateway, + InstanceID: hop.instanceID, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: visibility, + UserLabel: userLabel, + } +} + +func makeFundsCreditStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { + return Step{ + StepCode: edgeCode(from, to, rail, "credit"), + Kind: StepKindFundsCredit, + Action: model.RailOperationCredit, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + } +} + +func makeFundsBlockStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { + return Step{ + StepCode: edgeCode(from, to, rail, "block"), + Kind: StepKindFundsBlock, + Action: model.RailOperationBlock, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + } +} + +func makeFundsMoveStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { + return Step{ + StepCode: edgeCode(from, to, rail, "move"), + Kind: StepKindFundsMove, + Action: model.RailOperationMove, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + } +} + +func appendSettlementBranches( + ex *expansion, + from normalizedHop, + to normalizedHop, + rail model.Rail, + anchorObserveRef string, +) { + if strings.TrimSpace(anchorObserveRef) == "" { + return + } + + successStep := Step{ + StepCode: edgeCode(from, to, rail, "debit"), + Kind: StepKindFundsDebit, + Action: model.RailOperationDebit, + DependsOn: []string{anchorObserveRef}, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + CommitPolicy: model.CommitPolicyAfterSuccess, + CommitAfter: []string{anchorObserveRef}, + Metadata: map[string]string{"mode": "finalize_debit"}, + } + ex.appendBranch(successStep) + + failureStep := Step{ + StepCode: edgeCode(from, to, rail, "release"), + Kind: StepKindFundsRelease, + Action: model.RailOperationRelease, + DependsOn: []string{anchorObserveRef}, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + CommitPolicy: model.CommitPolicyAfterFailure, + CommitAfter: []string{anchorObserveRef}, + Metadata: map[string]string{"mode": "unlock_hold"}, + } + ex.appendBranch(failureStep) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go new file mode 100644 index 00000000..f0ba594c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go @@ -0,0 +1,254 @@ +package xplan + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func (s *svc) applyPolicy( + ex *expansion, + policy Policy, + from normalizedHop, + to normalizedHop, + intent model.PaymentIntent, +) error { + if len(policy.Steps) == 0 { + return merrors.InvalidArgument("policy.steps are required") + } + + anchorRef := "" + for i := range policy.Steps { + step, err := policyStepToStep(policy.Steps[i], from, to, intent) + if err != nil { + return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " step[" + itoa(i) + "]: " + err.Error()) + } + anchorRef = ex.appendMain(step) + } + if strings.TrimSpace(anchorRef) == "" { + return merrors.InvalidArgument("policy produced no anchor step") + } + + for i := range policy.Success { + step, err := policyStepToStep(policy.Success[i], from, to, intent) + if err != nil { + return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " success[" + itoa(i) + "]: " + err.Error()) + } + if len(step.DependsOn) == 0 { + step.DependsOn = []string{anchorRef} + } + if len(step.CommitAfter) == 0 { + step.CommitAfter = cloneStringSlice(step.DependsOn) + } + step.CommitPolicy = model.CommitPolicyAfterSuccess + ex.appendBranch(step) + } + + for i := range policy.Failure { + step, err := policyStepToStep(policy.Failure[i], from, to, intent) + if err != nil { + return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " failure[" + itoa(i) + "]: " + err.Error()) + } + if len(step.DependsOn) == 0 { + step.DependsOn = []string{anchorRef} + } + if len(step.CommitAfter) == 0 { + step.CommitAfter = cloneStringSlice(step.DependsOn) + } + step.CommitPolicy = model.CommitPolicyAfterFailure + ex.appendBranch(step) + } + + return nil +} + +func selectPolicy(from normalizedHop, to normalizedHop, policies []Policy) *Policy { + best := -1 + bestPriority := 0 + + for i := range policies { + policy := &policies[i] + if !policyEnabled(*policy) { + continue + } + if !policyMatches(policy.Match, from, to) { + continue + } + + if best == -1 || policy.Priority > bestPriority { + best = i + bestPriority = policy.Priority + } + } + + if best == -1 { + return nil + } + return &policies[best] +} + +func policyEnabled(policy Policy) bool { + if policy.Enabled == nil { + return true + } + return *policy.Enabled +} + +func policyMatches(match EdgeMatch, from normalizedHop, to normalizedHop) bool { + return endpointMatches(match.Source, from) && endpointMatches(match.Target, to) +} + +func endpointMatches(match EndpointMatch, hop normalizedHop) bool { + if match.Rail != nil && normalizeRail(string(*match.Rail)) != hop.rail { + return false + } + if match.Custody != nil && *match.Custody != custodyForRail(hop.rail) { + return false + } + if gateway := strings.TrimSpace(match.Gateway); gateway != "" && !strings.EqualFold(gateway, hop.gateway) { + return false + } + if network := strings.TrimSpace(match.Network); network != "" && !strings.EqualFold(network, hop.network) { + return false + } + if strings.TrimSpace(match.Method) != "" { + // Method-matching is reserved for the next phase once method is passed in intent/route context. + return false + } + return true +} + +func policyStepToStep(spec PolicyStep, from normalizedHop, to normalizedHop, intent model.PaymentIntent) (Step, error) { + code := strings.TrimSpace(spec.Code) + if code == "" { + return Step{}, merrors.InvalidArgument("code is required") + } + + action := normalizeAction(spec.Action) + if action == model.RailOperationUnspecified { + return Step{}, merrors.InvalidArgument("action is required") + } + + rail := inferPolicyRail(spec, action, from, to) + if rail == model.RailUnspecified { + return Step{}, merrors.InvalidArgument("rail could not be inferred") + } + + hopIndex, hopRole, gateway, instanceID := resolveStepContext(rail, action, from, to) + + visibility := model.NormalizeReportVisibility(spec.Visibility) + if visibility == model.ReportVisibilityUnspecified { + visibility = defaultVisibilityForAction(action, hopRole) + } + + userLabel := strings.TrimSpace(spec.UserLabel) + if userLabel == "" && visibility == model.ReportVisibilityUser { + userLabel = defaultUserLabel(action, rail, hopRole, intent.Kind) + } + + return Step{ + StepCode: code, + Kind: kindForAction(action), + Action: action, + DependsOn: cloneStringSlice(spec.DependsOn), + Rail: rail, + Gateway: gateway, + InstanceID: instanceID, + HopIndex: hopIndex, + HopRole: hopRole, + Visibility: visibility, + UserLabel: userLabel, + Metadata: cloneMetadata(spec.Metadata), + }, nil +} + +func normalizeAction(action model.RailOperation) model.RailOperation { + switch strings.ToUpper(strings.TrimSpace(string(action))) { + case string(model.RailOperationDebit): + return model.RailOperationDebit + case string(model.RailOperationCredit): + return model.RailOperationCredit + case string(model.RailOperationExternalDebit): + return model.RailOperationExternalDebit + case string(model.RailOperationExternalCredit): + return model.RailOperationExternalCredit + case string(model.RailOperationMove): + return model.RailOperationMove + case string(model.RailOperationSend): + return model.RailOperationSend + case string(model.RailOperationFee): + return model.RailOperationFee + case string(model.RailOperationObserveConfirm): + return model.RailOperationObserveConfirm + case string(model.RailOperationFXConvert): + return model.RailOperationFXConvert + case string(model.RailOperationBlock): + return model.RailOperationBlock + case string(model.RailOperationRelease): + return model.RailOperationRelease + default: + return model.RailOperationUnspecified + } +} + +func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalizedHop, to normalizedHop) model.Rail { + if spec.Rail != nil { + return normalizeRail(string(*spec.Rail)) + } + + switch action { + case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee: + return to.rail + case model.RailOperationBlock, + model.RailOperationRelease, + model.RailOperationDebit, + model.RailOperationCredit, + model.RailOperationExternalDebit, + model.RailOperationExternalCredit, + model.RailOperationMove: + return internalRailForBoundary(from, to) + default: + return model.RailUnspecified + } +} + +func resolveStepContext( + rail model.Rail, + action model.RailOperation, + from normalizedHop, + to normalizedHop, +) (uint32, paymenttypes.QuoteRouteHopRole, string, string) { + if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) { + return to.index, to.role, to.gateway, to.instanceID + } + if rail == from.rail { + return from.index, from.role, from.gateway, from.instanceID + } + if rail == to.rail { + return to.index, to.role, to.gateway, to.instanceID + } + return to.index, paymenttypes.QuoteRouteHopRoleTransit, "", "" +} + +func kindForAction(action model.RailOperation) StepKind { + switch action { + case model.RailOperationSend: + return StepKindRailSend + case model.RailOperationObserveConfirm: + return StepKindRailObserve + case model.RailOperationCredit, model.RailOperationExternalCredit: + return StepKindFundsCredit + case model.RailOperationDebit, model.RailOperationExternalDebit: + return StepKindFundsDebit + case model.RailOperationMove: + return StepKindFundsMove + case model.RailOperationBlock: + return StepKindFundsBlock + case model.RailOperationRelease: + return StepKindFundsRelease + default: + return StepKindUnspecified + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/test_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/test_helpers_test.go new file mode 100644 index 00000000..01a32834 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/test_helpers_test.go @@ -0,0 +1,57 @@ +package xplan + +import ( + "testing" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func assertStep( + t *testing.T, + step Step, + code string, + action model.RailOperation, + rail model.Rail, + visibility model.ReportVisibility, +) { + t.Helper() + if got, want := step.StepCode, code; got != want { + t.Fatalf("step code mismatch: got=%q want=%q", got, want) + } + if got, want := step.Action, action; got != want { + t.Fatalf("step action mismatch: got=%q want=%q", got, want) + } + if got, want := step.Rail, rail; got != want { + t.Fatalf("step rail mismatch: got=%q want=%q", got, want) + } + if got, want := step.Visibility, visibility; got != want { + t.Fatalf("step visibility mismatch: got=%q want=%q", got, want) + } +} + +func testIntent(kind model.PaymentKind) model.PaymentIntent { + return model.PaymentIntent{ + Kind: kind, + Amount: &paymenttypes.Money{ + Amount: "10", + Currency: "USD", + }, + } +} + +func railPtr(v model.Rail) *model.Rail { + return &v +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go deleted file mode 100644 index eff95946..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go +++ /dev/null @@ -1,10 +0,0 @@ -package orchestrator - -const ( - defaultCardGateway = "monetix" - - stepCodeGasTopUp = "gas_top_up" - stepCodeFundingTransfer = "funding_transfer" - stepCodeCardPayout = "card_payout" - stepCodeFeeTransfer = "fee_transfer" -) diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go deleted file mode 100644 index c26b16d6..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go +++ /dev/null @@ -1,367 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/shopspring/decimal" - chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *sharedv1.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/orchestrator/internal/service/orchestrator/card_payout_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go deleted file mode 100644 index 86554e87..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go +++ /dev/null @@ -1,80 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" -) - -func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan { - if payment == nil { - return nil - } - if payment.ExecutionPlan == nil { - payment.ExecutionPlan = &model.ExecutionPlan{} - } - return payment.ExecutionPlan -} - -func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep { - if plan == nil { - return nil - } - code = strings.TrimSpace(code) - if code == "" { - return nil - } - for _, step := range plan.Steps { - if step == nil { - continue - } - if strings.EqualFold(step.Code, code) { - if step.Code == "" { - step.Code = code - } - return step - } - } - step := &model.ExecutionStep{Code: code} - plan.Steps = append(plan.Steps, step) - return step -} - -func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) { - if plan == nil { - return - } - total := decimal.Zero - currency := "" - hasFee := false - for _, step := range plan.Steps { - if step == nil || step.NetworkFee == nil { - continue - } - fee := step.NetworkFee - if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { - continue - } - if currency == "" { - currency = strings.TrimSpace(fee.GetCurrency()) - } else if !strings.EqualFold(currency, fee.GetCurrency()) { - continue - } - value, err := decimalFromMoney(fee) - if err != nil { - continue - } - total = total.Add(value) - hasFee = true - } - if !hasFee || currency == "" { - plan.TotalNetworkFee = nil - return - } - plan.TotalNetworkFee = &paymenttypes.Money{ - Currency: currency, - Amount: total.String(), - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go deleted file mode 100644 index 927e1d29..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go +++ /dev/null @@ -1,29 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/pkg/merrors" - "go.uber.org/zap" -) - -func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { - if len(s.deps.cardRoutes) == 0 { - s.logger.Warn("Card routing not configured", zap.String("gateway", gateway)) - return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured") - } - key := strings.ToLower(strings.TrimSpace(gateway)) - if key == "" { - key = defaultCardGateway - } - route, ok := s.deps.cardRoutes[key] - if !ok { - s.logger.Warn("Card routing missing for gateway", zap.String("gateway", key)) - return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) - } - if strings.TrimSpace(route.FundingAddress) == "" { - s.logger.Warn("Card routing missing funding address", zap.String("gateway", key)) - return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) - } - return route, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go deleted file mode 100644 index 343ca1a6..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go +++ /dev/null @@ -1,351 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/google/uuid" - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - "go.uber.org/zap" -) - -func (s *Service) submitCardPayout(ctx context.Context, operationRef string, payment *model.Payment) error { - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - if payment.Execution != nil && strings.TrimSpace(payment.Execution.CardPayoutRef) != "" { - return nil - } - intent := payment.Intent - card := intent.Destination.Card - if card == nil { - return merrors.InvalidArgument("card payout: card endpoint is required") - } - amount, err := cardPayoutAmount(payment) - if err != nil { - return err - } - amtDec, err := decimalFromMoney(amount) - if err != nil { - return err - } - minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() - - payoutID := payment.PaymentRef - currency := strings.TrimSpace(amount.GetCurrency()) - holder := strings.TrimSpace(card.Cardholder) - meta := cloneMetadata(payment.Metadata) - customer := intent.Customer - customerID := "" - customerFirstName := "" - customerMiddleName := "" - customerLastName := "" - customerIP := "" - customerZip := "" - customerCountry := "" - customerState := "" - customerCity := "" - customerAddress := "" - if customer != nil { - customerID = strings.TrimSpace(customer.ID) - customerFirstName = strings.TrimSpace(customer.FirstName) - customerMiddleName = strings.TrimSpace(customer.MiddleName) - customerLastName = strings.TrimSpace(customer.LastName) - customerIP = strings.TrimSpace(customer.IP) - customerZip = strings.TrimSpace(customer.Zip) - customerCountry = strings.TrimSpace(customer.Country) - customerState = strings.TrimSpace(customer.State) - customerCity = strings.TrimSpace(customer.City) - customerAddress = strings.TrimSpace(customer.Address) - } - if customerFirstName == "" { - customerFirstName = strings.TrimSpace(card.Cardholder) - } - if customerLastName == "" { - customerLastName = strings.TrimSpace(card.CardholderSurname) - } - if customerID == "" { - return merrors.InvalidArgument("card payout: customer id is required") - } - if customerFirstName == "" { - return merrors.InvalidArgument("card payout: customer first name is required") - } - if customerLastName == "" { - return merrors.InvalidArgument("card payout: customer last name is required") - } - if customerIP == "" { - return merrors.InvalidArgument("card payout: customer ip is required") - } - - var ( - state *mntxv1.CardPayoutState - ) - - if token := strings.TrimSpace(card.Token); token != "" { - req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - IdempotencyKey: payment.IdempotencyKey, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardToken: token, - CardHolder: holder, - MaskedPan: strings.TrimSpace(card.MaskedPan), - Metadata: meta, - } - resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) - if err != nil { - s.logger.Warn("Card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - state = resp.GetPayout() - } else if pan := strings.TrimSpace(card.Pan); pan != "" { - req := &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - IdempotencyKey: payment.IdempotencyKey, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardPan: pan, - CardExpYear: card.ExpYear, - CardExpMonth: card.ExpMonth, - CardHolder: holder, - Metadata: meta, - IntentRef: payment.Intent.Ref, - OperationRef: operationRef, - } - resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) - if err != nil { - s.logger.Warn("Card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - state = resp.GetPayout() - } else { - return merrors.InvalidArgument("card payout: either token or pan must be provided") - } - - if state == nil { - return merrors.Internal("card payout: missing payout state") - } - recordCardPayoutState(payment, state) - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - if exec.CardPayoutRef == "" { - exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) - } - payment.Execution = exec - - plan := ensureExecutionPlan(payment) - if plan != nil { - step := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(step, executionStepRoleConsumer) - step.Description = "Submit card payout" - step.Amount = cloneMoney(amount) - if masked := strings.TrimSpace(card.MaskedPan); masked != "" { - step.DestinationRef = masked - } - if exec.CardPayoutRef != "" { - step.TransferRef = exec.CardPayoutRef - } - setExecutionStepStatus(step, model.OperationStateWaiting) - updateExecutionPlanTotalNetworkFee(plan) - } - - s.logger.Info("Card payout submitted", zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_id", exec.CardPayoutRef), zap.String("operation_ref", state.OperationRef)) - - return nil -} - -func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) { - if payment == nil || state == nil { - return - } - if payment.CardPayout == nil { - payment.CardPayout = &model.CardPayout{} - } - payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId()) - payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId()) - payment.CardPayout.Status = state.GetStatus().String() - payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage()) - payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode()) - if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil { - payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country) - } - if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil { - payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan) - } - payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId()) -} - -func updateCardPayoutPlanSteps(payment *model.Payment, payout *mntxv1.CardPayoutState) bool { - if payment == nil || payout == nil || payment.PaymentPlan == nil { - return false - } - plan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if plan == nil { - return false - } - payoutID := strings.TrimSpace(payout.GetPayoutId()) - if payoutID == "" { - return false - } - - updated := false - for idx, planStep := range payment.PaymentPlan.Steps { - if planStep == nil { - continue - } - if planStep.Rail != model.RailCardPayout { - continue - } - if planStep.Action != model.RailOperationSend && planStep.Action != model.RailOperationObserveConfirm { - continue - } - if idx >= len(plan.Steps) { - continue - } - execStep := plan.Steps[idx] - if execStep == nil { - execStep = &model.ExecutionStep{ - Code: planStepID(planStep, idx), - Description: describePlanStep(planStep), - OperationRef: uuid.New().String(), - State: model.OperationStateCreated, - } - plan.Steps[idx] = execStep - } - if execStep.TransferRef == "" { - execStep.TransferRef = payoutID - } - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: - setExecutionStepStatus(execStep, model.OperationStateCreated) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: - setExecutionStepStatus(execStep, model.OperationStateWaiting) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - setExecutionStepStatus(execStep, model.OperationStateSuccess) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - setExecutionStepStatus(execStep, model.OperationStateFailed) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - setExecutionStepStatus(execStep, model.OperationStateCancelled) - - default: - setExecutionStepStatus(execStep, model.OperationStatePlanned) - } - updated = true - } - return updated -} - -func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) { - if payment == nil || payout == nil { - return - } - recordCardPayoutState(payment, payout) - - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.CardPayoutRef == "" { - payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId()) - } - - updated := updateCardPayoutPlanSteps(payment, payout) - plan := ensureExecutionPlan(payment) - if plan != nil && !updated { - step := findExecutionStepByTransferRef(plan, strings.TrimSpace(payout.GetPayoutId())) - if step == nil { - step = ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(step, executionStepRoleConsumer) - if step.TransferRef == "" { - step.TransferRef = payment.Execution.CardPayoutRef - } - } - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: - setExecutionStepStatus(step, model.OperationStatePlanned) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: - setExecutionStepStatus(step, model.OperationStateWaiting) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - setExecutionStepStatus(step, model.OperationStateSuccess) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - setExecutionStepStatus(step, model.OperationStateFailed) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - setExecutionStepStatus(step, model.OperationStateCancelled) - - default: - setExecutionStepStatus(step, model.OperationStatePlanned) - } - - } - - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = "payout cancelled" - - default: - // CREATED / WAITING — keep as is - } -} - -func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) { - if payment == nil { - return nil, merrors.InvalidArgument("payment is required") - } - amount := cloneMoney(payment.Intent.Amount) - if payment.LastQuote != nil { - settlement := payment.LastQuote.ExpectedSettlementAmount - if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" { - amount = cloneMoney(settlement) - } - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("card payout: amount is required") - } - return amount, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go deleted file mode 100644 index bfbdf623..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - "testing" - - chainclient "github.com/tech/sendico/gateway/chain/client" - mntxclient "github.com/tech/sendico/gateway/mntx/client" - "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" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { - ctx := context.Background() - - const ( - sourceWalletRef = "wallet-src" - feeWalletRef = "wallet-fee" - fundingAddress = "0xfunding" - ) - - var estimateCalls []*chainv1.EstimateTransferFeeRequest - var computeCalls []*chainv1.ComputeGasTopUpRequest - var ensureCalls []*chainv1.EnsureGasTopUpRequest - var submitCalls []*chainv1.SubmitTransferRequest - - gateway := &chainclient.Fake{ - EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { - estimateCalls = append(estimateCalls, req) - dest := req.GetDestination() - if req.GetSourceWalletRef() == feeWalletRef { - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.005"}, - }, nil - } - if dest != nil && strings.TrimSpace(dest.GetExternalAddress()) != "" { - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"}, - }, nil - } - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.02"}, - }, nil - }, - ComputeGasTopUpFn: func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { - computeCalls = append(computeCalls, req) - return &chainv1.ComputeGasTopUpResponse{ - TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"}, - }, nil - }, - EnsureGasTopUpFn: func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { - ensureCalls = append(ensureCalls, req) - return &chainv1.EnsureGasTopUpResponse{ - TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"}, - Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()}, - }, nil - }, - SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { - submitCalls = append(submitCalls, req) - return &chainv1.SubmitTransferResponse{ - Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()}, - }, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - deps: serviceDependencies{ - gateway: gatewayDependency{resolver: staticChainGatewayResolver{client: gateway}}, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: fundingAddress, - FeeWalletRef: feeWalletRef, - }, - }, - }, - } - - payment := &model.Payment{ - PaymentRef: "pay-1", - IdempotencyKey: "pay-1", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: sourceWalletRef, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - MaskedPan: "4111", - }, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, - }, - } - - quote := &sharedv1.PaymentQuote{ - ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"}, - } - - if err := svc.submitCardFundingTransfers(ctx, payment, quote); err != nil { - t.Fatalf("submitCardFundingTransfers error: %v", err) - } - - if len(estimateCalls) != 4 { - t.Fatalf("expected 4 fee estimates, got %d", len(estimateCalls)) - } - if len(computeCalls) != 1 { - t.Fatalf("expected 1 gas top-up compute call, got %d", len(computeCalls)) - } - if len(ensureCalls) != 1 { - t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls)) - } - if len(submitCalls) != 2 { - t.Fatalf("expected 2 transfer submissions, got %d", len(submitCalls)) - } - - computeCall := computeCalls[0] - if computeCall.GetWalletRef() != sourceWalletRef { - t.Fatalf("gas top-up compute wallet mismatch: %s", computeCall.GetWalletRef()) - } - if computeCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || computeCall.GetEstimatedTotalFee().GetAmount() != "0.03" { - t.Fatalf("gas top-up compute fee mismatch: %s %s", computeCall.GetEstimatedTotalFee().GetCurrency(), computeCall.GetEstimatedTotalFee().GetAmount()) - } - - ensureCall := ensureCalls[0] - if ensureCall.GetSourceWalletRef() != feeWalletRef { - t.Fatalf("gas top-up source wallet mismatch: %s", ensureCall.GetSourceWalletRef()) - } - if ensureCall.GetTargetWalletRef() != sourceWalletRef { - t.Fatalf("gas top-up destination mismatch: %s", ensureCall.GetTargetWalletRef()) - } - if ensureCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || ensureCall.GetEstimatedTotalFee().GetAmount() != "0.03" { - t.Fatalf("gas top-up ensure fee mismatch: %s %s", ensureCall.GetEstimatedTotalFee().GetCurrency(), ensureCall.GetEstimatedTotalFee().GetAmount()) - } - - fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund") - if fundCall.GetDestination().GetExternalAddress() != fundingAddress { - t.Fatalf("funding destination mismatch: %s", fundCall.GetDestination().GetExternalAddress()) - } - if fundCall.GetAmount().GetCurrency() != "USDT" || fundCall.GetAmount().GetAmount() != "5" { - t.Fatalf("funding amount mismatch: %s %s", fundCall.GetAmount().GetCurrency(), fundCall.GetAmount().GetAmount()) - } - - feeCall := findSubmitCall(t, submitCalls, "pay-1:card:fee") - if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef { - t.Fatalf("fee destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef()) - } - if feeCall.GetAmount().GetCurrency() != "USDT" || feeCall.GetAmount().GetAmount() != "0.35" { - t.Fatalf("fee amount mismatch: %s %s", feeCall.GetAmount().GetCurrency(), feeCall.GetAmount().GetAmount()) - } - - if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" || payment.Execution.FeeTransferRef != "pay-1:card:fee" { - t.Fatalf("expected funding transfer ref recorded, got %v", payment.Execution) - } - - plan := payment.ExecutionPlan - if plan == nil { - t.Fatal("expected execution plan to be populated") - } - gasStep := findExecutionStep(t, plan, stepCodeGasTopUp) - if gasStep.Amount.GetAmount() != "0.025" || gasStep.Amount.GetCurrency() != "ETH" { - t.Fatalf("gas step amount mismatch: %s %s", gasStep.Amount.GetCurrency(), gasStep.Amount.GetAmount()) - } - if gasStep.NetworkFee.GetAmount() != "0.005" || gasStep.NetworkFee.GetCurrency() != "ETH" { - t.Fatalf("gas step fee mismatch: %s %s", gasStep.NetworkFee.GetCurrency(), gasStep.NetworkFee.GetAmount()) - } - if gasStep.TransferRef != "pay-1:card:gas" { - t.Fatalf("expected gas step transfer ref to be set, got %s", gasStep.TransferRef) - } - - fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer) - if fundStep.NetworkFee.GetAmount() != "0.01" || fundStep.NetworkFee.GetCurrency() != "ETH" { - t.Fatalf("funding step fee mismatch: %s %s", fundStep.NetworkFee.GetCurrency(), fundStep.NetworkFee.GetAmount()) - } - if fundStep.TransferRef != "pay-1:card:fund" { - t.Fatalf("funding step transfer ref mismatch: %s", fundStep.TransferRef) - } - - cardStep := findExecutionStep(t, plan, stepCodeCardPayout) - if cardStep.Amount.GetAmount() != "5" || cardStep.Amount.GetCurrency() != "USDT" { - t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount()) - } - - feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer) - if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" { - t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount()) - } - if feeStep.NetworkFee.GetAmount() != "0.02" || feeStep.NetworkFee.GetCurrency() != "ETH" { - t.Fatalf("fee step network fee mismatch: %s %s", feeStep.NetworkFee.GetCurrency(), feeStep.NetworkFee.GetAmount()) - } - if feeStep.TransferRef != "pay-1:card:fee" { - t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef) - } - - if plan.TotalNetworkFee == nil || plan.TotalNetworkFee.GetAmount() != "0.035" || plan.TotalNetworkFee.GetCurrency() != "ETH" { - t.Fatalf("total network fee mismatch: %v", plan.TotalNetworkFee) - } -} - -func TestSubmitCardPayout_UsesSettlementAmount(t *testing.T) { - ctx := context.Background() - - const sourceWalletRef = "wallet-src" - - var payoutReq *mntxv1.CardPayoutRequest - var submitCalls []*chainv1.SubmitTransferRequest - - gateway := &chainclient.Fake{ - SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { - submitCalls = append(submitCalls, req) - return &chainv1.SubmitTransferResponse{ - Transfer: &chainv1.Transfer{TransferRef: "fee-transfer"}, - }, nil - }, - } - mntx := &mntxclient.Fake{ - CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { - payoutReq = req - return &mntxv1.CardPayoutResponse{ - Payout: &mntxv1.CardPayoutState{ - PayoutId: "payout-1", - Status: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING, - }, - }, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - deps: serviceDependencies{ - gateway: gatewayDependency{resolver: staticChainGatewayResolver{client: gateway}}, - mntx: mntxDependency{client: mntx}, - }, - } - - payment := &model.Payment{ - PaymentRef: "pay-2", - IdempotencyKey: "pay-2", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-2", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: sourceWalletRef, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - Pan: "5536913762657597", - Cardholder: "Stephan", - }, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, - Customer: &model.Customer{ - ID: "recipient-1", - FirstName: "Stephan", - LastName: "Tester", - IP: "198.51.100.10", - }, - }, - LastQuote: &model.PaymentQuoteSnapshot{ - ExpectedSettlementAmount: &paymenttypes.Money{Currency: "RUB", Amount: "392.30"}, - ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "0.35"}, - }, - } - - if err := svc.submitCardPayout(ctx, "op-ref", payment); err != nil { - t.Fatalf("submitCardPayout error: %v", err) - } - - if payoutReq == nil { - t.Fatal("expected card payout request to be sent") - } - if payoutReq.GetCurrency() != "RUB" || payoutReq.GetAmountMinor() != 39230 { - t.Fatalf("payout request amount mismatch: %s %d", payoutReq.GetCurrency(), payoutReq.GetAmountMinor()) - } - - if payment.Execution == nil || payment.Execution.CardPayoutRef != "payout-1" { - t.Fatalf("expected card payout ref recorded, got %v", payment.Execution) - } - - if len(submitCalls) != 0 { - t.Fatalf("expected 0 fee transfer submissions, got %d", len(submitCalls)) - } - - plan := payment.ExecutionPlan - if plan == nil { - t.Fatal("expected execution plan to be populated") - } - cardStep := findExecutionStep(t, plan, stepCodeCardPayout) - if cardStep.TransferRef != "payout-1" { - t.Fatalf("card step transfer ref mismatch: %s", cardStep.TransferRef) - } - if cardStep.Amount.GetAmount() != "392.30" || cardStep.Amount.GetCurrency() != "RUB" { - t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount()) - } - -} - -func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) { - ctx := context.Background() - - gateway := &chainclient.Fake{ - EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"}, - }, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - deps: serviceDependencies{ - gateway: gatewayDependency{resolver: staticChainGatewayResolver{client: gateway}}, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: "0xfunding", - }, - }, - }, - } - - payment := &model.Payment{ - PaymentRef: "pay-3", - IdempotencyKey: "pay-3", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-3", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-src", - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - MaskedPan: "4111", - }, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, - }, - } - - quote := &sharedv1.PaymentQuote{ - ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"}, - } - - err := svc.submitCardFundingTransfers(ctx, payment, quote) - if err == nil { - t.Fatal("expected error for missing fee wallet ref") - } - if !strings.Contains(err.Error(), "fee wallet ref") { - t.Fatalf("unexpected error: %v", err) - } -} - -func findSubmitCall(t *testing.T, calls []*chainv1.SubmitTransferRequest, idempotencyKey string) *chainv1.SubmitTransferRequest { - t.Helper() - for _, call := range calls { - if call.GetIdempotencyKey() == idempotencyKey { - return call - } - } - t.Fatalf("missing submit transfer call for %s", idempotencyKey) - return nil -} - -func findExecutionStep(t *testing.T, plan *model.ExecutionPlan, code string) *model.ExecutionStep { - t.Helper() - if plan == nil { - t.Fatal("execution plan is nil") - } - for _, step := range plan.Steps { - if step != nil && strings.EqualFold(step.Code, code) { - return step - } - } - t.Fatalf("missing execution step %s", code) - return nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go deleted file mode 100644 index aedc0be8..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go +++ /dev/null @@ -1,77 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -type paymentEngine interface { - EnsureRepository(ctx context.Context) error - ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) - ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *sharedv1.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) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - return e.svc.resolvePaymentQuote(ctx, in) -} - -func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *sharedv1.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) 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/orchestrator/internal/service/orchestrator/composite_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go deleted file mode 100644 index 5e3f3609..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go +++ /dev/null @@ -1,65 +0,0 @@ -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 { - key := model.GatewayDescriptorIdentityKey(entry) - if key == "" { - continue - } - items[key] = 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 model.LessGatewayDescriptor(result[i], result[j]) - }) - return result, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go deleted file mode 100644 index 12e6b3ce..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ /dev/null @@ -1,959 +0,0 @@ -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" - paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/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/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "google.golang.org/protobuf/types/known/timestamppb" -) - -func intentFromProto(src *sharedv1.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 *sharedv1.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 *sharedv1.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 *sharedv1.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) *sharedv1.Payment { - if src == nil { - return nil - } - payment := &sharedv1.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 = &sharedv1.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) *sharedv1.PaymentIntent { - intent := &sharedv1.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 *sharedv1.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) *sharedv1.Customer { - if src == nil { - return nil - } - return &sharedv1.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) *sharedv1.PaymentEndpoint { - endpoint := &sharedv1.PaymentEndpoint{ - Metadata: cloneMetadata(src.Metadata), - InstanceId: strings.TrimSpace(src.InstanceID), - } - switch src.Type { - case model.EndpointTypeLedger: - if src.Ledger != nil { - endpoint.Endpoint = &sharedv1.PaymentEndpoint_Ledger{ - Ledger: &sharedv1.LedgerEndpoint{ - LedgerAccountRef: src.Ledger.LedgerAccountRef, - ContraLedgerAccountRef: src.Ledger.ContraLedgerAccountRef, - }, - } - } - case model.EndpointTypeManagedWallet: - if src.ManagedWallet != nil { - endpoint.Endpoint = &sharedv1.PaymentEndpoint_ManagedWallet{ - ManagedWallet: &sharedv1.ManagedWalletEndpoint{ - ManagedWalletRef: src.ManagedWallet.ManagedWalletRef, - Asset: assetToProto(src.ManagedWallet.Asset), - }, - } - } - case model.EndpointTypeExternalChain: - if src.ExternalChain != nil { - endpoint.Endpoint = &sharedv1.PaymentEndpoint_ExternalChain{ - ExternalChain: &sharedv1.ExternalChainEndpoint{ - Asset: assetToProto(src.ExternalChain.Asset), - Address: src.ExternalChain.Address, - Memo: src.ExternalChain.Memo, - }, - } - } - case model.EndpointTypeCard: - if src.Card != nil { - card := &sharedv1.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 = &sharedv1.CardEndpoint_Pan{Pan: pan} - } - if token := strings.TrimSpace(src.Card.Token); token != "" { - card.Card = &sharedv1.CardEndpoint_Token{Token: token} - } - endpoint.Endpoint = &sharedv1.PaymentEndpoint_Card{Card: card} - } - default: - // leave unspecified - } - return endpoint -} - -func protoFXIntentFromModel(src *model.FXIntent) *sharedv1.FXIntent { - if src == nil { - return nil - } - return &sharedv1.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) *sharedv1.ExecutionRefs { - if src == nil { - return nil - } - return &sharedv1.ExecutionRefs{ - DebitEntryRef: src.DebitEntryRef, - CreditEntryRef: src.CreditEntryRef, - FxEntryRef: src.FXEntryRef, - ChainTransferRef: src.ChainTransferRef, - CardPayoutRef: src.CardPayoutRef, - FeeTransferRef: src.FeeTransferRef, - } -} - -func protoExecutionStepFromModel(src *model.ExecutionStep) *sharedv1.ExecutionStep { - if src == nil { - return nil - } - return &sharedv1.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) *sharedv1.ExecutionPlan { - if src == nil { - return nil - } - steps := make([]*sharedv1.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 &sharedv1.ExecutionPlan{ - Steps: steps, - TotalNetworkFee: protoMoney(src.TotalNetworkFee), - } -} - -func protoPaymentStepFromModel(src *model.PaymentStep) *sharedv1.PaymentStep { - if src == nil { - return nil - } - return &sharedv1.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) *sharedv1.PaymentPlan { - if src == nil { - return nil - } - steps := make([]*sharedv1.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 := &sharedv1.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) *sharedv1.PaymentQuote { - if src == nil { - return nil - } - return &sharedv1.PaymentQuote{ - DebitAmount: protoMoney(src.DebitAmount), - DebitSettlementAmount: protoMoney(src.DebitSettlementAmount), - ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount), - ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal), - FeeLines: feeLinesToProto(src.FeeLines), - FeeRules: feeRulesToProto(src.FeeRules), - FxQuote: fxQuoteToProto(src.FXQuote), - NetworkFee: networkFeeToProto(src.NetworkFee), - QuoteRef: strings.TrimSpace(src.QuoteRef), - } -} - -func 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) sharedv1.PaymentKind { - switch kind { - case model.PaymentKindPayout: - return sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT - case model.PaymentKindInternalTransfer: - return sharedv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER - case model.PaymentKindFXConversion: - return sharedv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION - default: - return sharedv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED - } -} - -func modelKindFromProto(kind sharedv1.PaymentKind) model.PaymentKind { - switch kind { - case sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT: - return model.PaymentKindPayout - case sharedv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER: - return model.PaymentKindInternalTransfer - case sharedv1.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) sharedv1.PaymentState { - switch state { - case model.PaymentStateAccepted: - return sharedv1.PaymentState_PAYMENT_STATE_ACCEPTED - case model.PaymentStateFundsReserved: - return sharedv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED - case model.PaymentStateSubmitted: - return sharedv1.PaymentState_PAYMENT_STATE_SUBMITTED - case model.PaymentStateSettled: - return sharedv1.PaymentState_PAYMENT_STATE_SETTLED - case model.PaymentStateFailed: - return sharedv1.PaymentState_PAYMENT_STATE_FAILED - case model.PaymentStateCancelled: - return sharedv1.PaymentState_PAYMENT_STATE_CANCELLED - default: - return sharedv1.PaymentState_PAYMENT_STATE_UNSPECIFIED - } -} - -func modelStateFromProto(state sharedv1.PaymentState) model.PaymentState { - switch state { - case sharedv1.PaymentState_PAYMENT_STATE_ACCEPTED: - return model.PaymentStateAccepted - case sharedv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED: - return model.PaymentStateFundsReserved - case sharedv1.PaymentState_PAYMENT_STATE_SUBMITTED: - return model.PaymentStateSubmitted - case sharedv1.PaymentState_PAYMENT_STATE_SETTLED: - return model.PaymentStateSettled - case sharedv1.PaymentState_PAYMENT_STATE_FAILED: - return model.PaymentStateFailed - case sharedv1.PaymentState_PAYMENT_STATE_CANCELLED: - return model.PaymentStateCancelled - default: - return model.PaymentStateUnspecified - } -} - -func protoFailureFromModel(code model.PaymentFailureCode) sharedv1.PaymentFailureCode { - switch code { - case model.PaymentFailureCodeBalance: - return sharedv1.PaymentFailureCode_FAILURE_BALANCE - case model.PaymentFailureCodeLedger: - return sharedv1.PaymentFailureCode_FAILURE_LEDGER - case model.PaymentFailureCodeFX: - return sharedv1.PaymentFailureCode_FAILURE_FX - case model.PaymentFailureCodeChain: - return sharedv1.PaymentFailureCode_FAILURE_CHAIN - case model.PaymentFailureCodeFees: - return sharedv1.PaymentFailureCode_FAILURE_FEES - case model.PaymentFailureCodePolicy: - return sharedv1.PaymentFailureCode_FAILURE_POLICY - default: - return sharedv1.PaymentFailureCode_FAILURE_UNSPECIFIED - } -} - -func settlementModeFromProto(mode paymentv1.SettlementMode) model.SettlementMode { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE: - return model.SettlementModeFixSource - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return model.SettlementModeFixReceived - default: - return model.SettlementModeUnspecified - } -} - -func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode { - switch mode { - case model.SettlementModeFixSource: - return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE - case model.SettlementModeFixReceived: - return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED - default: - return paymentv1.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 - } - pricedAtUnixMs := int64(0) - if ts := quote.GetPricedAt(); ts != nil { - pricedAtUnixMs = ts.AsTime().UnixMilli() - } - return &paymenttypes.FXQuote{ - QuoteRef: strings.TrimSpace(quote.GetQuoteRef()), - Pair: pairFromProto(quote.GetPair()), - Side: fxSideFromProto(quote.GetSide()), - Price: decimalFromProto(quote.GetPrice()), - BaseAmount: moneyFromProto(quote.GetBaseAmount()), - QuoteAmount: moneyFromProto(quote.GetQuoteAmount()), - ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(), - PricedAtUnixMs: pricedAtUnixMs, - Provider: strings.TrimSpace(quote.GetProvider()), - RateRef: strings.TrimSpace(quote.GetRateRef()), - Firm: quote.GetFirm(), - } -} - -func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote { - if quote == nil { - return nil - } - var pricedAt *timestamppb.Timestamp - if quote.PricedAtUnixMs > 0 { - pricedAt = timestamppb.New(time.UnixMilli(quote.PricedAtUnixMs).UTC()) - } - return &oraclev1.Quote{ - QuoteRef: strings.TrimSpace(quote.QuoteRef), - Pair: pairToProto(quote.Pair), - Side: fxSideToProto(quote.Side), - Price: decimalToProto(quote.Price), - BaseAmount: protoMoney(quote.BaseAmount), - QuoteAmount: protoMoney(quote.QuoteAmount), - ExpiresAtUnixMs: quote.ExpiresAtUnixMs, - PricedAt: pricedAt, - Provider: strings.TrimSpace(quote.Provider), - RateRef: strings.TrimSpace(quote.RateRef), - Firm: quote.Firm, - } -} - -func 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/orchestrator/internal/service/orchestrator/convert_card_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go deleted file mode 100644 index 7d5c1e7c..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package orchestrator - -import ( - "testing" - - "github.com/tech/sendico/payments/storage/model" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func TestEndpointFromProtoCard(t *testing.T) { - protoEndpoint := &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Card{ - Card: &sharedv1.CardEndpoint{ - Card: &sharedv1.CardEndpoint_Pan{Pan: " 411111 "}, - CardholderName: " Jane ", - CardholderSurname: " Doe ", - ExpMonth: 12, - ExpYear: 2030, - Country: " US ", - MaskedPan: " ****1111 ", - }, - }, - Metadata: map[string]string{"k": "v"}, - } - - modelEndpoint := endpointFromProto(protoEndpoint) - if modelEndpoint.Type != model.EndpointTypeCard { - t.Fatalf("expected card type, got %s", modelEndpoint.Type) - } - if modelEndpoint.Card == nil { - t.Fatalf("card payload missing") - } - if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane" || modelEndpoint.Card.CardholderSurname != "Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" { - t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card) - } - if modelEndpoint.Metadata["k"] != "v" { - t.Fatalf("metadata not preserved") - } -} - -func TestProtoEndpointFromModelCard(t *testing.T) { - modelEndpoint := model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - Token: "tok_123", - Cardholder: "Jane", - CardholderSurname: "Doe", - ExpMonth: 1, - ExpYear: 2028, - Country: "GB", - MaskedPan: "****1234", - }, - Metadata: map[string]string{"k": "v"}, - } - - protoEndpoint := protoEndpointFromModel(modelEndpoint) - card := protoEndpoint.GetCard() - if card == nil { - t.Fatalf("card payload missing in proto") - } - token, ok := card.Card.(*sharedv1.CardEndpoint_Token) - if !ok || token.Token != "tok_123" { - t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card) - } - if card.GetCardholderName() != "Jane" || card.GetCardholderSurname() != "Doe" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" { - t.Fatalf("card details mismatch: %#v", card) - } - if protoEndpoint.GetMetadata()["k"] != "v" { - t.Fatalf("metadata not preserved in proto endpoint") - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go deleted file mode 100644 index 40fc62b5..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package orchestrator - -import ( - "testing" - "time" - - paymenttypes "github.com/tech/sendico/pkg/payments/types" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - "google.golang.org/protobuf/types/known/timestamppb" -) - -func TestMoneyConversionRoundTrip(t *testing.T) { - proto := &moneyv1.Money{Currency: "USD", Amount: "12.34"} - model := moneyFromProto(proto) - if model == nil || model.Currency != "USD" || model.Amount != "12.34" { - t.Fatalf("moneyFromProto mismatch: %#v", model) - } - back := protoMoney(model) - if back == nil || back.GetCurrency() != "USD" || back.GetAmount() != "12.34" { - t.Fatalf("protoMoney mismatch: %#v", back) - } -} - -func TestFeePolicyConversionRoundTrip(t *testing.T) { - proto := &feesv1.PolicyOverrides{InsufficientNet: feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH} - model := feePolicyFromProto(proto) - if model == nil || model.InsufficientNet != paymenttypes.InsufficientNetSweepOrgCash { - t.Fatalf("feePolicyFromProto mismatch: %#v", model) - } - back := feePolicyToProto(model) - if back == nil || back.GetInsufficientNet() != feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH { - t.Fatalf("feePolicyToProto mismatch: %#v", back) - } -} - -func TestFeeLineConversionRoundTrip(t *testing.T) { - protoLine := &feesv1.DerivedPostingLine{ - LedgerAccountRef: "ledger:fees", - Money: &moneyv1.Money{Currency: "EUR", Amount: "1.00"}, - LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, - Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, - Meta: map[string]string{"k": "v"}, - } - modelLines := feeLinesFromProto([]*feesv1.DerivedPostingLine{protoLine}) - if len(modelLines) != 1 { - t.Fatalf("expected 1 model line, got %d", len(modelLines)) - } - modelLine := modelLines[0] - if modelLine.LedgerAccountRef != "ledger:fees" || modelLine.Money.GetCurrency() != "EUR" || modelLine.Money.GetAmount() != "1.00" { - t.Fatalf("model line mismatch: %#v", modelLine) - } - if modelLine.LineType != paymenttypes.PostingLineTypeFee || modelLine.Side != paymenttypes.EntrySideDebit { - t.Fatalf("model line enums mismatch: %#v", modelLine) - } - back := feeLinesToProto(modelLines) - if len(back) != 1 { - t.Fatalf("expected 1 proto line, got %d", len(back)) - } - protoBack := back[0] - if protoBack.GetLedgerAccountRef() != "ledger:fees" || protoBack.GetMoney().GetCurrency() != "EUR" || protoBack.GetMoney().GetAmount() != "1.00" { - t.Fatalf("proto line mismatch: %#v", protoBack) - } - if protoBack.GetLineType() != accountingv1.PostingLineType_POSTING_LINE_FEE || protoBack.GetSide() != accountingv1.EntrySide_ENTRY_SIDE_DEBIT { - t.Fatalf("proto line enums mismatch: %#v", protoBack) - } -} - -func TestFXQuoteConversionRoundTrip(t *testing.T) { - pricedAt := int64(1700000000000) - proto := &oraclev1.Quote{ - QuoteRef: "q1", - Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - Price: &moneyv1.Decimal{Value: "0.9"}, - BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "100"}, - QuoteAmount: &moneyv1.Money{Currency: "EUR", Amount: "90"}, - ExpiresAtUnixMs: 1700000000000, - PricedAt: timestamppb.New(time.UnixMilli(pricedAt).UTC()), - Provider: "provider", - RateRef: "rate", - Firm: true, - } - model := fxQuoteFromProto(proto) - if model == nil || model.QuoteRef != "q1" || model.Pair.GetBase() != "USD" || model.Pair.GetQuote() != "EUR" { - t.Fatalf("fxQuoteFromProto mismatch: %#v", model) - } - if model.Side != paymenttypes.FXSideSellBaseBuyQuote || model.Price.GetValue() != "0.9" { - t.Fatalf("fxQuoteFromProto enums mismatch: %#v", model) - } - if model.PricedAtUnixMs != pricedAt { - t.Fatalf("fxQuoteFromProto priced_at mismatch: %#v", model) - } - back := fxQuoteToProto(model) - if back == nil || back.GetQuoteRef() != "q1" || back.GetPair().GetBase() != "USD" || back.GetPair().GetQuote() != "EUR" { - t.Fatalf("fxQuoteToProto mismatch: %#v", back) - } - if back.GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE || back.GetPrice().GetValue() != "0.9" { - t.Fatalf("fxQuoteToProto enums mismatch: %#v", back) - } - if got := back.GetPricedAt(); got == nil || got.AsTime().UnixMilli() != pricedAt { - t.Fatalf("fxQuoteToProto priced_at mismatch: %#v", back) - } -} - -func TestAssetConversionRoundTrip(t *testing.T) { - proto := &chainv1.Asset{ - Chain: chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, - TokenSymbol: "USDT", - ContractAddress: "0xabc", - } - model := assetFromProto(proto) - if model == nil || model.Chain != "TRON_MAINNET" || model.TokenSymbol != "USDT" || model.ContractAddress != "0xabc" { - t.Fatalf("assetFromProto mismatch: %#v", model) - } - back := assetToProto(model) - if back == nil || back.GetChain() != chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET || back.GetTokenSymbol() != "USDT" || back.GetContractAddress() != "0xabc" { - t.Fatalf("assetToProto mismatch: %#v", back) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go deleted file mode 100644 index 6485b4ef..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go +++ /dev/null @@ -1,212 +0,0 @@ -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, entry.CurrencyMeta), - Version: entry.Version, - IsEnabled: entry.Healthy, - }) - } - sort.Slice(items, func(i, j int) bool { - return model.LessGatewayDescriptor(items[i], items[j]) - }) - 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, currencies []discovery.CurrencyAnnouncement) model.Limits { - limits := model.Limits{ - VolumeLimit: map[string]string{}, - VelocityLimit: map[string]int{}, - CurrencyLimits: map[string]model.LimitsOverride{}, - } - if src != nil { - limits.MinAmount = strings.TrimSpace(src.MinAmount) - limits.MaxAmount = strings.TrimSpace(src.MaxAmount) - 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 - } - } - applyCurrencyTransferLimits(&limits, currencies) - if len(limits.VolumeLimit) == 0 { - limits.VolumeLimit = nil - } - if len(limits.VelocityLimit) == 0 { - limits.VelocityLimit = nil - } - if len(limits.CurrencyLimits) == 0 { - limits.CurrencyLimits = nil - } - return limits -} - -func applyCurrencyTransferLimits(dst *model.Limits, currencies []discovery.CurrencyAnnouncement) { - if dst == nil || len(currencies) == 0 { - return - } - var ( - commonMin string - commonMax string - commonMinInit bool - commonMaxInit bool - commonMinConsistent = true - commonMaxConsistent = true - ) - - for _, currency := range currencies { - code := strings.ToUpper(strings.TrimSpace(currency.Currency)) - if code == "" || currency.Limits == nil || currency.Limits.Amount == nil { - commonMinConsistent = false - commonMaxConsistent = false - continue - } - min := strings.TrimSpace(currency.Limits.Amount.Min) - max := strings.TrimSpace(currency.Limits.Amount.Max) - - if min != "" || max != "" { - override := dst.CurrencyLimits[code] - if min != "" { - override.MinAmount = min - } - if max != "" { - override.MaxAmount = max - } - if override.MinAmount != "" || override.MaxAmount != "" || override.MaxFee != "" || override.MaxOps > 0 || override.MaxVolume != "" { - dst.CurrencyLimits[code] = override - } - } - - if min == "" { - commonMinConsistent = false - } else if !commonMinInit { - commonMin = min - commonMinInit = true - } else if commonMin != min { - commonMinConsistent = false - } - - if max == "" { - commonMaxConsistent = false - } else if !commonMaxInit { - commonMax = max - commonMaxInit = true - } else if commonMax != max { - commonMaxConsistent = false - } - } - - if commonMinInit && commonMinConsistent { - dst.PerTxMinAmount = firstDiscoveryLimitValue(dst.PerTxMinAmount, commonMin) - } - if commonMaxInit && commonMaxConsistent { - dst.PerTxMaxAmount = firstDiscoveryLimitValue(dst.PerTxMaxAmount, commonMax) - } -} - -func firstDiscoveryLimitValue(primary, fallback string) string { - primary = strings.TrimSpace(primary) - if primary != "" { - return primary - } - return strings.TrimSpace(fallback) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry_test.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry_test.go deleted file mode 100644 index d5a15ef1..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package orchestrator - -import ( - "testing" - - "github.com/tech/sendico/pkg/discovery" -) - -func TestLimitsFromDiscovery_MapsPerTxMinimumFromCurrencyMeta(t *testing.T) { - limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{ - { - Currency: "RUB", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{ - Min: "100.00", - Max: "10000.00", - }, - }, - }, - }) - - if limits.PerTxMinAmount != "100.00" { - t.Fatalf("expected per tx min 100.00, got %q", limits.PerTxMinAmount) - } - if limits.PerTxMaxAmount != "10000.00" { - t.Fatalf("expected per tx max 10000.00, got %q", limits.PerTxMaxAmount) - } - override, ok := limits.CurrencyLimits["RUB"] - if !ok { - t.Fatalf("expected RUB currency override") - } - if override.MinAmount != "100.00" { - t.Fatalf("expected RUB min override 100.00, got %q", override.MinAmount) - } -} - -func TestLimitsFromDiscovery_DropsCommonPerTxMinimumWhenCurrenciesDiffer(t *testing.T) { - limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{ - { - Currency: "USD", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{Min: "10.00"}, - }, - }, - { - Currency: "EUR", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{Min: "20.00"}, - }, - }, - }) - - if limits.PerTxMinAmount != "" { - t.Fatalf("expected empty common per tx min, got %q", limits.PerTxMinAmount) - } - if limits.CurrencyLimits["USD"].MinAmount != "10.00" { - t.Fatalf("expected USD min override 10.00, got %q", limits.CurrencyLimits["USD"].MinAmount) - } - if limits.CurrencyLimits["EUR"].MinAmount != "20.00" { - t.Fatalf("expected EUR min override 20.00, got %q", limits.CurrencyLimits["EUR"].MinAmount) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go b/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go deleted file mode 100644 index c9d312e5..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go +++ /dev/null @@ -1,103 +0,0 @@ -package orchestrator - -import ( - "github.com/tech/sendico/payments/orchestrator/internal/service/execution" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/model/account_role" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -type Liveness = execution.Liveness - -const ( - StepFinal Liveness = execution.StepFinal - StepRunnable Liveness = execution.StepRunnable - StepBlocked Liveness = execution.StepBlocked - StepDead Liveness = execution.StepDead - - executionStepRoleSource = execution.ExecutionStepRoleSource - executionStepRoleConsumer = execution.ExecutionStepRoleConsumer -) - -func setExecutionStepRole(step *model.ExecutionStep, role string) { - execution.SetExecutionStepRole(step, role) -} - -func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) { - execution.SetExecutionStepStatus(step, state) -} - -func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep { - return execution.FindExecutionStepByTransferRef(plan, transferRef) -} - -func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep { - return execution.UpdateExecutionStepFromTransfer(plan, event) -} - -func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs { - return execution.EnsureExecutionRefs(payment) -} - -func executionQuote(payment *model.Payment, quote *sharedv1.PaymentQuote) *sharedv1.PaymentQuote { - return execution.ExecutionQuote(payment, quote, modelQuoteToProto) -} - -func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan { - return execution.EnsureExecutionPlanForPlan(payment, plan) -} - -func executionPlanComplete(plan *model.ExecutionPlan) bool { - return execution.ExecutionPlanComplete(plan) -} - -func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { - return execution.BlockStepConfirmed(plan, execPlan) -} - -func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) { - return execution.RoleHintsForStep(plan, idx) -} - -func linkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) { - execution.LinkRailObservation(payment, rail, referenceID, dependsOn) -} - -func planStepID(step *model.PaymentStep, idx int) string { - return execution.PlanStepID(step, idx) -} - -func describePlanStep(step *model.PaymentStep) string { - return execution.DescribePlanStep(step) -} - -func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string { - return execution.PlanStepIdempotencyKey(payment, idx, step) -} - -func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep { - return execution.ExecutionStepsByCode(plan) -} - -func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep { - return execution.PlanStepsByID(plan) -} - -func stepDependenciesReady( - step *model.PaymentStep, - execSteps map[string]*model.ExecutionStep, - planSteps map[string]*model.PaymentStep, - requireSuccess bool, -) (ready bool, waiting bool, blocked bool, err error) { - return execution.StepDependenciesReady(step, execSteps, planSteps, requireSuccess) -} - -func cardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { - return execution.CardPayoutDependenciesConfirmed(plan, execPlan) -} - -func analyzeExecutionPlan(logger mlogger.Logger, payment *model.Payment) (bool, bool, error) { - return execution.AnalyzeExecutionPlan(logger, payment) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go deleted file mode 100644 index a28b0e93..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go +++ /dev/null @@ -1,295 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "strings" - - paymodel "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - cons "github.com/tech/sendico/pkg/messaging/consumer" - paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" - np "github.com/tech/sendico/pkg/messaging/notifications/processor" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/payments/rail" - "go.uber.org/zap" -) - -func (s *Service) startGatewayConsumers() { - if s == nil || s.gatewayBroker == nil { - s.logger.Warn("Missing broker. Gateway feedback consumer has NOT started") - return - } - s.logger.Info("Gateway feedback consumer started") - processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution) - s.consumeGatewayProcessor(processor) -} - -func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) { - consumer, err := cons.NewConsumer(s.logger, s.gatewayBroker, processor.GetSubject()) - if err != nil { - s.logger.Warn("Failed to create payment gateway consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) - return - } - s.gatewayConsumers = append(s.gatewayConsumers, consumer) - go func() { - if err := consumer.ConsumeMessages(processor.Process); err != nil { - s.logger.Warn("Payment gateway consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) - } - }() -} - -func executionPlanSucceeded(plan *paymodel.ExecutionPlan) bool { - for _, s := range plan.Steps { - if !s.IsTerminal() { - return false - } - if s.State != paymodel.OperationStateSuccess { - return false - } - } - return true -} - -func executionPlanFailed(plan *paymodel.ExecutionPlan) bool { - hasFailed := false - - for _, s := range plan.Steps { - if !s.IsTerminal() { - return false - } - if s.State == paymodel.OperationStateFailed { - hasFailed = true - } - } - - return hasFailed -} - -func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error { - if exec == nil { - return merrors.InvalidArgument("payment gateway execution is nil", "execution") - } - - paymentRef := strings.TrimSpace(exec.PaymentRef) - if paymentRef == "" { - return merrors.InvalidArgument("payment_ref is required", "payment_ref") - } - - store := s.storage.Payments() - - payment, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - s.logger.Warn("Failed to fetch payment from database", zap.Error(err)) - return err - } - - // --- metadata - if payment.Metadata == nil { - payment.Metadata = map[string]string{} - } - payment.Metadata["gateway_operation_result"] = string(exec.Status) - payment.Metadata["gateway_operation_ref"] = exec.OperationRef - payment.Metadata["gateway_request_idempotency"] = exec.IdempotencyKey - - // --- update exactly ONE step - - if payment.State, err = updateExecutionStepsFromGatewayExecution(s.logger, payment, exec); err != nil { - s.logger.Warn("No execution step matched gateway result", - zap.String("payment_ref", paymentRef), - zap.String("operation_ref", exec.OperationRef), - zap.String("idempotency", exec.IdempotencyKey), - ) - } - - if err := store.Update(ctx, payment); err != nil { - return err - } - - // reload unified state - payment, err = store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return err - } - - // --- if plan can continue — continue - if payment.ExecutionPlan != nil && !executionPlanComplete(payment.ExecutionPlan) { - return s.resumePaymentPlan(ctx, store, payment) - } - - // --- plan is terminal: decide payment fate by aggregation - if payment.ExecutionPlan != nil && executionPlanComplete(payment.ExecutionPlan) { - switch { - case executionPlanSucceeded(payment.ExecutionPlan): - payment.State = paymodel.PaymentStateSettled - - case executionPlanFailed(payment.ExecutionPlan): - payment.State = paymodel.PaymentStateFailed - payment.FailureReason = "execution_plan_failed" - } - - return store.Update(ctx, payment) - } - - return nil -} - -func updateExecutionStepsFromGatewayExecution( - logger mlogger.Logger, - payment *paymodel.Payment, - exec *model.PaymentGatewayExecution, -) (paymodel.PaymentState, error) { - - log := logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("operation_ref", strings.TrimSpace(exec.OperationRef)), - zap.String("gateway_status", string(exec.Status)), - ) - - log.Debug("Gateway execution received") - - if payment == nil || payment.PaymentPlan == nil || exec == nil { - log.Warn("Invalid input: payment/plan/exec is nil") - return paymodel.PaymentStateSubmitted, - merrors.DataConflict("payment is missing plan or execution step") - } - - operationRef := strings.TrimSpace(exec.OperationRef) - if operationRef == "" { - log.Warn("Empty operation_ref from gateway") - return paymodel.PaymentStateSubmitted, - merrors.InvalidArgument("no operation reference provided") - } - - execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if execPlan == nil { - log.Warn("Execution plan missing") - return paymodel.PaymentStateSubmitted, merrors.InvalidArgument("execution plan missing") - } - - status := executionStepStatusFromGatewayStatus(exec.Status) - if status == "" { - log.Warn("Unknown gateway status") - return paymodel.PaymentStateSubmitted, - merrors.DataConflict(fmt.Sprintf("unknown gateway status: %s", exec.Status)) - } - - var matched bool - - for idx, execStep := range execPlan.Steps { - if execStep == nil { - continue - } - - if strings.EqualFold(strings.TrimSpace(execStep.OperationRef), operationRef) { - - log.Debug("Execution step matched", - zap.Int("step_index", idx), - zap.String("step_code", execStep.Code), - zap.String("prev_state", string(execStep.State)), - ) - - if execStep.TransferRef == "" && exec.TransferRef != "" { - execStep.TransferRef = strings.TrimSpace(exec.TransferRef) - log.Debug("Transfer_ref attached to step", zap.String("transfer_ref", execStep.TransferRef)) - } - - setExecutionStepStatus(execStep, status) - if exec.Error != "" && execStep.Error == "" { - execStep.Error = strings.TrimSpace(exec.Error) - } - - log.Debug("Execution step state updated", - zap.Int("step_index", idx), - zap.String("step_code", execStep.Code), - zap.String("new_state", string(execStep.State)), - ) - - matched = true - break - } - } - - if !matched { - log.Warn("No execution step found for operation_ref") - return paymodel.PaymentStateSubmitted, - merrors.InvalidArgument( - fmt.Sprintf("execution step not found for operation reference: %s", operationRef), - ) - } - - // -------- GLOBAL REDUCTION -------- - - var ( - hasSuccess bool - allDone = true - ) - - for idx, step := range execPlan.Steps { - if step == nil { - continue - } - - log.Debug("Evaluating step for payment state", - zap.Int("step_index", idx), - zap.String("step_code", step.Code), - zap.String("step_state", string(step.State)), - ) - - switch step.State { - case paymodel.OperationStateFailed: - payment.FailureReason = step.Error - log.Info("Payment marked as FAILED due to step failure", - zap.String("failed_step_code", step.Code), - zap.String("error", step.Error), - ) - return paymodel.PaymentStateFailed, nil - - case paymodel.OperationStateSuccess: - hasSuccess = true - - case paymodel.OperationStateSkipped: - // ok - - default: - allDone = false - } - } - - if hasSuccess && allDone { - log.Info("Payment marked as SUCCESS (all steps completed)") - return paymodel.PaymentStateSuccess, nil - } - - log.Info("Payment still PROCESSING (steps not finished)") - return paymodel.PaymentStateSubmitted, nil -} - -func executionStepStatusFromGatewayStatus(status rail.OperationResult) paymodel.OperationState { - switch status { - - case rail.OperationResultSuccess: - return paymodel.OperationStateSuccess - - case rail.OperationResultFailed: - return paymodel.OperationStateFailed - - case rail.OperationResultCancelled: - return paymodel.OperationStateCancelled - - default: - return paymodel.OperationStateFailed - } -} - -func (s *Service) Shutdown() { - if s == nil { - return - } - for _, consumer := range s.gatewayConsumers { - if consumer != nil { - consumer.Close() - } - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go deleted file mode 100644 index 79c429f6..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - 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" -) - -func TestGatewayExecutionSuccessUpdatesMetadataOnly(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - store := newHelperPaymentStore() - - payment := &paymodel.Payment{ - PaymentRef: "pi-1", - State: paymodel.PaymentStateSubmitted, - } - if err := store.Create(context.Background(), payment); err != nil { - t.Fatalf("failed to seed payment: %v", err) - } - - svc := &Service{ - logger: logger, - storage: stubRepo{payments: store}, - } - - exec := &model.PaymentGatewayExecution{ - PaymentRef: "pi-1", - Status: rail.OperationResultSuccess, - IdempotencyKey: "idem-1", - OperationRef: "oper-1", - } - - if err := svc.onGatewayExecution(context.Background(), exec); err != nil { - t.Fatalf("onGatewayExecution error: %v", err) - } - - updated, _ := store.GetByPaymentRef(context.Background(), "pi-1") - - // Should not be Settled without execution plan - if updated.State != paymodel.PaymentStateSubmitted { - t.Fatalf("expected payment to remain submitted, got %s", updated.State) - } - - if updated.Metadata["gateway_request_idempotency"] != "idem-1" { - t.Fatalf("expected gateway_request_idempotency metadata") - } - - if updated.Metadata["gateway_operation_result"] != string(rail.OperationResultSuccess) { - t.Fatalf("expected gateway_operation_result metadata") - } -} - -func TestGatewayExecutionRejectedFailsPayment(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - store := newHelperPaymentStore() - - payment := &paymodel.Payment{ - PaymentRef: "pi-2", State: paymodel.PaymentStateSubmitted, IdempotencyKey: "idem-1", - PaymentPlan: &paymodel.PaymentPlan{ - Steps: []*paymodel.PaymentStep{ - {StepID: "crypto_send"}, - }, - }, - ExecutionPlan: &paymodel.ExecutionPlan{ - Steps: []*paymodel.ExecutionStep{ - {Code: "crypto_send", OperationRef: "s1", State: paymodel.OperationStateWaiting, TransferRef: "trn-1"}, - }, - }, - } - - if err := store.Create(context.Background(), payment); err != nil { - t.Fatalf("failed to seed payment: %v", err) - } - - svc := &Service{ - logger: logger, - storage: stubRepo{payments: store}, - } - - exec := &model.PaymentGatewayExecution{ - PaymentRef: "pi-2", - OperationRef: "s1", - TransferRef: "trn-1", - Status: rail.OperationResultFailed, - Error: "execution_plan_failed", - } - - if err := svc.onGatewayExecution(context.Background(), exec); err != nil { - t.Fatalf("onGatewayExecution error: %v", err) - } - - updated, _ := store.GetByPaymentRef(context.Background(), "pi-2") - - if updated.State != paymodel.PaymentStateFailed { - t.Fatalf("expected payment failed, got %s", updated.State) - } - - if updated.FailureReason != "execution_plan_failed" { - t.Fatalf("expected failure reason execution_plan_failed, got %q", updated.FailureReason) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go deleted file mode 100644 index 19b60785..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go +++ /dev/null @@ -1,117 +0,0 @@ -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 { - key := model.GatewayDescriptorIdentityKey(gw) - if key == "" { - continue - } - items[key] = 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 model.LessGatewayDescriptor(result[i], result[j]) - }) - 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/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go deleted file mode 100644 index adf3ad86..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - "github.com/tech/sendico/payments/storage/model" -) - -type identityGatewayRegistryStub struct { - items []*model.GatewayInstanceDescriptor -} - -func (s identityGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} - -func TestGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) { - registry := NewGatewayRegistry(nil, []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"}, - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"}, - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a-new"}, - }) - if registry == nil { - t.Fatalf("expected registry to be created") - } - - items, err := registry.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got, want := len(items), 2; got != want { - t.Fatalf("unexpected items count: got=%d want=%d", got, want) - } - if got, want := items[0].InstanceID, "inst-a"; got != want { - t.Fatalf("unexpected first instance id: got=%q want=%q", got, want) - } - if got, want := items[0].InvokeURI, "grpc://a-new"; got != want { - t.Fatalf("expected latest duplicate to win for same gateway+instance: got=%q want=%q", got, want) - } - if got, want := items[1].InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected second instance id: got=%q want=%q", got, want) - } -} - -func TestCompositeGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) { - registry := NewCompositeGatewayRegistry(nil, - identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"}, - }}, - identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"}, - }}, - ) - if registry == nil { - t.Fatalf("expected registry to be created") - } - - items, err := registry.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got, want := len(items), 2; got != want { - t.Fatalf("unexpected items count: got=%d want=%d", got, want) - } - if got, want := items[0].InstanceID, "inst-a"; got != want { - t.Fatalf("unexpected first instance id: got=%q want=%q", got, want) - } - if got, want := items[1].InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected second instance id: got=%q want=%q", got, want) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go deleted file mode 100644 index b92d92b7..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go +++ /dev/null @@ -1,142 +0,0 @@ -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 - } - 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 - }) - if instanceID != "" { - for _, entry := range eligible { - if strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) { - return entry, nil - } - } - } - 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/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go deleted file mode 100644 index f172b03e..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ /dev/null @@ -1,405 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "strings" - - "github.com/google/uuid" - "github.com/tech/sendico/payments/orchestrator/internal/service/shared" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "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/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -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, quotestorage.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) - } - if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_executable", merrors.InvalidArgument(note)) - } - - intents := record.Intents - quotes := record.Quotes - plans := record.Plans - if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified { - intents = []model.PaymentIntent{record.Intent} - } - if len(quotes) == 0 && record.Quote != nil { - quotes = []*model.PaymentQuoteSnapshot{record.Quote} - } - if len(plans) == 0 && record.Plan != nil { - plans = []*model.PaymentPlan{record.Plan} - } - if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete")) - } - if len(plans) > 0 && len(plans) != len(intents) { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plans are incomplete")) - } - - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - payments := make([]*sharedv1.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 := shared.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) - var plan *model.PaymentPlan - if i < len(plans) { - plan = plans[i] - } - if plan == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plans are incomplete")) - } - attachStoredPlan(entity, plan, perKey) - if err = store.Create(ctx, entity); err != nil { - if errors.Is(err, storage.ErrDuplicatePayment) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - 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, plan, 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_not_executable": - 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 = &sharedv1.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 plan == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plan is required")) - } - attachStoredPlan(entity, plan, idempotencyKey) - - if err = store.Create(ctx, entity); err != nil { - if errors.Is(err, storage.ErrDuplicatePayment) { - 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")) - } - _, 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 := &sharedv1.PaymentIntent{ - Ref: uuid.New().String(), - Kind: sharedv1.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, resolvedIntent, plan, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ - OrgRef: req.GetMeta().GetOrganizationRef(), - OrgID: orgID, - Meta: req.GetMeta(), - Intent: intentProto, - IdempotencyKey: req.GetIdempotencyKey(), - }) - if err != nil { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if quote == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote is required")) - } - if resolvedIntent == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required")) - } - if plan == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plan is required")) - } - - entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quote) - attachStoredPlan(entity, plan, idempotencyKey) - - 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/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go deleted file mode 100644 index 17535a63..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go +++ /dev/null @@ -1,318 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/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/orchestrator/internal/service/orchestrator/handlers_queries.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go deleted file mode 100644 index b190b7f2..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go +++ /dev/null @@ -1,81 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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([]*sharedv1.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/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go deleted file mode 100644 index 28717bff..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ /dev/null @@ -1,299 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/shopspring/decimal" - "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" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - - 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" - paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" -) - -type moneyGetter interface { - GetAmount() string - GetCurrency() string -} - -const ( - feeLineMetaTarget = "fee_target" - feeLineTargetWallet = "wallet" -) - -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 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 paymentv1.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 paymentv1.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 paymentv1.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 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 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 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 *sharedv1.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/orchestrator/internal/service/orchestrator/helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go deleted file mode 100644 index cb49ab5a..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package orchestrator - -import ( - "testing" - - 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" - paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" -) - -func TestResolveTradeAmountsBuyBase(t *testing.T) { - fxQuote := &oraclev1.Quote{ - Side: fxv1.Side_BUY_BASE_SELL_QUOTE, - Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"}, - BaseAmount: &moneyv1.Money{ - Currency: "EUR", - Amount: "100", - }, - QuoteAmount: &moneyv1.Money{ - Currency: "USD", - Amount: "110", - }, - } - - pay, settle := resolveTradeAmounts(nil, fxQuote, fxv1.Side_SIDE_UNSPECIFIED) - if pay.GetCurrency() != "USD" || pay.GetAmount() != "110" { - t.Fatalf("expected pay amount in USD 110, got %s %s", pay.GetCurrency(), pay.GetAmount()) - } - if settle.GetCurrency() != "EUR" || settle.GetAmount() != "100" { - t.Fatalf("expected settlement in EUR 100, got %s %s", settle.GetCurrency(), settle.GetAmount()) - } -} - -func TestComputeAggregatesConvertsCurrencies(t *testing.T) { - pay := &moneyv1.Money{Currency: "USD", Amount: "100"} - settle := &moneyv1.Money{Currency: "EUR", Amount: "50"} - fee := &moneyv1.Money{Currency: "USD", Amount: "10"} - network := &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "USD", Amount: "5"}, - } - fxQuote := &oraclev1.Quote{ - Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"}, - Side: fxv1.Side_BUY_BASE_SELL_QUOTE, - Price: &moneyv1.Decimal{ - Value: "2", - }, - } - - debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED) - if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" { - t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount()) - } - if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "50" { - t.Fatalf("expected settlement 50 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount()) - } -} - -func TestComputeAggregatesRecipientPaysFee(t *testing.T) { - pay := &moneyv1.Money{Currency: "USDT", Amount: "100"} - settle := &moneyv1.Money{Currency: "RUB", Amount: "7932"} // 100 * 79.32 - fee := &moneyv1.Money{Currency: "USDT", Amount: "7"} // 7% of 100 - fxQuote := &oraclev1.Quote{ - Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - Price: &moneyv1.Decimal{ - Value: "79.32", - }, - } - - debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE) - if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" { - t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount()) - } - if settlement.GetCurrency() != "RUB" || settlement.GetAmount() != "7376.76" { - t.Fatalf("expected settlement 7376.76 RUB, got %s %s", settlement.GetCurrency(), settlement.GetAmount()) - } -} - -func TestLedgerChargesFromFeeLinesSkipsWalletTarget(t *testing.T) { - lines := []*feesv1.DerivedPostingLine{ - { - LedgerAccountRef: "ledger:fees", - Money: &moneyv1.Money{Currency: "USDT", Amount: "0.7"}, - LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, - Meta: map[string]string{ - feeLineMetaTarget: feeLineTargetWallet, - }, - }, - { - LedgerAccountRef: "ledger:fees", - Money: &moneyv1.Money{Currency: "USDT", Amount: "1.0"}, - LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, - }, - } - - charges := ledgerChargesFromFeeLines(lines) - if len(charges) != 1 { - t.Fatalf("expected 1 ledger charge, got %d", len(charges)) - } - if charges[0].GetMoney().GetAmount() != "1.0" { - t.Fatalf("expected remaining charge amount 1.0, got %s", charges[0].GetMoney().GetAmount()) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go deleted file mode 100644 index 062e1f27..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go +++ /dev/null @@ -1,68 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/mservice" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func (s *Service) ensureRepository(ctx context.Context) error { - if s.storage == nil { - return errStorageUnavailable - } - return s.storage.Ping(ctx) -} - -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 shouldEstimateNetworkFee(intent *sharedv1.PaymentIntent) bool { - if intent == nil { - return false - } - dest := intent.GetDestination() - if dest == nil { - return false - } - if dest.GetCard() != nil { - return false - } - if intent.GetKind() == sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT { - return true - } - if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil { - return true - } - return false -} - -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/orchestrator/internal/service/orchestrator/internal_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go deleted file mode 100644 index ad9395ef..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package orchestrator - -import ( - "testing" - - "github.com/tech/sendico/payments/storage/model" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) { - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, - Destination: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Card{ - Card: &sharedv1.CardEndpoint{}, - }, - }, - } - if shouldEstimateNetworkFee(intent) { - t.Fatalf("expected network fee estimation to be skipped for card payouts") - } -} - -func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) { - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Destination: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_ManagedWallet{ - ManagedWallet: &sharedv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"}, - }, - }, - } - if !shouldEstimateNetworkFee(intent) { - t.Fatalf("expected network fee estimation when destination is managed wallet") - } -} - -func TestMapMntxStatusToState(t *testing.T) { - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS) != model.PaymentStateSettled { - t.Fatalf("processed should map to settled") - } - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed { - t.Fatalf("failed should map to failed") - } - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING) != model.PaymentStateSubmitted { - t.Fatalf("pending should map to submitted") - } - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified { - t.Fatalf("unspecified should map to unspecified") - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/metrics.go b/api/payments/orchestrator/internal/service/orchestrator/metrics.go deleted file mode 100644 index 417eb90e..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/metrics.go +++ /dev/null @@ -1,65 +0,0 @@ -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/orchestrator/internal/service/orchestrator/model_money.go b/api/payments/orchestrator/internal/service/orchestrator/model_money.go deleted file mode 100644 index 3a8184d3..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/model_money.go +++ /dev/null @@ -1,13 +0,0 @@ -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/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index f404b9d2..1b3591e2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -2,27 +2,24 @@ package orchestrator import ( "context" - "sort" "strings" "time" - "github.com/shopspring/decimal" chainclient "github.com/tech/sendico/gateway/chain/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" "github.com/tech/sendico/payments/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" - "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/discovery" 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" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - "go.uber.org/zap" ) -// Option configures service dependencies. +// Option configures Service construction. +// +// Orchestration runtime is v2-only; legacy option knobs are retained as no-op +// compatibility shims for server wiring. type Option func(*Service) // GatewayInvokeResolver resolves gateway invoke URIs into chain gateway clients. @@ -30,240 +27,9 @@ 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 quotationDependency struct { - client quotationv1.QuotationServiceClient -} - -func (q quotationDependency) available() bool { - if q.client == nil { - return false - } - if checker, ok := q.client.(interface{ Available() bool }); ok { - return checker.Available() - } - return true -} - -type feesDependency struct { - client feesv1.FeeEngineClient - timeout time.Duration -} - -type ledgerDependency struct { - client ledgerclient.Client - internal rail.InternalLedger -} - -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.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 model.LessGatewayDescriptor(candidates[i], candidates[j]) - }) - entry, selectionMode := model.SelectGatewayByPreference( - candidates, - step.GatewayID, - step.InstanceID, - step.GatewayInvokeURI, - ) - if entry == nil { - entry = candidates[0] - selectionMode = "rail_fallback" - } - invokeURI := strings.TrimSpace(entry.InvokeURI) - if invokeURI == "" { - return nil, merrors.InvalidArgument("rail gateway: invoke uri is required") - } - originalGatewayID := strings.TrimSpace(step.GatewayID) - originalInstanceID := strings.TrimSpace(step.InstanceID) - originalInvokeURI := strings.TrimSpace(step.GatewayInvokeURI) - step.GatewayID = strings.TrimSpace(entry.ID) - step.InstanceID = strings.TrimSpace(entry.InstanceID) - step.GatewayInvokeURI = invokeURI - g.logger.Debug("Rail gateway candidate selected", - zap.String("step_id", strings.TrimSpace(step.StepID)), - zap.String("selection_mode", selectionMode), - zap.String("requested_gateway_id", originalGatewayID), - zap.String("requested_instance_id", originalInstanceID), - zap.String("requested_invoke_uri", originalInvokeURI), - zap.String("resolved_gateway_id", step.GatewayID), - zap.String("resolved_instance_id", step.InstanceID), - zap.String("resolved_invoke_uri", step.GatewayInvokeURI), - ) - - 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, - }, - } - - if selectionMode != "exact" && (originalGatewayID != "" || originalInstanceID != "" || originalInvokeURI != "") { - g.logger.Warn("Rail gateway identity fallback applied", - zap.String("step_id", strings.TrimSpace(step.StepID)), - zap.String("selection_mode", selectionMode), - zap.String("requested_gateway_id", originalGatewayID), - zap.String("requested_instance_id", originalInstanceID), - zap.String("requested_invoke_uri", originalInvokeURI), - zap.String("resolved_gateway_id", step.GatewayID), - zap.String("resolved_instance_id", step.InstanceID), - zap.String("resolved_invoke_uri", step.GatewayInvokeURI), - ) - } - g.logger.Info("Rail gateway resolved", - zap.String("step_id", strings.TrimSpace(step.StepID)), - zap.String("action", string(step.Action)), - zap.String("selection_mode", selectionMode), - 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 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 +// GatewayRegistry exposes gateway descriptors. +type GatewayRegistry interface { + List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) } // CardGatewayRoute maps a gateway to its funding and fee destinations. @@ -273,204 +39,169 @@ type CardGatewayRoute struct { 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, - } - } +// WithFeeEngine is retained for backward-compatible wiring and is currently a no-op. +func WithFeeEngine(_ feesv1.FeeEngineClient, _ time.Duration) Option { + return func(*Service) {} } -func WithPaymentGatewayBroker(broker mb.Broker) Option { - return func(s *Service) { - if broker != nil { - s.gatewayBroker = broker - } - } +// WithLedgerClient is retained for backward-compatible wiring and is currently a no-op. +func WithLedgerClient(_ ledgerclient.Client) Option { + return func(*Service) {} } -// WithQuotationService wires the quotation gRPC client. -func WithQuotationService(client quotationv1.QuotationServiceClient) Option { - return func(s *Service) { - s.deps.quotation = quotationDependency{client: client} - } +// WithMntxGateway is retained for backward-compatible wiring and is currently a no-op. +func WithMntxGateway(_ mntxclient.Client) Option { + return func(*Service) {} } -// WithLedgerClient wires the ledger client. -func WithLedgerClient(client ledgerclient.Client) Option { - return func(s *Service) { - s.deps.ledger = ledgerDependency{ - client: client, - internal: client, - } - } +// WithPaymentGatewayBroker is retained for backward-compatible wiring and is currently a no-op. +func WithPaymentGatewayBroker(_ mb.Broker) Option { + return func(*Service) {} } -// WithChainGatewayClient wires the chain gateway client. -func WithChainGatewayClient(client chainclient.Client) Option { - return func(s *Service) { - s.deps.gateway = gatewayDependency{resolver: staticChainGatewayResolver{client: client}} - } +// WithClock is retained for backward-compatible wiring and is currently a no-op. +func WithClock(_ clockpkg.Clock) Option { + return func(*Service) {} } -// 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} - } - } +// WithMaxFXQuoteTTLMillis is retained for backward-compatible wiring and is currently a no-op. +func WithMaxFXQuoteTTLMillis(_ int64) Option { + return func(*Service) {} } -// 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}} - } +// WithGatewayInvokeResolver is retained for backward-compatible wiring and is currently a no-op. +func WithGatewayInvokeResolver(_ GatewayInvokeResolver) Option { + return func(*Service) {} } -// 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} - } - } +// WithCardGatewayRoutes is retained for backward-compatible wiring and is currently a no-op. +func WithCardGatewayRoutes(_ map[string]CardGatewayRoute) Option { + return func(*Service) {} } -// 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 - } +// WithFeeLedgerAccounts is retained for backward-compatible wiring and is currently a no-op. +func WithFeeLedgerAccounts(_ map[string]string) Option { + return func(*Service) {} } -// 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) - } +// WithGatewayRegistry is retained for backward-compatible wiring and is currently a no-op. +func WithGatewayRegistry(_ GatewayRegistry) Option { + return func(*Service) {} } -// WithMntxGateway wires the Monetix gateway client. -func WithMntxGateway(client mntxclient.Client) Option { - return func(s *Service) { - s.deps.mntx = mntxDependency{client: client} - } +type discoveryGatewayRegistry struct { + registry *discovery.Registry } -// 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 - } +// NewDiscoveryGatewayRegistry adapts discovery registry entries to gateway descriptors. +func NewDiscoveryGatewayRegistry(_ mlogger.Logger, registry *discovery.Registry) GatewayRegistry { + if registry == nil { + return nil } + return &discoveryGatewayRegistry{registry: registry} } -// 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 - } - } -} - -// WithGatewayRegistry wires a registry of gateway instances for routing. -func WithGatewayRegistry(registry plan_builder.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") - } - } -} - -// WithClock overrides the default clock. -func WithClock(clock clockpkg.Clock) Option { - return func(s *Service) { - if clock != nil { - s.clock = clock - } - } -} - -// WithMaxFXQuoteTTLMillis caps forwarded FX quote TTL requests. -func WithMaxFXQuoteTTLMillis(value int64) Option { - return func(s *Service) { - if value > 0 { - s.maxFXQuoteTTLMillis = value - } - } -} - -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 +func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if r == nil || r.registry == nil { + return nil, nil } - 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 { + entries := r.registry.List(time.Now(), true) + items := make([]*model.GatewayInstanceDescriptor, 0, len(entries)) + for _, entry := range entries { + rail := railFromDiscovery(entry.Rail) + if rail == model.RailUnspecified { 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 + operations := operationsFromDiscovery(entry.Operations) + items = append(items, &model.GatewayInstanceDescriptor{ + ID: strings.TrimSpace(entry.ID), + InstanceID: strings.TrimSpace(entry.InstanceID), + Rail: rail, + Network: strings.ToUpper(strings.TrimSpace(entry.Network)), + InvokeURI: strings.TrimSpace(entry.InvokeURI), + Currencies: currenciesFromDiscovery(entry.Currencies), + Operations: operations, + Capabilities: model.RailCapabilitiesFromOperations(operations), + Limits: limitsFromDiscovery(entry.Limits), + IsEnabled: entry.Healthy, }) - for _, entry := range items { - result.byRail[railID] = append(result.byRail[railID], entry.gw) - } } + return items, nil +} +func railFromDiscovery(value string) model.Rail { + switch discovery.NormalizeRail(value) { + case discovery.RailCrypto: + return model.RailCrypto + case discovery.RailProviderSettlement: + return model.RailProviderSettlement + case discovery.RailLedger: + return model.RailLedger + case discovery.RailCardPayout: + return model.RailCardPayout + case discovery.RailFiatOnRamp: + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} + +func operationsFromDiscovery(values []string) []model.RailOperation { + return model.NormalizeRailOperationStrings(discovery.NormalizeRailOperations(values)) +} + +func currenciesFromDiscovery(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + currency := strings.ToUpper(strings.TrimSpace(value)) + if currency == "" || seen[currency] { + continue + } + seen[currency] = true + result = append(result, currency) + } + if len(result) == 0 { + return nil + } return result } + +func limitsFromDiscovery(src *discovery.Limits) model.Limits { + limits := model.Limits{} + if src == nil { + return limits + } + + limits.MinAmount = strings.TrimSpace(src.MinAmount) + limits.MaxAmount = strings.TrimSpace(src.MaxAmount) + + if len(src.VolumeLimit) > 0 { + limits.VolumeLimit = map[string]string{} + for bucket, value := range src.VolumeLimit { + key := strings.TrimSpace(bucket) + amount := strings.TrimSpace(value) + if key == "" || amount == "" { + continue + } + limits.VolumeLimit[key] = amount + } + } + + if len(src.VelocityLimit) > 0 { + limits.VelocityLimit = map[string]int{} + for bucket, value := range src.VelocityLimit { + key := strings.TrimSpace(bucket) + if key == "" || value <= 0 { + continue + } + limits.VelocityLimit[key] = value + } + } + + return limits +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go b/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go deleted file mode 100644 index 11aac9df..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/payments/storage/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - "go.uber.org/zap" -) - -type optionsGatewayRegistryStub struct { - items []*model.GatewayInstanceDescriptor -} - -func (s optionsGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} - -type optionsInvokeResolverStub struct { - uris []string -} - -func (s *optionsInvokeResolverStub) Resolve(_ context.Context, invokeURI string) (chainclient.Client, error) { - s.uris = append(s.uris, invokeURI) - return &chainclient.Fake{}, nil -} - -func TestResolveDynamicGateway_FallsBackToInvokeURI(t *testing.T) { - resolver := &optionsInvokeResolverStub{} - deps := railGatewayDependency{ - registry: optionsGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - { - ID: "aaa", - InstanceID: "inst-a", - Rail: model.RailCrypto, - Network: "TRON", - InvokeURI: "grpc://gw-a:50051", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - IsEnabled: true, - }, - { - ID: "bbb", - InstanceID: "inst-b", - Rail: model.RailCrypto, - Network: "TRON", - InvokeURI: "grpc://gw-b:50051", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - IsEnabled: true, - }, - }}, - chainResolver: resolver, - logger: zap.NewNop(), - } - step := &model.PaymentStep{ - StepID: "crypto.send", - Rail: model.RailCrypto, - Action: model.RailOperationSend, - GatewayID: "legacy-id", - InstanceID: "legacy-instance", - GatewayInvokeURI: "grpc://gw-b:50051", - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1"}, - } - - if _, err := deps.resolveDynamic(context.Background(), step); err != nil { - t.Fatalf("resolveDynamic returned error: %v", err) - } - if got, want := step.GatewayID, "bbb"; got != want { - t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want) - } - if got, want := step.InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected instance_id: got=%q want=%q", got, want) - } - if got, want := step.GatewayInvokeURI, "grpc://gw-b:50051"; got != want { - t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want) - } - if len(resolver.uris) != 1 || resolver.uris[0] != "grpc://gw-b:50051" { - t.Fatalf("unexpected resolver invocations: %#v", resolver.uris) - } -} - -func TestResolveDynamicGateway_FallsBackToGatewayIDWhenInstanceChanges(t *testing.T) { - resolver := &optionsInvokeResolverStub{} - deps := railGatewayDependency{ - registry: optionsGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - { - ID: "aaa", - InstanceID: "inst-a", - Rail: model.RailCrypto, - Network: "TRON", - InvokeURI: "grpc://gw-a:50051", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - IsEnabled: true, - }, - { - ID: "crypto_rail_gateway_tron", - InstanceID: "inst-new", - Rail: model.RailCrypto, - Network: "TRON", - InvokeURI: "grpc://gw-tron:50051", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - IsEnabled: true, - }, - }}, - chainResolver: resolver, - logger: zap.NewNop(), - } - step := &model.PaymentStep{ - StepID: "crypto.send", - Rail: model.RailCrypto, - Action: model.RailOperationSend, - GatewayID: "crypto_rail_gateway_tron", - InstanceID: "inst-old", - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1"}, - } - - if _, err := deps.resolveDynamic(context.Background(), step); err != nil { - t.Fatalf("resolveDynamic returned error: %v", err) - } - if got, want := step.GatewayID, "crypto_rail_gateway_tron"; got != want { - t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want) - } - if got, want := step.InstanceID, "inst-new"; got != want { - t.Fatalf("unexpected instance_id: got=%q want=%q", got, want) - } - if got, want := step.GatewayInvokeURI, "grpc://gw-tron:50051"; got != want { - t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want) - } - if len(resolver.uris) != 1 || resolver.uris[0] != "grpc://gw-tron:50051" { - t.Fatalf("unexpected resolver invocations: %#v", resolver.uris) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go deleted file mode 100644 index 1ef737a4..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ /dev/null @@ -1,173 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 *sharedv1.PaymentQuote) error { - if store == nil { - return errStorageUnavailable - } - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - if payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_missing", merrors.InvalidArgument("payment plan is required")) - } - if strings.TrimSpace(payment.PaymentPlan.ID) == "" { - payment.PaymentPlan.ID = payment.PaymentRef - } - if strings.TrimSpace(payment.PaymentPlan.IdempotencyKey) == "" { - payment.PaymentPlan.IdempotencyKey = payment.IdempotencyKey - } - - return p.executePaymentPlan(ctx, store, payment, quote) -} - -func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *sharedv1.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.Warn("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/orchestrator/internal/service/orchestrator/payment_plan_card.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go deleted file mode 100644 index 2a1fb32a..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go +++ /dev/null @@ -1,196 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" -) - -func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *model.Payment, operationRef string, amount *moneyv1.Money, fromRole, toRole *account_role.AccountRole) (string, error) { - if payment == nil { - return "", merrors.InvalidArgument("payment is required") - } - if !p.deps.mntx.available() { - return "", merrors.Internal("card_gateway_unavailable") - } - intent := payment.Intent - card := intent.Destination.Card - if card == nil { - return "", merrors.InvalidArgument("card payout: card endpoint is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("card payout: amount is required") - } - - amtDec, err := decimalFromMoney(amount) - if err != nil { - return "", err - } - minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() - - payoutID := payment.PaymentRef - currency := strings.TrimSpace(amount.GetCurrency()) - holder := strings.TrimSpace(card.Cardholder) - meta := cloneMetadata(payment.Metadata) - if strings.TrimSpace(string(mergeAccountRole(fromRole))) != "" { - if meta == nil { - meta = map[string]string{} - } - meta[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(mergeAccountRole(fromRole))) - } - if strings.TrimSpace(string(mergeAccountRole(toRole))) != "" { - if meta == nil { - meta = map[string]string{} - } - meta[account_role.MetadataKeyToRole] = strings.TrimSpace(string(mergeAccountRole(toRole))) - } - customer := intent.Customer - customerID := "" - customerFirstName := "" - customerMiddleName := "" - customerLastName := "" - customerIP := "" - customerZip := "" - customerCountry := "" - customerState := "" - customerCity := "" - customerAddress := "" - if customer != nil { - customerID = strings.TrimSpace(customer.ID) - customerFirstName = strings.TrimSpace(customer.FirstName) - customerMiddleName = strings.TrimSpace(customer.MiddleName) - customerLastName = strings.TrimSpace(customer.LastName) - customerIP = strings.TrimSpace(customer.IP) - customerZip = strings.TrimSpace(customer.Zip) - customerCountry = strings.TrimSpace(customer.Country) - customerState = strings.TrimSpace(customer.State) - customerCity = strings.TrimSpace(customer.City) - customerAddress = strings.TrimSpace(customer.Address) - } - if customerFirstName == "" { - customerFirstName = strings.TrimSpace(card.Cardholder) - } - if customerLastName == "" { - customerLastName = strings.TrimSpace(card.CardholderSurname) - } - if customerID == "" { - return "", merrors.InvalidArgument("card payout: customer id is required") - } - if customerFirstName == "" { - return "", merrors.InvalidArgument("card payout: customer first name is required") - } - if customerLastName == "" { - return "", merrors.InvalidArgument("card payout: customer last name is required") - } - if customerIP == "" { - return "", merrors.InvalidArgument("card payout: customer ip is required") - } - - var state *mntxv1.CardPayoutState - if token := strings.TrimSpace(card.Token); token != "" { - req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardToken: token, - CardHolder: holder, - MaskedPan: strings.TrimSpace(card.MaskedPan), - Metadata: meta, - OperationRef: operationRef, - IntentRef: payment.Intent.Ref, - IdempotencyKey: payment.IdempotencyKey, - } - resp, err := p.deps.mntx.client.CreateCardTokenPayout(ctx, req) - if err != nil { - return "", err - } - state = resp.GetPayout() - } else if pan := strings.TrimSpace(card.Pan); pan != "" { - req := &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardPan: pan, - CardExpYear: card.ExpYear, - CardExpMonth: card.ExpMonth, - CardHolder: holder, - Metadata: meta, - OperationRef: operationRef, - IntentRef: payment.Intent.Ref, - IdempotencyKey: payment.IdempotencyKey, - } - resp, err := p.deps.mntx.client.CreateCardPayout(ctx, req) - if err != nil { - return "", err - } - state = resp.GetPayout() - } else { - return "", merrors.InvalidArgument("card payout: either token or pan must be provided") - } - - if state == nil { - return "", merrors.Internal("card payout: missing payout state") - } - recordCardPayoutState(payment, state) - exec := ensureExecutionRefs(payment) - if exec.CardPayoutRef == "" { - exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) - } - return exec.CardPayoutRef, nil -} - -func mergeAccountRole(role *account_role.AccountRole) account_role.AccountRole { - if role == nil { - return "" - } - return account_role.AccountRole(strings.TrimSpace(string(*role))) -} - -func (p *paymentExecutor) resolveCardRoute(intent model.PaymentIntent) (CardGatewayRoute, error) { - if p.svc != nil { - return p.svc.cardRoute(p.gatewayKeyFromIntent(intent)) - } - key := p.gatewayKeyFromIntent(intent) - route, ok := p.deps.cardRoutes[key] - if !ok { - return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) - } - if strings.TrimSpace(route.FundingAddress) == "" { - return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) - } - return route, nil -} - -func (p *paymentExecutor) gatewayKeyFromIntent(intent model.PaymentIntent) string { - key := strings.TrimSpace(intent.Attributes["gateway"]) - if key == "" && intent.Destination.Card != nil { - key = defaultCardGateway - } - return strings.ToLower(key) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go deleted file mode 100644 index c97deb9b..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go +++ /dev/null @@ -1,116 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amount *paymenttypes.Money, action model.RailOperation, idempotencyKey, operationRef string, quote *sharedv1.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/orchestrator/internal/service/orchestrator/payment_plan_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go deleted file mode 100644 index 671d20c3..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go +++ /dev/null @@ -1,200 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 (p *paymentExecutor) pickIndependentSteps( - ctx context.Context, - l *zap.Logger, - store storage.PaymentsStore, - waiting []*model.ExecutionStep, - payment *model.Payment, - quote *sharedv1.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 *sharedv1.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 *sharedv1.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/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go deleted file mode 100644 index 2a45114b..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - mntxclient "github.com/tech/sendico/gateway/mntx/client" - ledgerclient "github.com/tech/sendico/ledger/client" - "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" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { - ctx := context.Background() - - store := newStubPaymentsStore() - repo := &stubRepository{store: store} - - transferRefs := []string{"send-1", "fee-1"} - sendCalls := 0 - railGateway := &fakeRailGateway{ - rail: "CRYPTO", - sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { - ref := transferRefs[sendCalls] - sendCalls++ - return rail.RailResult{ReferenceID: ref, Status: rail.TransferStatusWaiting}, nil - }, - } - - moveCalls := 0 - pendingAccountID := "ledger:pending" - operatingAccountID := "ledger:operating" - transitAccountID := "ledger:transit" - ledgerFake := &ledgerclient.Fake{ - ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { - details, _ := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_PENDING", - }) - detailsOperating, _ := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_OPERATING", - }) - detailsTransit, _ := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_TRANSIT", - }) - return &connectorv1.ListAccountsResponse{ - Accounts: []*connectorv1.Account{ - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: pendingAccountID}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USDT", ProviderDetails: details}, - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: operatingAccountID}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USDT", ProviderDetails: detailsOperating}, - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: transitAccountID}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USDT", ProviderDetails: detailsTransit}, - }, - }, nil - }, - TransferInternalFn: func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - moveCalls++ - return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil - }, - } - - payoutCalls := 0 - mntxFake := &mntxclient.Fake{ - CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { - payoutCalls++ - return &mntxv1.CardPayoutResponse{Payout: &mntxv1.CardPayoutState{PayoutId: "payout-1"}}, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - storage: repo, - deps: serviceDependencies{ - railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{ - "crypto-default": railGateway, - }, nil, nil, nil, nil), - ledger: ledgerDependency{ - client: ledgerFake, - internal: ledgerFake, - }, - mntx: mntxDependency{client: mntxFake}, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: "funding-address", - FeeWalletRef: "fee-wallet", - }, - }, - }, - } - - executor := newPaymentExecutor(&svc.deps, svc.logger, svc) - - payment := &model.Payment{ - PaymentRef: "pay-plan-1", - IdempotencyKey: "pay-plan-1", - OrganizationBoundBase: mo.OrganizationBoundBase{ - OrganizationRef: bson.NewObjectID(), - }, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-src", - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - Pan: "4111111111111111", - Cardholder: "Ada", - CardholderSurname: "Lovelace", - ExpMonth: 1, - ExpYear: 2030, - MaskedPan: "4111", - }, - }, - Attributes: map[string]string{ - "ledger_credit_account_ref": "ledger:credit", - "ledger_debit_account_ref": "ledger:debit", - }, - Customer: &model.Customer{ - ID: "cust-1", - FirstName: "Ada", - LastName: "Lovelace", - IP: "1.2.3.4", - }, - }, - PaymentPlan: &model.PaymentPlan{ - ID: "pay-plan-1", - IdempotencyKey: "pay-plan-1", - Steps: []*model.PaymentStep{ - {StepID: "crypto_send", Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}}, - {StepID: "crypto_fee", Rail: model.RailCrypto, Action: model.RailOperationFee, DependsOn: []string{"crypto_send"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}}, - {StepID: "crypto_observe", Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm, DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Action: model.RailOperationMove, DependsOn: []string{"crypto_observe"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, - {StepID: "card_payout", Rail: model.RailCardPayout, Action: model.RailOperationSend, DependsOn: []string{"ledger_credit"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, - {StepID: "ledger_debit", Rail: model.RailLedger, Action: model.RailOperationMove, DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, - }, - }, - } - - store.payments[payment.PaymentRef] = payment - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan error: %v", err) - } - - if payment.Execution == nil || payment.Execution.ChainTransferRef == "" { - t.Fatalf("expected chain transfer ref set") - } - if payment.Execution.FeeTransferRef != "" { - t.Fatalf("fee must NOT be executed before send success") - } - - steps := executionStepsByCode(payment.ExecutionPlan) - - if steps["crypto_send"].State != model.OperationStateWaiting { - t.Fatalf("send must be waiting") - } - if steps["crypto_fee"].State != model.OperationStatePlanned { - t.Fatalf("fee must NOT start before send success") - } - if steps["crypto_observe"].State != model.OperationStatePlanned { - t.Fatalf("observe must NOT start before send success") - } - - // ---- имитируем подтверждение сети по crypto_send ---- - setExecutionStepStatus(steps["crypto_send"], model.OperationStateSuccess) - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan resume error: %v", err) - } - - // Теперь должны стартовать fee и observe - if steps["crypto_fee"].State != model.OperationStateWaiting { - t.Fatalf("fee must start after send success") - } - if steps["crypto_observe"].State != model.OperationStateWaiting { - t.Fatalf("observe must start after send success") - } - - // Имитируем подтверждение observe (это unlock ledger_credit) - setExecutionStepStatus(steps["crypto_observe"], model.OperationStateSuccess) - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan resume after observe error: %v", err) - } - - if moveCalls != 1 { - t.Fatalf("expected one ledger move after observe confirmation, got %d", moveCalls) - } - if payoutCalls != 1 { - t.Fatalf("expected card payout submitted, got %d", payoutCalls) - } - - // Mock card payout confirmation - cardStep := executionStepsByCode(payment.ExecutionPlan)["card_payout"] - setExecutionStepStatus(cardStep, model.OperationStateSuccess) - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan finalize error: %v", err) - } - - if moveCalls != 2 { - t.Fatalf("expected two ledger moves after payout confirmation, got %d", moveCalls) - } - -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go deleted file mode 100644 index 9cb753c4..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go +++ /dev/null @@ -1,596 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/ledgerconv" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 *sharedv1.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 *sharedv1.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 *sharedv1.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/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go deleted file mode 100644 index 88525f2a..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - ledgerclient "github.com/tech/sendico/ledger/client" - "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" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestLedgerAccountResolution_UsesRoleAccounts(t *testing.T) { - ctx := context.Background() - fromAccountID := "ledger:operating:usd" - toAccountID := "ledger:transit:usd" - - operatingDetails, err := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_OPERATING", - }) - if err != nil { - t.Fatalf("provider details build error: %v", err) - } - transitDetails, err := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_TRANSIT", - }) - if err != nil { - t.Fatalf("provider details build error: %v", err) - } - - listCalls := 0 - var transferReq *ledgerv1.TransferRequest - ledgerFake := &ledgerclient.Fake{ - ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { - listCalls++ - return &connectorv1.ListAccountsResponse{ - Accounts: []*connectorv1.Account{ - { - Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: fromAccountID}, - Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, - Asset: "USD", - OwnerRef: "", - ProviderDetails: operatingDetails, - }, - { - Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: toAccountID}, - Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, - Asset: "USD", - OwnerRef: "", - ProviderDetails: transitDetails, - }, - }, - }, nil - }, - TransferInternalFn: func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - transferReq = req - return &ledgerv1.PostResponse{JournalEntryRef: "entry-1"}, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - deps: serviceDependencies{ - ledger: ledgerDependency{ - client: ledgerFake, - internal: ledgerFake, - }, - }, - } - executor := newPaymentExecutor(&svc.deps, svc.logger, svc) - - amount := &paymenttypes.Money{Currency: "USD", Amount: "10"} - payment := &model.Payment{ - PaymentRef: "pay-1", - IdempotencyKey: "pay-1", - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - }, - PaymentPlan: &model.PaymentPlan{ - ID: "pay-1", - IdempotencyKey: "pay-1", - Steps: []*model.PaymentStep{ - {StepID: "ledger_move", Rail: model.RailLedger, Action: model.RailOperationMove, Amount: cloneMoney(amount), FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, - }, - }, - } - payment.OrganizationRef = bson.NewObjectID() - - store := newStubPaymentsStore() - store.payments[payment.PaymentRef] = payment - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan error: %v", err) - } - if listCalls == 0 { - t.Fatalf("expected ledger accounts lookup") - } - if transferReq == nil { - t.Fatalf("expected ledger transfer") - } - if transferReq.GetFromLedgerAccountRef() != fromAccountID { - t.Fatalf("expected from account %s, got %s", fromAccountID, transferReq.GetFromLedgerAccountRef()) - } - if transferReq.GetToLedgerAccountRef() != toAccountID { - t.Fatalf("expected to account %s, got %s", toAccountID, transferReq.GetToLedgerAccountRef()) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go deleted file mode 100644 index d0362fb8..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go +++ /dev/null @@ -1,50 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "go.uber.org/zap" -) - -func (p *paymentExecutor) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if store == nil { - return errStorageUnavailable - } - if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return nil - } - execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if execPlan == nil || !blockStepConfirmed(payment.PaymentPlan, execPlan) { - return nil - } - execSteps := executionStepsByCode(execPlan) - execQuote := executionQuote(payment, nil) - - for idx, step := range payment.PaymentPlan.Steps { - if step == nil || step.Action != model.RailOperationRelease { - continue - } - stepID := planStepID(step, idx) - execStep := execSteps[stepID] - if execStep == nil { - execStep = &model.ExecutionStep{Code: stepID} - execSteps[stepID] = execStep - if idx < len(execPlan.Steps) { - execPlan.Steps[idx] = execStep - } - } - if execStep.State == model.OperationStateSuccess { - p.logger.Debug("Payment step already confirmed, skipping", zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef)) - continue - } - if _, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, nil, idx); err != nil { - p.logger.Warn("Failed to execute payment step", zap.Error(err), - zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef)) - return err - } - } - - return p.persistPayment(ctx, store, payment) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go deleted file mode 100644 index be8ed450..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - "testing" - - ledgerclient "github.com/tech/sendico/ledger/client" - "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" - "go.uber.org/zap" -) - -func TestReleasePaymentHold_RejectsLegacyLedgerRelease(t *testing.T) { - ctx := context.Background() - - store := newStubPaymentsStore() - repo := &stubRepository{store: store} - - ledgerFake := &ledgerclient.Fake{} - - svc := &Service{ - logger: zap.NewNop(), - storage: repo, - deps: serviceDependencies{ - ledger: ledgerDependency{ - client: ledgerFake, - internal: ledgerFake, - }, - }, - } - - executor := newPaymentExecutor(&svc.deps, svc.logger, svc) - - payment := &model.Payment{ - PaymentRef: "pay-release-1", - IdempotencyKey: "pay-release-1", - OrganizationBoundBase: mo.OrganizationBoundBase{ - OrganizationRef: bson.NewObjectID(), - }, - Intent: model.PaymentIntent{ - Ref: "ref-release-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-src", - }, - }, - Attributes: map[string]string{ - "ledger_debit_account_ref": "ledger:debit", - "ledger_block_account_ref": "ledger:block", - }, - }, - PaymentPlan: &model.PaymentPlan{ - ID: "pay-release-1", - IdempotencyKey: "pay-release-1", - Steps: []*model.PaymentStep{ - {StepID: "ledger_block", Rail: model.RailLedger, Action: model.RailOperationBlock, Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}}, - {StepID: "ledger_release", Rail: model.RailLedger, Action: model.RailOperationRelease, DependsOn: []string{"ledger_block"}, Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}}, - }, - }, - } - - store.payments[payment.PaymentRef] = payment - - payment.ExecutionPlan = ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - steps := executionStepsByCode(payment.ExecutionPlan) - blockStep := steps["ledger_block"] - if blockStep == nil { - t.Fatalf("expected block step in execution plan") - } - setExecutionStepStatus(blockStep, model.OperationStateSuccess) - - err := executor.releasePaymentHold(ctx, store, payment) - if err == nil { - t.Fatal("expected legacy ledger operation error") - } - if !strings.Contains(err.Error(), "unsupported action") { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go deleted file mode 100644 index ef2a7d4d..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go +++ /dev/null @@ -1,427 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -func (p *paymentExecutor) executePlanStep( - ctx context.Context, - payment *model.Payment, - step *model.PaymentStep, - execStep *model.ExecutionStep, - quote *sharedv1.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 (p *paymentExecutor) executeSendStep( - ctx context.Context, - payment *model.Payment, - step *model.PaymentStep, - execStep *model.ExecutionStep, - quote *sharedv1.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/orchestrator/internal/service/orchestrator/payment_plan_storage.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go deleted file mode 100644 index 2cf52afd..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go +++ /dev/null @@ -1,112 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" -) - -func attachStoredPlan(payment *model.Payment, plan *model.PaymentPlan, idempotencyKey string) { - if payment == nil || plan == nil { - return - } - cloned := cloneStoredPaymentPlan(plan) - if cloned == nil { - return - } - if strings.TrimSpace(cloned.ID) == "" { - cloned.ID = strings.TrimSpace(payment.PaymentRef) - } - if strings.TrimSpace(cloned.IdempotencyKey) == "" { - cloned.IdempotencyKey = strings.TrimSpace(idempotencyKey) - } - payment.PaymentPlan = cloned -} - -func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan { - if src == nil { - return nil - } - clone := &model.PaymentPlan{ - ID: strings.TrimSpace(src.ID), - IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), - CreatedAt: src.CreatedAt, - FXQuote: cloneStoredFXQuote(src.FXQuote), - Fees: cloneStoredFeeLines(src.Fees), - } - if len(src.Steps) > 0 { - clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps)) - for _, step := range src.Steps { - if step == nil { - clone.Steps = append(clone.Steps, nil) - continue - } - stepClone := &model.PaymentStep{ - StepID: strings.TrimSpace(step.StepID), - Rail: step.Rail, - GatewayID: strings.TrimSpace(step.GatewayID), - InstanceID: strings.TrimSpace(step.InstanceID), - GatewayInvokeURI: strings.TrimSpace(step.GatewayInvokeURI), - Action: step.Action, - ReportVisibility: step.ReportVisibility, - DependsOn: cloneStringList(step.DependsOn), - CommitPolicy: step.CommitPolicy, - CommitAfter: cloneStringList(step.CommitAfter), - Amount: cloneMoney(step.Amount), - FromRole: cloneAccountRole(step.FromRole), - ToRole: cloneAccountRole(step.ToRole), - } - clone.Steps = append(clone.Steps, stepClone) - } - } - return clone -} - -func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote { - if src == nil { - return nil - } - result := &paymenttypes.FXQuote{ - QuoteRef: strings.TrimSpace(src.QuoteRef), - Side: src.Side, - ExpiresAtUnixMs: src.ExpiresAtUnixMs, - PricedAtUnixMs: src.PricedAtUnixMs, - Provider: strings.TrimSpace(src.Provider), - RateRef: strings.TrimSpace(src.RateRef), - Firm: src.Firm, - BaseAmount: cloneMoney(src.BaseAmount), - QuoteAmount: cloneMoney(src.QuoteAmount), - } - if src.Pair != nil { - result.Pair = &paymenttypes.CurrencyPair{ - Base: strings.TrimSpace(src.Pair.Base), - Quote: strings.TrimSpace(src.Pair.Quote), - } - } - if src.Price != nil { - result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)} - } - return result -} - -func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine { - if len(lines) == 0 { - return nil - } - result := make([]*paymenttypes.FeeLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - result = append(result, nil) - continue - } - result = append(result, &paymenttypes.FeeLine{ - LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), - Money: cloneMoney(line.Money), - LineType: line.LineType, - Side: line.Side, - Meta: cloneMetadata(line.Meta), - }) - } - return result -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go deleted file mode 100644 index 1691878b..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go +++ /dev/null @@ -1,7 +0,0 @@ -package orchestrator - -import "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" - -// GatewayRegistry re-exports the plan_builder.GatewayRegistry interface for use -// within the orchestrator package (gateway_registry.go, options.go, etc.). -type GatewayRegistry = plan_builder.GatewayRegistry diff --git a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go deleted file mode 100644 index cbebc77d..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go +++ /dev/null @@ -1,132 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 *sharedv1.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 *sharedv1.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/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go deleted file mode 100644 index dbdc8093..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go +++ /dev/null @@ -1,180 +0,0 @@ -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/orchestrator/internal/service/orchestrator/rail_endpoints.go b/api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go deleted file mode 100644 index b9f5c7bb..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go +++ /dev/null @@ -1,99 +0,0 @@ -package orchestrator - -import ( - "strings" - "time" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { - override := railOverrideFromAttributes(attrs, isSource) - if override != model.RailUnspecified { - return override, networkFromEndpoint(endpoint), nil - } - switch endpoint.Type { - case model.EndpointTypeLedger: - return model.RailLedger, "", nil - case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain: - return model.RailCrypto, networkFromEndpoint(endpoint), nil - case model.EndpointTypeCard: - return model.RailCardPayout, "", nil - default: - return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint") - } -} - -func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail { - if len(attrs) == 0 { - return model.RailUnspecified - } - keys := []string{"source_rail", "sourceRail"} - if !isSource { - keys = []string{"destination_rail", "destinationRail"} - } - lookup := map[string]struct{}{} - for _, key := range keys { - lookup[strings.ToLower(key)] = struct{}{} - } - for key, value := range attrs { - if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok { - continue - } - rail := parseRailValue(value) - if rail != model.RailUnspecified { - return rail - } - } - return model.RailUnspecified -} - -func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole { - if role == nil { - return nil - } - cloned := *role - return &cloned -} - -func resolveFeeAmount(payment *model.Payment, quote *sharedv1.PaymentQuote) *paymenttypes.Money { - if quote != nil && quote.GetExpectedFeeTotal() != nil { - return moneyFromProto(quote.GetExpectedFeeTotal()) - } - if payment != nil && payment.LastQuote != nil { - return cloneMoney(payment.LastQuote.ExpectedFeeTotal) - } - return nil -} - -func planTimestamp(payment *model.Payment) time.Time { - if payment != nil && !payment.CreatedAt.IsZero() { - return payment.CreatedAt.UTC() - } - return time.Now().UTC() -} - -func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) { - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("plan builder: " + label + " is required") - } - return amount, nil -} - -func networkFromEndpoint(endpoint model.PaymentEndpoint) string { - switch endpoint.Type { - case model.EndpointTypeManagedWallet: - if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil { - return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain())) - } - case model.EndpointTypeExternalChain: - if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil { - return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain())) - } - } - return "" -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go b/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go deleted file mode 100644 index 765b3745..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/pkg/payments/rail" -) - -type fakeRailGateway struct { - rail string - network string - capabilities rail.RailCapabilities - sendFn func(context.Context, rail.TransferRequest) (rail.RailResult, error) - observeFn func(context.Context, string) (rail.ObserveResult, error) - blockFn func(context.Context, rail.BlockRequest) (rail.RailResult, error) - releaseFn func(context.Context, rail.ReleaseRequest) (rail.RailResult, error) -} - -func (f *fakeRailGateway) Rail() string { - return f.rail -} - -func (f *fakeRailGateway) Network() string { - return f.network -} - -func (f *fakeRailGateway) Capabilities() rail.RailCapabilities { - return f.capabilities -} - -func (f *fakeRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { - if f.sendFn != nil { - return f.sendFn(ctx, req) - } - return rail.RailResult{ReferenceID: "transfer-1", Status: rail.TransferStatusWaiting}, nil -} - -func (f *fakeRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { - if f.observeFn != nil { - return f.observeFn(ctx, referenceID) - } - return rail.ObserveResult{ReferenceID: referenceID, Status: rail.TransferStatusWaiting}, nil -} - -func (f *fakeRailGateway) Block(ctx context.Context, req rail.BlockRequest) (rail.RailResult, error) { - if f.blockFn != nil { - return f.blockFn(ctx, req) - } - return rail.RailResult{ReferenceID: req.IdempotencyKey, Status: rail.TransferStatusWaiting}, nil -} - -func (f *fakeRailGateway) Release(ctx context.Context, req rail.ReleaseRequest) (rail.RailResult, error) { - if f.releaseFn != nil { - return f.releaseFn(ctx, req) - } - return rail.RailResult{ReferenceID: req.ReferenceID, Status: rail.TransferStatusWaiting}, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index ace88660..be53a50a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -1,91 +1,32 @@ package orchestrator import ( - "context" - "time" - - "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" "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" - orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" "google.golang.org/grpc" ) -type serviceError string - -func (e serviceError) Error() string { - return string(e) -} - -const ( - defaultMaxFXQuoteTTL = 10 * time.Minute - defaultMaxFXQuoteTTLMillis = int64(defaultMaxFXQuoteTTL / time.Millisecond) -) - -var ( - errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised") - errQuotationUnavailable = serviceError("payments.orchestrator: quotation service not configured") -) - -// Service orchestrates payments across ledger, billing, FX, and chain domains. +// Service is a v2-only payment orchestrator gRPC adapter. type Service struct { - logger mlogger.Logger - storage storage.Repository - clock clockpkg.Clock - maxFXQuoteTTLMillis int64 - - deps serviceDependencies - h handlerSet - comp componentSet - - gatewayBroker mb.Broker - gatewayConsumers []msg.Consumer - - orchestrationv1.UnimplementedPaymentExecutionServiceServer + logger mlogger.Logger + repo storage.Repository + v2 psvc.Service } -type serviceDependencies struct { - quotation quotationDependency - fees feesDependency - ledger ledgerDependency - gateway gatewayDependency - railGateways railGatewayDependency - providerGateway providerGatewayDependency - - mntx mntxDependency - gatewayRegistry plan_builder.GatewayRegistry - gatewayInvokeResolver GatewayInvokeResolver - cardRoutes map[string]CardGatewayRoute - feeLedgerAccounts map[string]string -} - -type handlerSet struct { - commands *paymentCommandFactory - queries *paymentQueryHandler - events *paymentEventHandler -} - -type componentSet struct { - executor *paymentExecutor -} - -// NewService constructs a payment orchestrator service. +// NewService constructs the v2 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(), - maxFXQuoteTTLMillis: defaultMaxFXQuoteTTLMillis, + if logger == nil { + logger = zap.NewNop() } - initMetrics() + svc := &Service{ + logger: logger.Named("payment_orchestrator"), + repo: repo, + } for _, opt := range opts { if opt != nil { @@ -93,116 +34,19 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) } } - if svc.clock == nil { - svc.clock = clockpkg.NewSystem() - } - if svc.maxFXQuoteTTLMillis <= 0 { - svc.maxFXQuoteTTLMillis = defaultMaxFXQuoteTTLMillis - } - - 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() - + svc.v2 = newOrchestrationV2Service(svc.logger, repo) 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 { + if s == nil || s.v2 == nil { + return nil + } return router.Register(func(reg grpc.ServiceRegistrar) { - orchestrationv1.RegisterPaymentExecutionServiceServer(reg, s) + orchestrationv2.RegisterPaymentOrchestratorServiceServer(reg, newV2GRPCServer(s.v2)) }) } -// 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 *sharedv1.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) -} +// Shutdown releases runtime resources. Orchestration v2 currently has no background workers. +func (s *Service) Shutdown() {} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go deleted file mode 100644 index ea3ebefa..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go +++ /dev/null @@ -1,269 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "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" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "google.golang.org/protobuf/proto" -) - -func validateMetaAndOrgRef(meta *sharedv1.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 *sharedv1.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) (quotestorage.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 *sharedv1.RequestMeta - Intent *sharedv1.PaymentIntent - QuoteRef string - IdempotencyKey string -} - -type quoteResolutionError struct { - code string - err error -} - -func (e quoteResolutionError) Error() string { return e.err.Error() } - -func (s *Service) clampFXIntentTTL(intent *sharedv1.PaymentIntent) *sharedv1.PaymentIntent { - if intent == nil { - return nil - } - cloned, ok := proto.Clone(intent).(*sharedv1.PaymentIntent) - if !ok || cloned == nil { - return intent - } - if s == nil || s.maxFXQuoteTTLMillis <= 0 { - return cloned - } - if fx := cloned.GetFx(); fx != nil && fx.GetTtlMs() > s.maxFXQuoteTTLMillis { - fx.TtlMs = s.maxFXQuoteTTLMillis - } - return cloned -} - -func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - if ref := strings.TrimSpace(in.QuoteRef); ref != "" { - quotesStore, err := ensureQuotesStore(s.storage) - if err != nil { - return nil, nil, nil, err - } - record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) - if err != nil { - if errors.Is(err, quotestorage.ErrQuoteNotFound) { - return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} - } - return nil, nil, nil, err - } - if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { - return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} - } - if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return nil, nil, nil, quoteResolutionError{code: "quote_not_executable", err: merrors.InvalidArgument(note)} - } - intent, err := recordIntentFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - if in.Intent != nil && !proto.Equal(intent, in.Intent) { - return nil, nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} - } - quote, err := recordQuoteFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - quote.QuoteRef = ref - plan, err := recordPlanFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - return quote, intent, plan, nil - } - - if in.Intent == nil { - return nil, nil, nil, merrors.InvalidArgument("intent is required") - } - intent := s.clampFXIntentTTL(in.Intent) - req := "ationv1.QuotePaymentRequest{ - Meta: in.Meta, - IdempotencyKey: in.IdempotencyKey, - Intent: intent, - PreviewOnly: false, - } - if !s.deps.quotation.available() { - return nil, nil, nil, errQuotationUnavailable - } - quoteResp, err := s.deps.quotation.client.QuotePayment(ctx, req) - if err != nil { - return nil, nil, nil, err - } - quote := quoteResp.GetQuote() - if quote == nil { - return nil, nil, nil, merrors.InvalidArgument("stored quote is empty") - } - ref := strings.TrimSpace(quote.GetQuoteRef()) - if ref == "" { - return nil, nil, nil, merrors.InvalidArgument("quotation response does not include quote_ref") - } - - return s.resolvePaymentQuote(ctx, quoteResolutionInput{ - OrgRef: in.OrgRef, - OrgID: in.OrgID, - Meta: in.Meta, - Intent: intent, - QuoteRef: ref, - IdempotencyKey: in.IdempotencyKey, - }) -} - -func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.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) (*sharedv1.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 recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) { - if record == nil { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - if len(record.Plans) > 0 { - if len(record.Plans) != 1 { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return cloneStoredPaymentPlan(record.Plans[0]), nil - } - if record.Plan != nil { - return cloneStoredPaymentPlan(record.Plan), nil - } - return nil, nil -} - -func newPayment(orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *sharedv1.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/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go deleted file mode 100644 index 3461166e..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ /dev/null @@ -1,610 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - "time" - - ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - clockpkg "github.com/tech/sendico/pkg/clock" - mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" - "github.com/tech/sendico/pkg/model/account_role" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/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/orchestration/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "google.golang.org/grpc" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestValidateMetaAndOrgRef(t *testing.T) { - org := bson.NewObjectID() - meta := &sharedv1.RequestMeta{OrganizationRef: org.Hex()} - ref, id, err := validateMetaAndOrgRef(meta) - if err != nil { - t.Fatalf("expected nil error: %v", err) - } - if ref != org.Hex() || id != org { - t.Fatalf("unexpected org parsing: %s %s", ref, id.Hex()) - } - if _, _, err := validateMetaAndOrgRef(nil); err == nil { - t.Fatalf("expected error on nil meta") - } - if _, _, err := validateMetaAndOrgRef(&sharedv1.RequestMeta{OrganizationRef: ""}); err == nil { - t.Fatalf("expected error on empty orgRef") - } - if _, _, err := validateMetaAndOrgRef(&sharedv1.RequestMeta{OrganizationRef: "bad"}); err == nil { - t.Fatalf("expected error on invalid orgRef") - } -} - -func TestRequireIdempotencyKey(t *testing.T) { - if _, err := requireIdempotencyKey(" "); err == nil { - t.Fatalf("expected error for empty key") - } - val, err := requireIdempotencyKey(" key ") - if err != nil || val != "key" { - t.Fatalf("unexpected result %s err %v", val, err) - } -} - -func TestNewPayment(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, - SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, - SettlementCurrency: "USD", - } - quote := &sharedv1.PaymentQuote{QuoteRef: "q1"} - p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote) - if p.PaymentRef == "" || p.IdempotencyKey != "idem" || p.State != model.PaymentStateAccepted { - t.Fatalf("unexpected payment fields: %+v", p) - } - if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" { - t.Fatalf("intent not copied") - } - if p.Intent.SettlementMode != model.SettlementModeFixReceived { - t.Fatalf("settlement mode not preserved") - } - if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" { - t.Fatalf("quote not copied") - } -} - -func TestResolvePaymentQuote_NotFound(t *testing.T) { - org := bson.NewObjectID() - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{}}, - clock: clockpkg.NewSystem(), - } - _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"}, - QuoteRef: "missing", - }) - if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" { - t.Fatalf("expected quote_not_found, got %v", err) - } -} - -func TestResolvePaymentQuote_Expired(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, - ExpiresAt: time.Now().Add(-time.Minute), - } - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, - clock: clockpkg.NewSystem(), - } - _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: intent, - QuoteRef: "q1", - }) - if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_expired" { - t.Fatalf("expected quote_expired, got %v", err) - } -} - -func TestResolvePaymentQuote_NotExecutable(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, - ExecutionNote: "quote will not be executed: amount 1 USD below per-tx min limit 10", - ExpiresAt: time.Now().Add(time.Minute), - IdempotencyKey: "idem-1", - } - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, - clock: clockpkg.NewSystem(), - } - - _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: intent, - QuoteRef: "q1", - }) - if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_executable" { - t.Fatalf("expected quote_not_executable, got %v", err) - } -} - -func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, - } - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, - clock: clockpkg.NewSystem(), - } - quote, resolvedIntent, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - QuoteRef: "q1", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if quote == nil || quote.GetQuoteRef() != "q1" { - t.Fatalf("expected quote_ref q1, got %#v", quote) - } - if resolvedIntent == nil || resolvedIntent.GetAmount().GetAmount() != "1" { - t.Fatalf("expected resolved intent with amount, got %#v", resolvedIntent) - } -} - -func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, - } - - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, - clock: clockpkg.NewSystem(), - } - - _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - QuoteRef: "q1", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestResolvePaymentQuote_ClampsForwardedFXTTL(t *testing.T) { - const ( - requestedTTL = int64((15 * time.Minute) / time.Millisecond) - maxTTL = int64((10 * time.Minute) / time.Millisecond) - ) - - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "EUR", - Fx: &sharedv1.FXIntent{ - Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - Firm: true, - TtlMs: requestedTTL, - }, - } - intent = protoIntentFromModel(intentFromProto(intent)) - intent.Fx.TtlMs = requestedTTL - - recordIntent := protoIntentFromModel(intentFromProto(intent)) - recordIntent.Fx.TtlMs = maxTTL - - var capturedTTLMs int64 - svc := &Service{ - storage: stubRepo{ - quotes: &helperQuotesStore{ - records: map[string]*model.PaymentQuoteRecord{ - "q1": { - QuoteRef: "q1", - Intent: intentFromProto(recordIntent), - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - }, - }, - }, - }, - clock: clockpkg.NewSystem(), - maxFXQuoteTTLMillis: maxTTL, - deps: serviceDependencies{ - quotation: quotationDependency{ - client: &helperQuotationClient{ - quotePaymentFn: func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { - capturedTTLMs = req.GetIntent().GetFx().GetTtlMs() - return "ationv1.QuotePaymentResponse{ - Quote: &sharedv1.PaymentQuote{ - QuoteRef: "q1", - }, - IdempotencyKey: req.GetIdempotencyKey(), - }, nil - }, - }, - }, - }, - } - - _, resolvedIntent, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: intent, - IdempotencyKey: "idem-1", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedTTLMs != maxTTL { - t.Fatalf("expected forwarded ttl_ms to be clamped to %d, got %d", maxTTL, capturedTTLMs) - } - if intent.GetFx().GetTtlMs() != requestedTTL { - t.Fatalf("expected original intent ttl to stay unchanged, got %d", intent.GetFx().GetTtlMs()) - } - if resolvedIntent == nil || resolvedIntent.GetFx().GetTtlMs() != maxTTL { - t.Fatalf("expected resolved intent ttl to match stored clamped value") - } -} - -func TestInitiatePaymentIdempotency(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - org := bson.NewObjectID() - store := newHelperPaymentStore() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Source: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Ledger{Ledger: &sharedv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, - }, - Destination: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Ledger{Ledger: &sharedv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, - }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{ - DebitAmount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, - ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, - QuoteRef: "q1", - }, - Plan: &model.PaymentPlan{ - Steps: []*model.PaymentStep{ - { - StepID: "ledger_move", - Rail: model.RailLedger, - Action: model.RailOperationMove, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, - FromRole: rolePtr(account_role.AccountRoleOperating), - ToRole: rolePtr(account_role.AccountRoleTransit), - }, - }, - }, - } - ledgerFake := &ledgerclient.Fake{ - ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { - operatingDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_OPERATING"}) - transitDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_TRANSIT"}) - return &connectorv1.ListAccountsResponse{ - Accounts: []*connectorv1.Account{ - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: "ledger:operating"}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USD", ProviderDetails: operatingDetails}, - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: "ledger:transit"}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USD", ProviderDetails: transitDetails}, - }, - }, nil - }, - TransferInternalFn: func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil - }, - } - svc := NewService(logger, stubRepo{ - payments: store, - quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, - }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake), WithQuotationService(&helperQuotationClient{ - quotePaymentFn: func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { - amount := req.GetIntent().GetAmount() - return "ationv1.QuotePaymentResponse{ - Quote: &sharedv1.PaymentQuote{ - QuoteRef: "q1", - DebitAmount: amount, - ExpectedSettlementAmount: amount, - }, - IdempotencyKey: req.GetIdempotencyKey(), - }, nil - }, - })) - svc.ensureHandlers() - req := &orchestratorv1.InitiatePaymentRequest{ - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: intent, - IdempotencyKey: "k1", - } - resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("first call failed: %v", err) - } - resp2, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("second call failed: %v", err) - } - if resp == nil || resp2 == nil || resp.Payment.GetPaymentRef() != resp2.Payment.GetPaymentRef() { - t.Fatalf("idempotent call returned different payments") - } -} - -func TestInitiatePaymentByQuoteRef(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - org := bson.NewObjectID() - store := newHelperPaymentStore() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Source: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Ledger{Ledger: &sharedv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, - }, - Destination: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Ledger{Ledger: &sharedv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, - }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - Plan: &model.PaymentPlan{ - Steps: []*model.PaymentStep{ - { - StepID: "ledger_move", - Rail: model.RailLedger, - Action: model.RailOperationMove, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, - FromRole: rolePtr(account_role.AccountRoleOperating), - ToRole: rolePtr(account_role.AccountRoleTransit), - }, - }, - }, - } - ledgerFake := &ledgerclient.Fake{ - ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { - operatingDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_OPERATING"}) - transitDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_TRANSIT"}) - return &connectorv1.ListAccountsResponse{ - Accounts: []*connectorv1.Account{ - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: "ledger:operating"}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USD", ProviderDetails: operatingDetails}, - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: "ledger:transit"}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USD", ProviderDetails: transitDetails}, - }, - }, nil - }, - TransferInternalFn: func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil - }, - } - svc := NewService(logger, stubRepo{ - payments: store, - quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, - }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) - svc.ensureHandlers() - - req := &orchestratorv1.InitiatePaymentRequest{ - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - QuoteRef: "q1", - IdempotencyKey: "k1", - } - resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("initiate by quote_ref failed: %v", err) - } - if resp == nil || resp.GetPayment() == nil { - t.Fatalf("expected payment response") - } - if resp.GetPayment().GetIntent().GetAmount().GetAmount() != "1" { - t.Fatalf("expected intent amount to be resolved from quote") - } - if resp.GetPayment().GetLastQuote().GetQuoteRef() != "q1" { - t.Fatalf("expected last quote_ref to be set from stored quote") - } -} - -// --- test doubles --- - -type stubRepo struct { - payments storage.PaymentsStore - quotes quotestorage.QuotesStore - routes storage.RoutesStore - plans storage.PlanTemplatesStore - pingErr error -} - -func (s stubRepo) Ping(context.Context) error { return s.pingErr } -func (s stubRepo) Payments() storage.PaymentsStore { return s.payments } -func (s stubRepo) PaymentMethods() storage.PaymentMethodsStore { - return nil -} -func (s stubRepo) Quotes() quotestorage.QuotesStore { return s.quotes } -func (s stubRepo) Routes() storage.RoutesStore { return s.routes } -func (s stubRepo) PlanTemplates() storage.PlanTemplatesStore { - if s.plans != nil { - return s.plans - } - return &stubPlanTemplatesStore{} -} - -type helperPaymentStore struct { - byRef map[string]*model.Payment - byIdem map[string]*model.Payment - byChain map[string]*model.Payment -} - -func newHelperPaymentStore() *helperPaymentStore { - return &helperPaymentStore{ - byRef: make(map[string]*model.Payment), - byIdem: make(map[string]*model.Payment), - byChain: make(map[string]*model.Payment), - } -} - -func (s *helperPaymentStore) Create(_ context.Context, p *model.Payment) error { - if _, ok := s.byRef[p.PaymentRef]; ok { - return storage.ErrDuplicatePayment - } - s.byRef[p.PaymentRef] = p - if p.IdempotencyKey != "" { - s.byIdem[p.IdempotencyKey] = p - } - if p.Execution != nil && p.Execution.ChainTransferRef != "" { - s.byChain[p.Execution.ChainTransferRef] = p - } - return nil -} - -func (s *helperPaymentStore) Update(_ context.Context, p *model.Payment) error { - if p == nil { - return storage.ErrPaymentNotFound - } - if _, ok := s.byRef[p.PaymentRef]; !ok { - return storage.ErrPaymentNotFound - } - s.byRef[p.PaymentRef] = p - if p.IdempotencyKey != "" { - s.byIdem[p.IdempotencyKey] = p - } - return nil -} - -func (s *helperPaymentStore) GetByPaymentRef(_ context.Context, ref string) (*model.Payment, error) { - if p, ok := s.byRef[ref]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *helperPaymentStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, key string) (*model.Payment, error) { - if p, ok := s.byIdem[key]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *helperPaymentStore) GetByChainTransferRef(_ context.Context, ref string) (*model.Payment, error) { - if p, ok := s.byChain[ref]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *helperPaymentStore) List(_ context.Context, _ *model.PaymentFilter) (*model.PaymentList, error) { - return &model.PaymentList{}, nil -} - -type helperQuotesStore struct { - records map[string]*model.PaymentQuoteRecord -} - -func (s *helperQuotesStore) Create(_ context.Context, _ *model.PaymentQuoteRecord) error { return nil } - -func (s *helperQuotesStore) GetByRef(_ context.Context, _ bson.ObjectID, ref string) (*model.PaymentQuoteRecord, error) { - if s.records == nil { - return nil, quotestorage.ErrQuoteNotFound - } - if rec, ok := s.records[ref]; ok { - return rec, nil - } - return nil, quotestorage.ErrQuoteNotFound -} - -func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, ref string) (*model.PaymentQuoteRecord, error) { - if s.records == nil { - return nil, quotestorage.ErrQuoteNotFound - } - for _, rec := range s.records { - if rec.OrganizationRef != orgRef { - continue - } - if rec.IdempotencyKey == ref { - return rec, nil - } - } - return nil, quotestorage.ErrQuoteNotFound -} - -type helperQuotationClient struct { - quotePaymentFn func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) - quotePaymentsFn func(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) -} - -func (c *helperQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { - if c.quotePaymentFn != nil { - return c.quotePaymentFn(ctx, req, opts...) - } - return "ationv1.QuotePaymentResponse{}, nil -} - -func (c *helperQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) { - if c.quotePaymentsFn != nil { - return c.quotePaymentsFn(ctx, req, opts...) - } - return "ationv1.QuotePaymentsResponse{}, nil -} - -func rolePtr(role account_role.AccountRole) *account_role.AccountRole { - return &role -} - -type stubGatewayRegistry struct { - items []*model.GatewayInstanceDescriptor -} - -func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go new file mode 100644 index 00000000..0d3ffdb2 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go @@ -0,0 +1,74 @@ +package orchestrator + +import ( + "context" + "net" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/pkg/api/routers" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "google.golang.org/grpc" +) + +func TestService_RegisterV2Only(t *testing.T) { + svc := &Service{v2: fakeOrchestrationV2Service{}} + router := newGRPCCaptureRouterV2() + + if err := svc.Register(router); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + services := router.server.GetServiceInfo() + if _, ok := services[orchestrationv2.PaymentOrchestratorService_ServiceDesc.ServiceName]; !ok { + t.Fatalf("expected %q service to be registered", orchestrationv2.PaymentOrchestratorService_ServiceDesc.ServiceName) + } + if len(services) != 1 { + t.Fatalf("expected exactly one registered service, got %d", len(services)) + } +} + +type fakeOrchestrationV2Service struct{} + +func (fakeOrchestrationV2Service) ExecutePayment(context.Context, *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + return &orchestrationv2.ExecutePaymentResponse{}, nil +} + +func (fakeOrchestrationV2Service) GetPayment(context.Context, *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { + return &orchestrationv2.GetPaymentResponse{}, nil +} + +func (fakeOrchestrationV2Service) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { + return &orchestrationv2.ListPaymentsResponse{}, nil +} + +func (fakeOrchestrationV2Service) ReconcileExternal(context.Context, psvc.ReconcileExternalInput) (*psvc.ReconcileExternalOutput, error) { + return &psvc.ReconcileExternalOutput{}, nil +} + +type grpcCaptureRouterV2 struct { + server *grpc.Server + done chan error +} + +func newGRPCCaptureRouterV2() *grpcCaptureRouterV2 { + return &grpcCaptureRouterV2{ + server: grpc.NewServer(), + done: make(chan error), + } +} + +func (r *grpcCaptureRouterV2) Register(registration routers.GRPCServiceRegistration) error { + registration(r.server) + return nil +} + +func (r *grpcCaptureRouterV2) Start(context.Context) error { return nil } + +func (r *grpcCaptureRouterV2) Finish(context.Context) error { return nil } + +func (r *grpcCaptureRouterV2) Addr() net.Addr { return nil } + +func (r *grpcCaptureRouterV2) Done() <-chan error { return r.done } + +var _ routers.GRPC = (*grpcCaptureRouterV2)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go deleted file mode 100644 index ff2384c8..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ /dev/null @@ -1,646 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - mo "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - 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" - 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/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func TestExecutePayment_FXConversionSettled(t *testing.T) { - ctx := context.Background() - - store := newStubPaymentsStore() - repo := &stubRepository{store: store} - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: repo, - deps: serviceDependencies{ - ledger: func() ledgerDependency { - fake := &ledgerclient.Fake{ - ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { - return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil - }, - } - return ledgerDependency{ - client: fake, - internal: fake, - } - }(), - }, - } - - payment := &model.Payment{ - PaymentRef: "fx-1", - IdempotencyKey: "fx-1", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindFXConversion, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeLedger, - Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:source"}, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeLedger, - Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, - }, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}, - SettlementCurrency: "USD", - }, - } - store.payments[payment.PaymentRef] = payment - - quote := &sharedv1.PaymentQuote{ - FxQuote: &oraclev1.Quote{ - QuoteRef: "quote-1", - BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "100"}, - QuoteAmount: &moneyv1.Money{Currency: "EUR", Amount: "90"}, - Price: &moneyv1.Decimal{Value: "0.9"}, - }, - } - attachStoredPlan(payment, &model.PaymentPlan{ - Steps: []*model.PaymentStep{ - { - StepID: "fx_convert", - Rail: model.RailLedger, - Action: model.RailOperationFXConvert, - }, - }, - }, payment.IdempotencyKey) - if err := svc.executePayment(ctx, store, payment, quote); err != nil { - t.Fatalf("executePayment returned error: %v", err) - } - - if payment.State != model.PaymentStateSettled { - t.Fatalf("expected payment settled, got %s", payment.State) - } - - if payment.Execution == nil || payment.Execution.FXEntryRef == "" { - t.Fatal("expected FX entry ref set on payment execution") - } -} - -func TestExecutePayment_ChainFailure(t *testing.T) { - ctx := context.Background() - - store := newStubPaymentsStore() - routes := &stubRoutesStore{ - routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON_MAINNET", IsEnabled: true}, - }, - } - plans := &stubPlanTemplatesStore{ - templates: []*model.PaymentPlanTemplate{ - { - FromRail: model.RailCrypto, - ToRail: model.RailLedger, - Network: "TRON_MAINNET", - IsEnabled: true, - Steps: []model.OrchestrationStep{ - {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, - {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, - }, - }, - }, - } - repo := &stubRepository{store: store, routes: routes, plans: plans} - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: repo, - deps: serviceDependencies{ - railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{ - "crypto-tron": &fakeRailGateway{ - rail: "CRYPTO", - sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { - return rail.RailResult{}, errors.New("chain failure") - }, - }, - }, nil, nil, nil, nil), - gatewayRegistry: &stubGatewayRegistry{ - items: []*model.GatewayInstanceDescriptor{ - { - ID: "crypto-tron", - InstanceID: "crypto-tron-1", - Rail: model.RailCrypto, - Network: "TRON_MAINNET", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - RequiresObserveConfirm: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - }, - }, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: "funding-address", - }, - }, - }, - } - - payment := &model.Payment{ - PaymentRef: "chain-1", - IdempotencyKey: "chain-1", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-src", - Asset: &paymenttypes.Asset{ - Chain: "TRON_MAINNET", - TokenSymbol: "USDT", - }, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeLedger, - Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"}, - SettlementCurrency: "USDT", - }, - } - store.payments[payment.PaymentRef] = payment - - attachStoredPlan(payment, &model.PaymentPlan{ - Steps: []*model.PaymentStep{ - { - StepID: "crypto_send", - Rail: model.RailCrypto, - Action: model.RailOperationSend, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"}, - }, - }, - }, payment.IdempotencyKey) - err := svc.executePayment(ctx, store, payment, &sharedv1.PaymentQuote{}) - if err == nil || err.Error() != "chain failure" { - t.Fatalf("expected chain failure error, got %v", err) - } - if payment.State != model.PaymentStateFailed { - t.Fatalf("expected payment failed, got %s", payment.State) - } - if payment.FailureCode != model.PaymentFailureCodeChain { - t.Fatalf("expected failure code chain, got %s", payment.FailureCode) - } -} - -func TestProcessTransferUpdateHandler_Settled(t *testing.T) { - ctx := context.Background() - payment := &model.Payment{ - PaymentRef: "pay-1", - State: model.PaymentStateSubmitted, - Execution: &model.ExecutionRefs{ChainTransferRef: "transfer-1"}, - } - store := newStubPaymentsStore() - store.payments[payment.PaymentRef] = payment - store.byChain["transfer-1"] = payment - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: &stubRepository{store: store}, - } - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, nil, nil, nil) - - req := &orchestratorv1.ProcessTransferUpdateRequest{ - Event: &chainv1.TransferStatusChangedEvent{ - Transfer: &chainv1.Transfer{ - TransferRef: "transfer-1", - Status: chainv1.TransferStatus_TRANSFER_SUCCESS, - }, - }, - } - - reSP, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if reSP.GetPayment().GetState() != sharedv1.PaymentState_PAYMENT_STATE_SETTLED { - t.Fatalf("expected settled state, got %s", reSP.GetPayment().GetState()) - } -} - -func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { - ctx := context.Background() - - payment := &model.Payment{ - PaymentRef: "pay-card", - State: model.PaymentStateSubmitted, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{MaskedPan: "4111"}, - }, - }, - Execution: &model.ExecutionRefs{ChainTransferRef: "fund-1"}, - } - - plan := ensureExecutionPlan(payment) - fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) - fundStep.TransferRef = "fund-1" - setExecutionStepRole(fundStep, executionStepRoleSource) - setExecutionStepStatus(fundStep, model.OperationStateWaiting) - - feeStep := ensureExecutionStep(plan, stepCodeFeeTransfer) - feeStep.TransferRef = "fee-1" - setExecutionStepRole(feeStep, executionStepRoleSource) - setExecutionStepStatus(feeStep, model.OperationStateWaiting) - - cardStep := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(cardStep, executionStepRoleConsumer) - setExecutionStepStatus(cardStep, model.OperationStatePlanned) - - store := newStubPaymentsStore() - store.payments[payment.PaymentRef] = payment - store.indexTransfers(payment) - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: &stubRepository{store: store}, - } - - payoutCalls := 0 - submit := func(ctx context.Context, operationRef string, payment *model.Payment) error { - payoutCalls++ - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - payment.Execution.CardPayoutRef = "payout-1" - plan := ensureExecutionPlan(payment) - step := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(step, executionStepRoleConsumer) - step.TransferRef = "payout-1" - step.OperationRef = operationRef - setExecutionStepStatus(step, model.OperationStateWaiting) - return nil - } - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, submit, nil, nil) - - req := &orchestratorv1.ProcessTransferUpdateRequest{ - Event: &chainv1.TransferStatusChangedEvent{ - Transfer: &chainv1.Transfer{ - TransferRef: "fund-1", - Status: chainv1.TransferStatus_TRANSFER_SUCCESS, - }, - }, - } - resp, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if payoutCalls != 0 { - t.Fatalf("expected no payout on first confirmation, got %d", payoutCalls) - } - if fundStep.State != model.OperationStateSuccess { - t.Fatalf("expected funding step confirmed, got %s", feeStep.State) - } - if resp.GetPayment().GetState() != sharedv1.PaymentState_PAYMENT_STATE_SUBMITTED { - t.Fatalf("expected submitted state, got %s", resp.GetPayment().GetState()) - } - - req = &orchestratorv1.ProcessTransferUpdateRequest{ - Event: &chainv1.TransferStatusChangedEvent{ - Transfer: &chainv1.Transfer{ - TransferRef: "fee-1", - Status: chainv1.TransferStatus_TRANSFER_SUCCESS, - }, - }, - } - resp, err = gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if payoutCalls != 1 { - t.Fatalf("expected payout after all sources confirmed, got %d", payoutCalls) - } - if feeStep.State != model.OperationStateSuccess { - t.Fatalf("expected fee step confirmed, got %s", string(model.OperationStateSuccess)) - } - if resp.GetPayment().GetExecution().GetCardPayoutRef() != "payout-1" { - t.Fatalf("expected card payout ref set, got %s", resp.GetPayment().GetExecution().GetCardPayoutRef()) - } -} - -func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { - ctx := context.Background() - payment := &model.Payment{ - PaymentRef: "pay-2", - State: model.PaymentStateSubmitted, - Intent: model.PaymentIntent{ - Ref: "ref-2", - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-dst", - }, - }, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "40"}, - }, - } - store := newStubPaymentsStore() - store.listResp = &model.PaymentList{Items: []*model.Payment{payment}} - store.payments[payment.PaymentRef] = payment - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: &stubRepository{store: store}, - } - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, nil, nil, nil) - - req := &orchestratorv1.ProcessDepositObservedRequest{ - Event: &chainv1.WalletDepositObservedEvent{ - WalletRef: "wallet-dst", - Amount: &moneyv1.Money{Currency: "USD", Amount: "40"}, - }, - } - - reSP, err := gsresponse.Execute(ctx, svc.h.events.processDepositObserved(ctx, req)) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if reSP.GetPayment().GetState() != sharedv1.PaymentState_PAYMENT_STATE_SETTLED { - t.Fatalf("expected settled state, got %s", reSP.GetPayment().GetState()) - } -} - -// ---------------------------------------------------------------------- - -type stubRepository struct { - store *stubPaymentsStore - quotes quotestorage.QuotesStore - routes storage.RoutesStore - plans storage.PlanTemplatesStore -} - -func (r *stubRepository) Ping(context.Context) error { return nil } -func (r *stubRepository) Payments() storage.PaymentsStore { return r.store } -func (r *stubRepository) PaymentMethods() storage.PaymentMethodsStore { - return nil -} -func (r *stubRepository) Quotes() quotestorage.QuotesStore { - if r.quotes != nil { - return r.quotes - } - return &stubQuotesStore{} -} -func (r *stubRepository) Routes() storage.RoutesStore { - if r.routes != nil { - return r.routes - } - return &stubRoutesStore{} -} - -func (r *stubRepository) PlanTemplates() storage.PlanTemplatesStore { - if r.plans != nil { - return r.plans - } - return &stubPlanTemplatesStore{} -} - -type stubQuotesStore struct { - quotes map[string]*model.PaymentQuoteRecord -} - -func (s *stubQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error { - if quote == nil { - return merrors.InvalidArgument("nil quote") - } - if s.quotes == nil { - s.quotes = map[string]*model.PaymentQuoteRecord{} - } - s.quotes[strings.TrimSpace(quote.QuoteRef)] = quote - return nil -} - -func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { - if s.quotes == nil { - return nil, quotestorage.ErrQuoteNotFound - } - if q, ok := s.quotes[strings.TrimSpace(quoteRef)]; ok { - return q, nil - } - return nil, quotestorage.ErrQuoteNotFound -} - -func (s *stubQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) { - if s.quotes == nil { - return nil, quotestorage.ErrQuoteNotFound - } - for _, q := range s.quotes { - if q.OrganizationRef != orgRef { - continue - } - if q.IdempotencyKey == idempotencyKey { - return q, nil - } - } - return nil, quotestorage.ErrQuoteNotFound -} - -type stubRoutesStore struct { - routes []*model.PaymentRoute -} - -func (s *stubRoutesStore) Create(ctx context.Context, route *model.PaymentRoute) error { - return merrors.InvalidArgument("routes store not implemented") -} - -func (s *stubRoutesStore) Update(ctx context.Context, route *model.PaymentRoute) error { - return merrors.InvalidArgument("routes store not implemented") -} - -func (s *stubRoutesStore) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentRoute, error) { - return nil, storage.ErrRouteNotFound -} - -func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) { - items := make([]*model.PaymentRoute, 0, len(s.routes)) - for _, route := range s.routes { - if route == nil { - continue - } - if filter != nil { - if filter.FromRail != "" && route.FromRail != filter.FromRail { - continue - } - if filter.ToRail != "" && route.ToRail != filter.ToRail { - continue - } - if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) { - continue - } - } - if filter != nil && filter.IsEnabled != nil { - if route.IsEnabled != *filter.IsEnabled { - continue - } - } - items = append(items, route) - } - return &model.PaymentRouteList{Items: items}, nil -} - -type stubPlanTemplatesStore struct { - templates []*model.PaymentPlanTemplate -} - -func (s *stubPlanTemplatesStore) Create(ctx context.Context, template *model.PaymentPlanTemplate) error { - return merrors.InvalidArgument("plan templates store not implemented") -} - -func (s *stubPlanTemplatesStore) Update(ctx context.Context, template *model.PaymentPlanTemplate) error { - return merrors.InvalidArgument("plan templates store not implemented") -} - -func (s *stubPlanTemplatesStore) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentPlanTemplate, error) { - return nil, storage.ErrPlanTemplateNotFound -} - -func (s *stubPlanTemplatesStore) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { - items := make([]*model.PaymentPlanTemplate, 0, len(s.templates)) - for _, tpl := range s.templates { - if tpl == nil { - continue - } - if filter != nil { - if filter.FromRail != "" && tpl.FromRail != filter.FromRail { - continue - } - if filter.ToRail != "" && tpl.ToRail != filter.ToRail { - continue - } - if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) { - continue - } - } - if filter != nil && filter.IsEnabled != nil { - if tpl.IsEnabled != *filter.IsEnabled { - continue - } - } - items = append(items, tpl) - } - return &model.PaymentPlanTemplateList{Items: items}, nil -} - -type stubPaymentsStore struct { - payments map[string]*model.Payment - byChain map[string]*model.Payment - listResp *model.PaymentList -} - -func newStubPaymentsStore() *stubPaymentsStore { - return &stubPaymentsStore{ - payments: map[string]*model.Payment{}, - byChain: map[string]*model.Payment{}, - } -} - -func (s *stubPaymentsStore) Create(ctx context.Context, payment *model.Payment) error { - if _, exists := s.payments[payment.PaymentRef]; exists { - return storage.ErrDuplicatePayment - } - s.payments[payment.PaymentRef] = payment - s.indexTransfers(payment) - return nil -} - -func (s *stubPaymentsStore) Update(ctx context.Context, payment *model.Payment) error { - if _, exists := s.payments[payment.PaymentRef]; !exists { - return storage.ErrPaymentNotFound - } - s.payments[payment.PaymentRef] = payment - s.indexTransfers(payment) - return nil -} - -func (s *stubPaymentsStore) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.Payment, error) { - if p, ok := s.payments[paymentRef]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *stubPaymentsStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, key string) (*model.Payment, error) { - for _, p := range s.payments { - if p.OrganizationRef == orgRef && strings.TrimSpace(p.IdempotencyKey) == key { - return p, nil - } - } - return nil, storage.ErrPaymentNotFound -} - -func (s *stubPaymentsStore) GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error) { - if p, ok := s.byChain[transferRef]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *stubPaymentsStore) List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error) { - if s.listResp != nil { - return s.listResp, nil - } - return &model.PaymentList{}, nil -} - -func (s *stubPaymentsStore) indexTransfers(payment *model.Payment) { - if payment == nil { - return - } - if payment.Execution != nil && payment.Execution.ChainTransferRef != "" { - s.byChain[payment.Execution.ChainTransferRef] = payment - } - if payment.ExecutionPlan == nil { - return - } - for _, step := range payment.ExecutionPlan.Steps { - if step == nil || strings.TrimSpace(step.TransferRef) == "" { - continue - } - s.byChain[strings.TrimSpace(step.TransferRef)] = payment - } -} - -var _ storage.PaymentsStore = (*stubPaymentsStore)(nil) - -// testClock satisfies clock.Clock - -type testClock struct { - now time.Time -} - -func (c testClock) Now() time.Time { return c.now } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go new file mode 100644 index 00000000..ed6281a4 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -0,0 +1,112 @@ +package orchestrator + +import ( + "context" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/pkg/mlogger" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type v2MongoDBProvider interface { + MongoDatabase() *mongo.Database +} + +func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository) psvc.Service { + if logger == nil { + logger = zap.NewNop() + } + if repo == nil { + return nil + } + + paymentRepo := buildPaymentRepositoryV2(repo, logger) + if paymentRepo == nil { + if logger != nil { + logger.Warn("Orchestration v2 disabled: mongo database not available") + } + return nil + } + + query, err := pquery.New(pquery.Dependencies{ + Repository: paymentRepo, + Logger: logger.Named("orchestration_v2_pquery"), + }) + if err != nil { + if logger != nil { + logger.Warn("Orchestration v2 disabled: query service init failed", zap.Error(err)) + } + return nil + } + observer, err := oobs.New(oobs.Dependencies{Logger: logger.Named("orchestration_v2_observer")}) + if err != nil { + if logger != nil { + logger.Warn("Orchestration v2 disabled: observer init failed", zap.Error(err)) + } + return nil + } + + svc, err := psvc.New(psvc.Dependencies{ + Logger: logger.Named("orchestration_v2_psvc"), + QuoteStore: repo.Quotes(), + Repository: paymentRepo, + Query: query, + Observer: observer, + }) + if err != nil { + if logger != nil { + logger.Warn("Orchestration v2 disabled: service init failed", zap.Error(err)) + } + return nil + } + return svc +} + +func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) prepo.Repository { + if repo == nil { + return nil + } + provider, ok := repo.(v2MongoDBProvider) + if !ok { + return nil + } + db := provider.MongoDatabase() + if db == nil { + return nil + } + paymentRepo, err := prepo.NewMongo( + db.Collection("payments_v2"), + prepo.Dependencies{Logger: logger.Named("orchestration_v2_prepo")}, + ) + if err != nil { + return nil + } + return paymentRepo +} + +type v2GRPCServer struct { + orchestrationv2.UnimplementedPaymentOrchestratorServiceServer + svc psvc.Service +} + +func newV2GRPCServer(svc psvc.Service) *v2GRPCServer { + return &v2GRPCServer{svc: svc} +} + +func (s *v2GRPCServer) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + return s.svc.ExecutePayment(ctx, req) +} + +func (s *v2GRPCServer) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { + return s.svc.GetPayment(ctx, req) +} + +func (s *v2GRPCServer) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { + return s.svc.ListPayments(ctx, req) +} diff --git a/api/payments/orchestrator/internal/service/plan_builder/gateways.go b/api/payments/orchestrator/internal/service/plan_builder/gateways.go index 5316ebb0..50de480f 100644 --- a/api/payments/orchestrator/internal/service/plan_builder/gateways.go +++ b/api/payments/orchestrator/internal/service/plan_builder/gateways.go @@ -2,7 +2,6 @@ package plan_builder import ( "context" - "fmt" "sort" "strings" @@ -58,8 +57,8 @@ func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, 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()) + if err := model.IsGatewayEligible(gw, gw.Rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { + return merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } return nil } @@ -105,19 +104,14 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai network = strings.ToUpper(strings.TrimSpace(network)) eligible := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error for _, gw := range all { - if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil { - lastErr = err + if err := model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { 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") + return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } sort.Slice(eligible, func(i, j int) bool { return eligible[i].ID < eligible[j].ID @@ -132,142 +126,17 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai 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 { + return toGatewayDirection(dir).String() +} + +func toGatewayDirection(dir sendDirection) model.GatewayDirection { switch dir { case sendDirectionOut: - return "out" + return model.GatewayDirectionOut case sendDirectionIn: - return "in" + return model.GatewayDirectionIn default: - return "any" + return model.GatewayDirectionAny } } - -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/go.mod b/api/payments/quotation/go.mod index 89c1abea..e83786c5 100644 --- a/api/payments/quotation/go.mod +++ b/api/payments/quotation/go.mod @@ -17,7 +17,6 @@ replace github.com/tech/sendico/ledger => ../../ledger replace github.com/tech/sendico/payments/storage => ../storage require ( - 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 @@ -45,9 +44,10 @@ require ( 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/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/prometheus/client_golang v1.23.2 // 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 @@ -63,5 +63,5 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum index 254466a3..3800b6fb 100644 --- a/api/payments/quotation/go.sum +++ b/api/payments/quotation/go.sum @@ -91,8 +91,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -211,8 +211,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/quotation/internal/server/internal/serverimp.go b/api/payments/quotation/internal/server/internal/serverimp.go index f14f6d63..b6b3fa7b 100644 --- a/api/payments/quotation/internal/server/internal/serverimp.go +++ b/api/payments/quotation/internal/server/internal/serverimp.go @@ -64,7 +64,7 @@ func (i *Imp) Start() error { return svc, nil } - app, err := grpcapp.NewApp(i.logger, "payments_quotation", cfg.Config, i.debug, repoFactory, serviceFactory) + app, err := grpcapp.NewApp(i.logger, "payments.quotation", cfg.Config, i.debug, repoFactory, serviceFactory) if err != nil { return err } diff --git a/api/payments/quotation/internal/service/plan/builder.go b/api/payments/quotation/internal/service/plan/builder.go index a33d086f..b8b6a1d5 100644 --- a/api/payments/quotation/internal/service/plan/builder.go +++ b/api/payments/quotation/internal/service/plan/builder.go @@ -58,7 +58,7 @@ func SendDirectionForRail(rail model.Rail) SendDirection { } func IsGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir SendDirection, amount decimal.Decimal) error { - return isGatewayEligible(gw, rail, network, currency, action, sendDirection(dir), amount) + return model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(sendDirection(dir)), amount) } func ParseRailValue(value string) model.Rail { diff --git a/api/payments/quotation/internal/service/plan/plan_builder_gateways.go b/api/payments/quotation/internal/service/plan/plan_builder_gateways.go index ffa34496..e982dfba 100644 --- a/api/payments/quotation/internal/service/plan/plan_builder_gateways.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_gateways.go @@ -2,7 +2,6 @@ package plan import ( "context" - "fmt" "sort" "strings" @@ -58,8 +57,8 @@ func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, 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()) + if err := model.IsGatewayEligible(gw, gw.Rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { + return merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } return nil } @@ -105,19 +104,14 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai network = strings.ToUpper(strings.TrimSpace(network)) eligible := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error for _, gw := range all { - if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil { - lastErr = err + if err := model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { 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") + return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } sort.Slice(eligible, func(i, j int) bool { return eligible[i].ID < eligible[j].ID @@ -132,142 +126,17 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai 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 { + return toGatewayDirection(dir).String() +} + +func toGatewayDirection(dir sendDirection) model.GatewayDirection { switch dir { case sendDirectionOut: - return "out" + return model.GatewayDirectionOut case sendDirectionIn: - return "in" + return model.GatewayDirectionIn default: - return "any" + return model.GatewayDirectionAny } } - -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/quotation/batch_quote_processor_v2/service.go b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go index 9e592853..1b13a35a 100644 --- a/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go @@ -2,6 +2,7 @@ package batch_quote_processor_v2 import ( "context" + "errors" "fmt" "strings" @@ -48,7 +49,7 @@ func (p *BatchQuoteProcessorV2) Process(ctx context.Context, in ProcessInput) (* Item: *item, }) if processErr != nil { - return nil, fmt.Errorf("intents[%d]: %w", item.Index, processErr) + return nil, wrapIndexedIntentError(item.Index, processErr) } if res == nil || res.Quote == nil { return nil, merrors.InvalidArgument(fmt.Sprintf("intents[%d]: quote is required", item.Index)) @@ -109,3 +110,11 @@ func buildBatchItems(ctx BatchContext, intents []*transfer_intent_hydrator.Quote return items, nil } + +func wrapIndexedIntentError(index int, err error) error { + msg := fmt.Sprintf("intents[%d]", index) + if errors.Is(err, merrors.ErrInvalidArg) { + return merrors.InvalidArgumentWrap(err, msg) + } + return merrors.InternalWrap(err, msg) +} diff --git a/api/payments/quotation/internal/service/quotation/command_factory.go b/api/payments/quotation/internal/service/quotation/command_factory.go deleted file mode 100644 index 2a9eef7d..00000000 --- a/api/payments/quotation/internal/service/quotation/command_factory.go +++ /dev/null @@ -1,71 +0,0 @@ -package quotation - -import ( - "context" - "time" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type paymentEngine interface { - EnsureRepository(ctx context.Context) error - BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) - BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) - ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, 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 *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { - return e.svc.buildPaymentQuote(ctx, orgRef, req) -} - -func (e defaultPaymentEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) { - return e.svc.buildPaymentPlan(ctx, orgID, intent, idempotencyKey, quote) -} - -func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - return e.svc.resolvePaymentQuote(ctx, in) -} - -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"), - } -} diff --git a/api/payments/quotation/internal/service/quotation/compat_helpers.go b/api/payments/quotation/internal/service/quotation/compat_helpers.go deleted file mode 100644 index 940912b9..00000000 --- a/api/payments/quotation/internal/service/quotation/compat_helpers.go +++ /dev/null @@ -1,6 +0,0 @@ -package quotation - -const ( - providerSettlementMetaPaymentIntentID = "payment_ref" - providerSettlementMetaOutgoingLeg = "outgoing_leg" -) diff --git a/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go b/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go deleted file mode 100644 index 01cc8dc0..00000000 --- a/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go +++ /dev/null @@ -1,65 +0,0 @@ -package quotation - -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 { - key := model.GatewayDescriptorIdentityKey(entry) - if key == "" { - continue - } - items[key] = 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 model.LessGatewayDescriptor(result[i], result[j]) - }) - return result, nil -} diff --git a/api/payments/quotation/internal/service/quotation/convert.go b/api/payments/quotation/internal/service/quotation/convert.go index 452690fd..024bca64 100644 --- a/api/payments/quotation/internal/service/quotation/convert.go +++ b/api/payments/quotation/internal/service/quotation/convert.go @@ -2,20 +2,16 @@ package quotation 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" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "google.golang.org/protobuf/types/known/timestamppb" ) func intentFromProto(src *sharedv1.PaymentIntent) model.PaymentIntent { @@ -106,23 +102,6 @@ func fxIntentFromProto(src *sharedv1.FXIntent) *model.FXIntent { } } -func quoteSnapshotToModel(src *sharedv1.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 protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent { intent := &sharedv1.PaymentIntent{ Ref: src.Ref, @@ -251,23 +230,6 @@ func protoFXIntentFromModel(src *model.FXIntent) *sharedv1.FXIntent { } } -func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote { - if src == nil { - return nil - } - return &sharedv1.PaymentQuote{ - DebitAmount: protoMoney(src.DebitAmount), - DebitSettlementAmount: protoMoney(src.DebitSettlementAmount), - ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount), - ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal), - FeeLines: feeLinesToProto(src.FeeLines), - FeeRules: feeRulesToProto(src.FeeRules), - FxQuote: fxQuoteToProto(src.FXQuote), - NetworkFee: networkFeeToProto(src.NetworkFee), - QuoteRef: strings.TrimSpace(src.QuoteRef), - } -} - func protoKindFromModel(kind model.PaymentKind) sharedv1.PaymentKind { switch kind { case model.PaymentKindPayout: @@ -422,66 +384,6 @@ func fxSideToProto(side paymenttypes.FXSide) fxv1.Side { } } -func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote { - if quote == nil { - return nil - } - pricedAtUnixMs := int64(0) - if ts := quote.GetPricedAt(); ts != nil { - pricedAtUnixMs = ts.AsTime().UnixMilli() - } - return &paymenttypes.FXQuote{ - QuoteRef: strings.TrimSpace(quote.GetQuoteRef()), - Pair: pairFromProto(quote.GetPair()), - Side: fxSideFromProto(quote.GetSide()), - Price: decimalFromProto(quote.GetPrice()), - BaseAmount: moneyFromProto(quote.GetBaseAmount()), - QuoteAmount: moneyFromProto(quote.GetQuoteAmount()), - ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(), - PricedAtUnixMs: pricedAtUnixMs, - Provider: strings.TrimSpace(quote.GetProvider()), - RateRef: strings.TrimSpace(quote.GetRateRef()), - Firm: quote.GetFirm(), - } -} - -func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote { - if quote == nil { - return nil - } - var pricedAt *timestamppb.Timestamp - if quote.PricedAtUnixMs > 0 { - pricedAt = timestamppb.New(time.UnixMilli(quote.PricedAtUnixMs).UTC()) - } - return &oraclev1.Quote{ - QuoteRef: strings.TrimSpace(quote.QuoteRef), - Pair: pairToProto(quote.Pair), - Side: fxSideToProto(quote.Side), - Price: decimalToProto(quote.Price), - BaseAmount: protoMoney(quote.BaseAmount), - QuoteAmount: protoMoney(quote.QuoteAmount), - ExpiresAtUnixMs: quote.ExpiresAtUnixMs, - PricedAt: pricedAt, - Provider: strings.TrimSpace(quote.Provider), - RateRef: strings.TrimSpace(quote.RateRef), - Firm: quote.Firm, - } -} - -func 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 @@ -503,197 +405,3 @@ func assetToProto(asset *paymenttypes.Asset) *chainv1.Asset { 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/quotation/discovery_gateway_registry.go b/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go deleted file mode 100644 index 41905588..00000000 --- a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go +++ /dev/null @@ -1,212 +0,0 @@ -package quotation - -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, entry.CurrencyMeta), - Version: entry.Version, - IsEnabled: entry.Healthy, - }) - } - sort.Slice(items, func(i, j int) bool { - return model.LessGatewayDescriptor(items[i], items[j]) - }) - 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, currencies []discovery.CurrencyAnnouncement) model.Limits { - limits := model.Limits{ - VolumeLimit: map[string]string{}, - VelocityLimit: map[string]int{}, - CurrencyLimits: map[string]model.LimitsOverride{}, - } - if src != nil { - limits.MinAmount = strings.TrimSpace(src.MinAmount) - limits.MaxAmount = strings.TrimSpace(src.MaxAmount) - 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 - } - } - applyCurrencyTransferLimits(&limits, currencies) - if len(limits.VolumeLimit) == 0 { - limits.VolumeLimit = nil - } - if len(limits.VelocityLimit) == 0 { - limits.VelocityLimit = nil - } - if len(limits.CurrencyLimits) == 0 { - limits.CurrencyLimits = nil - } - return limits -} - -func applyCurrencyTransferLimits(dst *model.Limits, currencies []discovery.CurrencyAnnouncement) { - if dst == nil || len(currencies) == 0 { - return - } - var ( - commonMin string - commonMax string - commonMinInit bool - commonMaxInit bool - commonMinConsistent = true - commonMaxConsistent = true - ) - - for _, currency := range currencies { - code := strings.ToUpper(strings.TrimSpace(currency.Currency)) - if code == "" || currency.Limits == nil || currency.Limits.Amount == nil { - commonMinConsistent = false - commonMaxConsistent = false - continue - } - min := strings.TrimSpace(currency.Limits.Amount.Min) - max := strings.TrimSpace(currency.Limits.Amount.Max) - - if min != "" || max != "" { - override := dst.CurrencyLimits[code] - if min != "" { - override.MinAmount = min - } - if max != "" { - override.MaxAmount = max - } - if override.MinAmount != "" || override.MaxAmount != "" || override.MaxFee != "" || override.MaxOps > 0 || override.MaxVolume != "" { - dst.CurrencyLimits[code] = override - } - } - - if min == "" { - commonMinConsistent = false - } else if !commonMinInit { - commonMin = min - commonMinInit = true - } else if commonMin != min { - commonMinConsistent = false - } - - if max == "" { - commonMaxConsistent = false - } else if !commonMaxInit { - commonMax = max - commonMaxInit = true - } else if commonMax != max { - commonMaxConsistent = false - } - } - - if commonMinInit && commonMinConsistent { - dst.PerTxMinAmount = firstLimitValue(dst.PerTxMinAmount, commonMin) - } - if commonMaxInit && commonMaxConsistent { - dst.PerTxMaxAmount = firstLimitValue(dst.PerTxMaxAmount, commonMax) - } -} - -func firstLimitValue(primary, fallback string) string { - primary = strings.TrimSpace(primary) - if primary != "" { - return primary - } - return strings.TrimSpace(fallback) -} diff --git a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry_test.go b/api/payments/quotation/internal/service/quotation/discovery_gateway_registry_test.go deleted file mode 100644 index 91ff51e9..00000000 --- a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package quotation - -import ( - "testing" - - "github.com/tech/sendico/pkg/discovery" -) - -func TestLimitsFromDiscovery_MapsPerTxMinimumFromCurrencyMeta(t *testing.T) { - limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{ - { - Currency: "RUB", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{ - Min: "100.00", - Max: "10000.00", - }, - }, - }, - }) - - if limits.PerTxMinAmount != "100.00" { - t.Fatalf("expected per tx min 100.00, got %q", limits.PerTxMinAmount) - } - if limits.PerTxMaxAmount != "10000.00" { - t.Fatalf("expected per tx max 10000.00, got %q", limits.PerTxMaxAmount) - } - override, ok := limits.CurrencyLimits["RUB"] - if !ok { - t.Fatalf("expected RUB currency override") - } - if override.MinAmount != "100.00" { - t.Fatalf("expected RUB min override 100.00, got %q", override.MinAmount) - } -} - -func TestLimitsFromDiscovery_DropsCommonPerTxMinimumWhenCurrenciesDiffer(t *testing.T) { - limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{ - { - Currency: "USD", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{Min: "10.00"}, - }, - }, - { - Currency: "EUR", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{Min: "20.00"}, - }, - }, - }) - - if limits.PerTxMinAmount != "" { - t.Fatalf("expected empty common per tx min, got %q", limits.PerTxMinAmount) - } - if limits.CurrencyLimits["USD"].MinAmount != "10.00" { - t.Fatalf("expected USD min override 10.00, got %q", limits.CurrencyLimits["USD"].MinAmount) - } - if limits.CurrencyLimits["EUR"].MinAmount != "20.00" { - t.Fatalf("expected EUR min override 20.00, got %q", limits.CurrencyLimits["EUR"].MinAmount) - } -} diff --git a/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go b/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go deleted file mode 100644 index 49564387..00000000 --- a/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go +++ /dev/null @@ -1,12 +0,0 @@ -package quotation - -func (s *Service) Shutdown() { - if s == nil { - return - } - for _, consumer := range s.gatewayConsumers { - if consumer != nil { - consumer.Close() - } - } -} diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go index 1c4dc231..485280f6 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go @@ -217,9 +217,6 @@ func (r *StaticFundingProfileResolver) gatewayKey(req FundingProfileRequest) str if key := normalizeGatewayKey(req.Attributes["gateway"]); key != "" { return key } - if req.Destination != nil && req.Destination.Card != nil { - return r.defaultCardGateway - } return "" } diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go index 1b451b74..bdafd3b6 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go @@ -10,7 +10,7 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) -func TestStaticFundingProfileResolver_DefaultCardRoute(t *testing.T) { +func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) { resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{ DefaultMode: model.FundingModeNone, CardRoutes: map[string]CardGatewayFundingRoute{ @@ -47,6 +47,7 @@ func TestStaticFundingProfileResolver_DefaultCardRoute(t *testing.T) { }, }, Attributes: map[string]string{ + "gateway": "monetix", "initiator_ref": "usr-1", }, }) diff --git a/api/payments/quotation/internal/service/quotation/gateway_registry.go b/api/payments/quotation/internal/service/quotation/gateway_registry.go deleted file mode 100644 index f41cdc02..00000000 --- a/api/payments/quotation/internal/service/quotation/gateway_registry.go +++ /dev/null @@ -1,117 +0,0 @@ -package quotation - -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 { - key := model.GatewayDescriptorIdentityKey(gw) - if key == "" { - continue - } - items[key] = 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 model.LessGatewayDescriptor(result[i], result[j]) - }) - 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/quotation/gateway_registry_identity_test.go b/api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go deleted file mode 100644 index 6e4b4abe..00000000 --- a/api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package quotation - -import ( - "context" - "testing" - - "github.com/tech/sendico/payments/storage/model" -) - -type identityGatewayRegistryStub struct { - items []*model.GatewayInstanceDescriptor -} - -func (s identityGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} - -func TestGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) { - registry := NewGatewayRegistry(nil, []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"}, - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"}, - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a-new"}, - }) - if registry == nil { - t.Fatalf("expected registry to be created") - } - - items, err := registry.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got, want := len(items), 2; got != want { - t.Fatalf("unexpected items count: got=%d want=%d", got, want) - } - if got, want := items[0].InstanceID, "inst-a"; got != want { - t.Fatalf("unexpected first instance id: got=%q want=%q", got, want) - } - if got, want := items[0].InvokeURI, "grpc://a-new"; got != want { - t.Fatalf("expected latest duplicate to win for same gateway+instance: got=%q want=%q", got, want) - } - if got, want := items[1].InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected second instance id: got=%q want=%q", got, want) - } -} - -func TestCompositeGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) { - registry := NewCompositeGatewayRegistry(nil, - identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"}, - }}, - identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"}, - }}, - ) - if registry == nil { - t.Fatalf("expected registry to be created") - } - - items, err := registry.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got, want := len(items), 2; got != want { - t.Fatalf("unexpected items count: got=%d want=%d", got, want) - } - if got, want := items[0].InstanceID, "inst-a"; got != want { - t.Fatalf("unexpected first instance id: got=%q want=%q", got, want) - } - if got, want := items[1].InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected second instance id: got=%q want=%q", got, want) - } -} diff --git a/api/payments/quotation/internal/service/quotation/gateway_resolution.go b/api/payments/quotation/internal/service/quotation/gateway_resolution.go index b43677ac..7c82c703 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_resolution.go +++ b/api/payments/quotation/internal/service/quotation/gateway_resolution.go @@ -83,7 +83,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail network = strings.ToUpper(strings.TrimSpace(network)) eligible := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error for _, entry := range all { if entry == nil || !entry.IsEnabled { continue @@ -94,7 +93,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail ok := true for _, action := range actions { if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil { - lastErr = err ok = false break } @@ -106,10 +104,11 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail } if len(eligible) == 0 { - if lastErr != nil { - return nil, merrors.NoData("no eligible gateway instance found: " + lastErr.Error()) + action := model.RailOperationUnspecified + if len(actions) > 0 { + action = actions[0] } - return nil, merrors.NoData("no eligible gateway instance found") + return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } sort.Slice(eligible, func(i, j int) bool { return eligible[i].ID < eligible[j].ID @@ -124,6 +123,17 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail return eligible[0], nil } +func toGatewayDirection(dir plan.SendDirection) model.GatewayDirection { + switch dir { + case plan.SendDirectionOut: + return model.GatewayDirectionOut + case plan.SendDirectionIn: + return model.GatewayDirectionIn + default: + return model.GatewayDirectionAny + } +} + func railActionNames(actions []model.RailOperation) []string { if len(actions) == 0 { return nil diff --git a/api/payments/quotation/internal/service/quotation/handlers_commands.go b/api/payments/quotation/internal/service/quotation/handlers_commands.go deleted file mode 100644 index 09245da1..00000000 --- a/api/payments/quotation/internal/service/quotation/handlers_commands.go +++ /dev/null @@ -1,640 +0,0 @@ -package quotation - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "sort" - "strings" - "time" - - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "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" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 *sharedv1.PaymentIntent - previewOnly bool - idempotencyKey string - hash string -} - -type quotePaymentResult struct { - quote *sharedv1.PaymentQuote - executionNote string -} - -func (h *quotePaymentCommand) Execute( - ctx context.Context, - req *quotationv1.QuotePaymentRequest, -) gsresponse.Responder[quotationv1.QuotePaymentResponse] { - - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[quotationv1.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[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - result, err := h.quotePayment(ctx, quotesStore, qc, req) - if err != nil { - return h.mapQuoteErr(err) - } - - return gsresponse.Success("ationv1.QuotePaymentResponse{ - IdempotencyKey: req.GetIdempotencyKey(), - Quote: result.quote, - ExecutionNote: result.executionNote, - }) -} - -func (h *quotePaymentCommand) prepareQuoteCtx(req *quotationv1.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 quotestorage.QuotesStore, - qc *quoteCtx, - req *quotationv1.QuotePaymentRequest, -) (*quotePaymentResult, 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 "ePaymentResult{quote: quote}, nil - } - - existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil && !errors.Is(err, quotestorage.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 "ePaymentResult{ - quote: modelQuoteToProto(existing.Quote), - executionNote: strings.TrimSpace(existing.ExecutionNote), - }, 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 - - executionNote := "" - plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote) - if err != nil { - if errors.Is(err, merrors.ErrInvalidArg) { - executionNote = quoteNonExecutableNote(err) - h.logger.Info( - "Payment quote marked as non-executable", - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("quote_ref", quoteRef), - zap.String("execution_note", executionNote), - ) - } else { - h.logger.Warn( - "Failed to build payment plan", - zap.Error(err), - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - ) - return nil, err - } - } - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - IdempotencyKey: qc.idempotencyKey, - Hash: qc.hash, - Intent: intentFromProto(qc.intent), - Quote: quoteSnapshotToModel(quote), - Plan: cloneStoredPaymentPlan(plan), - ExecutionNote: executionNote, - ExpiresAt: expiresAt, - } - record.SetID(bson.NewObjectID()) - record.SetOrganizationRef(qc.orgRef) - - if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, quotestorage.ErrDuplicateQuote) { - existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if getErr == nil && existing != nil { - if existing.Hash != qc.hash { - return nil, errIdempotencyParamMismatch - } - return "ePaymentResult{ - quote: modelQuoteToProto(existing.Quote), - executionNote: strings.TrimSpace(existing.ExecutionNote), - }, 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 "ePaymentResult{ - quote: quote, - executionNote: executionNote, - }, nil -} - -func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotationv1.QuotePaymentResponse] { - if errors.Is(err, errIdempotencyRequired) || - errors.Is(err, errPreviewWithIdempotency) || - errors.Is(err, errIdempotencyParamMismatch) { - return gsresponse.InvalidArgument[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) -} - -func quoteNonExecutableNote(err error) string { - reason := strings.TrimSpace(err.Error()) - reason = strings.TrimPrefix(reason, merrors.ErrInvalidArg.Error()+":") - reason = strings.TrimSpace(reason) - if reason == "" { - return "quote will not be executed" - } - return "quote will not be executed: " + reason -} - -// TODO: temprorarary hashing function, replace with a proper solution later -func hashQuoteRequest(req *quotationv1.QuotePaymentRequest) string { - cloned := proto.Clone(req).(*quotationv1.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 *quotationv1.QuotePaymentsRequest, -) gsresponse.Responder[quotationv1.QuotePaymentsResponse] { - - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[quotationv1.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[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if qc.previewOnly { - quotes, _, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, true) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - aggregate, expiresAt, err := h.aggregate(quotes, expires) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - _ = expiresAt - return gsresponse.Success("ationv1.QuotePaymentsResponse{ - QuoteRef: "", - Aggregate: aggregate, - Quotes: quotes, - }) - } - - if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } else if ok { - return gsresponse.Success(h.responseFromRecord(rec)) - } - - quotes, plans, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, false) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - aggregate, expiresAt, err := h.aggregate(quotes, expires) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - quoteRef := bson.NewObjectID().Hex() - for _, q := range quotes { - if q != nil { - q.QuoteRef = quoteRef - } - } - - rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, plans, expiresAt) - if err != nil { - return gsresponse.Auto[quotationv1.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("ationv1.QuotePaymentsResponse{ - IdempotencyKey: req.GetIdempotencyKey(), - QuoteRef: quoteRef, - Aggregate: aggregate, - Quotes: quotes, - }) -} - -func (h *quotePaymentsCommand) prepare(req *quotationv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*sharedv1.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 quotestorage.QuotesStore, - qc *quotePaymentsCtx, -) (*model.PaymentQuoteRecord, bool, error) { - - rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil { - if errors.Is(err, quotestorage.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 *sharedv1.RequestMeta, - orgRef bson.ObjectID, - baseKey string, - intents []*sharedv1.PaymentIntent, - preview bool, -) ([]*sharedv1.PaymentQuote, []*model.PaymentPlan, []time.Time, error) { - - quotes := make([]*sharedv1.PaymentQuote, 0, len(intents)) - plans := make([]*model.PaymentPlan, 0, len(intents)) - expires := make([]time.Time, 0, len(intents)) - - for i, intent := range intents { - perKey := perIntentIdempotencyKey(baseKey, i, len(intents)) - req := "ationv1.QuotePaymentRequest{ - Meta: meta, - IdempotencyKey: perKey, - Intent: intent, - PreviewOnly: preview, - } - q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req) - if err != nil { - h.logger.Warn( - "Failed to build payment quote (batch item)", - zap.Int("idx", i), - zap.Error(err), - ) - return nil, nil, nil, err - } - if !preview { - plan, err := h.engine.BuildPaymentPlan(ctx, orgRef, intent, perKey, q) - if err != nil { - h.logger.Warn( - "Failed to build payment plan (batch item)", - zap.Int("idx", i), - zap.Error(err), - ) - return nil, nil, nil, err - } - plans = append(plans, cloneStoredPaymentPlan(plan)) - } - quotes = append(quotes, q) - expires = append(expires, exp) - } - - return quotes, plans, expires, nil -} - -func (h *quotePaymentsCommand) aggregate( - quotes []*sharedv1.PaymentQuote, - expires []time.Time, -) (*sharedv1.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 quotestorage.QuotesStore, - qc *quotePaymentsCtx, - quoteRef string, - intents []*sharedv1.PaymentIntent, - quotes []*sharedv1.PaymentQuote, - plans []*model.PaymentPlan, - expiresAt time.Time, -) (*model.PaymentQuoteRecord, error) { - - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - IdempotencyKey: qc.idempotencyKey, - Hash: qc.hash, - Intents: intentsFromProto(intents), - Quotes: quoteSnapshotsFromProto(quotes), - Plans: cloneStoredPaymentPlans(plans), - ExpiresAt: expiresAt, - } - record.SetID(bson.NewObjectID()) - record.SetOrganizationRef(qc.orgRef) - - if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, quotestorage.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) *quotationv1.QuotePaymentsResponse { - quotes := modelQuotesToProto(rec.Quotes) - for _, q := range quotes { - if q != nil { - q.QuoteRef = rec.QuoteRef - } - } - aggregate, _ := aggregatePaymentQuotes(quotes) - - return "ationv1.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[quotationv1.QuotePaymentsResponse] { - if errors.Is(err, errBatchIdempotencyRequired) || - errors.Is(err, errBatchPreviewWithIdempotency) || - errors.Is(err, errBatchIdempotencyParamMismatch) || - errors.Is(err, errBatchIdempotencyShapeMismatch) { - return gsresponse.InvalidArgument[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) -} - -func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*sharedv1.PaymentQuote { - if len(snaps) == 0 { - return nil - } - out := make([]*sharedv1.PaymentQuote, 0, len(snaps)) - for _, s := range snaps { - out = append(out, modelQuoteToProto(s)) - } - return out -} - -func hashQuotePaymentsIntents(intents []*sharedv1.PaymentIntent) (string, error) { - type item struct { - Idx int - H [32]byte - } - items := make([]item, 0, len(intents)) - - for i, intent := range intents { - b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent) - if err != nil { - return "", err - } - items = append(items, item{Idx: i, H: sha256.Sum256(b)}) - } - - sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx }) - - h := sha256.New() - h.Write([]byte("quote-payments-fp/v1")) - h.Write([]byte{0}) - for _, it := range items { - h.Write(it.H[:]) - h.Write([]byte{0}) - } - - return hex.EncodeToString(h.Sum(nil)), nil -} diff --git a/api/payments/quotation/internal/service/quotation/handlers_commands_test.go b/api/payments/quotation/internal/service/quotation/handlers_commands_test.go deleted file mode 100644 index 23305110..00000000 --- a/api/payments/quotation/internal/service/quotation/handlers_commands_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package quotation - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/merrors" - mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestQuotePaymentStoresNonExecutableQuoteWhenPlanInvalid(t *testing.T) { - org := bson.NewObjectID() - req := "ationv1.QuotePaymentRequest{ - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - IdempotencyKey: "idem-1", - Intent: &sharedv1.PaymentIntent{ - Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - }, - } - - quotesStore := "eCommandTestQuotesStore{ - byID: make(map[string]*model.PaymentQuoteRecord), - } - engine := "eCommandTestEngine{ - repo: quoteCommandTestRepo{quotes: quotesStore}, - buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { - return &sharedv1.PaymentQuote{ - DebitAmount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - }, time.Now().Add(time.Hour), nil - }, - buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) { - return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: gateway mntx eligibility check error: amount 1 USD below per-tx min limit 10") - }, - } - cmd := "ePaymentCommand{ - engine: engine, - logger: mloggerfactory.NewLogger(false), - } - - resp, err := cmd.Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil || resp.GetQuote() == nil { - t.Fatalf("expected quote response, got %#v", resp) - } - if note := resp.GetExecutionNote(); !strings.Contains(note, "quote will not be executed") { - t.Fatalf("expected non-executable note, got %q", note) - } - - stored := quotesStore.byID[req.GetIdempotencyKey()] - if stored == nil { - t.Fatalf("expected stored quote record") - } - if stored.Plan != nil { - t.Fatalf("expected no stored payment plan for non-executable quote") - } - if stored.ExecutionNote != resp.GetExecutionNote() { - t.Fatalf("expected stored execution note %q, got %q", resp.GetExecutionNote(), stored.ExecutionNote) - } -} - -func TestQuotePaymentReuseReturnsStoredExecutionNote(t *testing.T) { - org := bson.NewObjectID() - req := "ationv1.QuotePaymentRequest{ - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - IdempotencyKey: "idem-1", - Intent: &sharedv1.PaymentIntent{ - Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - }, - } - - existing := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - IdempotencyKey: req.GetIdempotencyKey(), - Hash: hashQuoteRequest(req), - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - ExecutionNote: "quote will not be executed: amount 1 USD below per-tx min limit 10", - } - quotesStore := "eCommandTestQuotesStore{ - byID: map[string]*model.PaymentQuoteRecord{ - req.GetIdempotencyKey(): existing, - }, - } - engine := "eCommandTestEngine{ - repo: quoteCommandTestRepo{quotes: quotesStore}, - buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { - t.Fatalf("build quote should not be called on idempotent reuse") - return nil, time.Time{}, nil - }, - buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) { - t.Fatalf("build plan should not be called on idempotent reuse") - return nil, nil - }, - } - cmd := "ePaymentCommand{ - engine: engine, - logger: mloggerfactory.NewLogger(false), - } - - resp, err := cmd.Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatalf("expected response") - } - if got, want := resp.GetExecutionNote(), existing.ExecutionNote; got != want { - t.Fatalf("expected execution note %q, got %q", want, got) - } - if resp.GetQuote().GetQuoteRef() != "q1" { - t.Fatalf("expected quote_ref q1, got %q", resp.GetQuote().GetQuoteRef()) - } -} - -type quoteCommandTestEngine struct { - repo storage.Repository - ensureErr error - buildQuoteFn func(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) - buildPlanFn func(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) -} - -func (e *quoteCommandTestEngine) EnsureRepository(context.Context) error { return e.ensureErr } - -func (e *quoteCommandTestEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { - if e.buildQuoteFn == nil { - return nil, time.Time{}, nil - } - return e.buildQuoteFn(ctx, orgRef, req) -} - -func (e *quoteCommandTestEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) { - if e.buildPlanFn == nil { - return nil, nil - } - return e.buildPlanFn(ctx, orgID, intent, idempotencyKey, quote) -} - -func (e *quoteCommandTestEngine) ResolvePaymentQuote(context.Context, quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - return nil, nil, nil, nil -} - -func (e *quoteCommandTestEngine) Repository() storage.Repository { return e.repo } - -type quoteCommandTestRepo struct { - quotes quotestorage.QuotesStore -} - -func (r quoteCommandTestRepo) Ping(context.Context) error { return nil } -func (r quoteCommandTestRepo) Payments() storage.PaymentsStore { return nil } -func (r quoteCommandTestRepo) PaymentMethods() storage.PaymentMethodsStore { return nil } -func (r quoteCommandTestRepo) Quotes() quotestorage.QuotesStore { return r.quotes } -func (r quoteCommandTestRepo) Routes() storage.RoutesStore { return nil } -func (r quoteCommandTestRepo) PlanTemplates() storage.PlanTemplatesStore { return nil } - -type quoteCommandTestQuotesStore struct { - byID map[string]*model.PaymentQuoteRecord -} - -func (s *quoteCommandTestQuotesStore) Create(_ context.Context, rec *model.PaymentQuoteRecord) error { - if s.byID == nil { - s.byID = make(map[string]*model.PaymentQuoteRecord) - } - s.byID[rec.IdempotencyKey] = rec - return nil -} - -func (s *quoteCommandTestQuotesStore) GetByRef(_ context.Context, _ bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { - for _, rec := range s.byID { - if rec != nil && rec.QuoteRef == quoteRef { - return rec, nil - } - } - return nil, quotestorage.ErrQuoteNotFound -} - -func (s *quoteCommandTestQuotesStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) { - if rec, ok := s.byID[idempotencyKey]; ok { - return rec, nil - } - return nil, quotestorage.ErrQuoteNotFound -} diff --git a/api/payments/quotation/internal/service/quotation/helpers.go b/api/payments/quotation/internal/service/quotation/helpers.go index 1bc1b1ec..68b7b4bc 100644 --- a/api/payments/quotation/internal/service/quotation/helpers.go +++ b/api/payments/quotation/internal/service/quotation/helpers.go @@ -51,24 +51,6 @@ func cloneMetadata(input map[string]string) map[string]string { return clone } -func cloneStringList(values []string) []string { - if len(values) == 0 { - return nil - } - result := make([]string, 0, len(values)) - for _, value := range values { - clean := strings.TrimSpace(value) - if clean == "" { - continue - } - result = append(result, clean) - } - if len(result) == 0 { - return nil - } - return result -} - func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine { if len(lines) == 0 { return nil diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go index 136611b4..8357d43b 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -5,20 +5,11 @@ import ( "strings" "time" - "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" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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) @@ -26,13 +17,6 @@ func (s *Service) withTimeout(ctx context.Context, d time.Duration) (context.Con 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 sharedv1.PaymentKind, requiresFX bool) feesv1.Trigger { switch kind { case sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT: diff --git a/api/payments/quotation/internal/service/quotation/metrics.go b/api/payments/quotation/internal/service/quotation/metrics.go deleted file mode 100644 index d6a5bf19..00000000 --- a/api/payments/quotation/internal/service/quotation/metrics.go +++ /dev/null @@ -1,65 +0,0 @@ -package quotation - -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/quotation/model_money.go b/api/payments/quotation/internal/service/quotation/model_money.go deleted file mode 100644 index cc9d09e0..00000000 --- a/api/payments/quotation/internal/service/quotation/model_money.go +++ /dev/null @@ -1,13 +0,0 @@ -package quotation - -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/quotation/options.go b/api/payments/quotation/internal/service/quotation/options.go index 576c2af2..ac04dad1 100644 --- a/api/payments/quotation/internal/service/quotation/options.go +++ b/api/payments/quotation/internal/service/quotation/options.go @@ -2,7 +2,6 @@ package quotation import ( "context" - "sort" "strings" "time" @@ -11,8 +10,7 @@ import ( 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/discovery" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" @@ -31,6 +29,18 @@ type ChainGatewayResolver interface { Resolve(ctx context.Context, network string) (chainclient.Client, error) } +// GatewayRegistry exposes gateway instances for capability-based selection. +type GatewayRegistry interface { + List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) +} + +// CardGatewayRoute maps a gateway to its funding and fee destinations. +type CardGatewayRoute struct { + FundingAddress string + FeeAddress string + FeeWalletRef string +} + type feesDependency struct { client feesv1.FeeEngineClient timeout time.Duration @@ -46,24 +56,10 @@ func (f feesDependency) available() bool { 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 -} - type oracleDependency struct { client oracleclient.Client } @@ -78,53 +74,25 @@ func (o oracleDependency) available() bool { 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") - } +func (r staticChainGatewayResolver) Resolve(context.Context, string) (chainclient.Client, error) { 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, - } + s.deps.fees = feesDependency{client: client, timeout: timeout} } } -func WithPaymentGatewayBroker(broker mb.Broker) Option { +// WithOracleClient wires the FX oracle client. +func WithOracleClient(client oracleclient.Client) 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, - } + s.deps.oracle = oracleDependency{client: client} } } @@ -144,48 +112,21 @@ func WithChainGatewayResolver(resolver ChainGatewayResolver) Option { } } -// 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. +// WithGatewayInvokeResolver wires a resolver for invoke URIs. func WithGatewayInvokeResolver(resolver GatewayInvokeResolver) Option { return func(s *Service) { - if resolver == nil { - return + if resolver != nil { + s.deps.gatewayInvokeResolver = resolver } - 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 { +// WithGatewayRegistry wires gateway descriptors used by quote computation/gateway selection. +func WithGatewayRegistry(registry GatewayRegistry) Option { return func(s *Service) { - if len(gateways) == 0 { - return + if registry != nil { + s.deps.gatewayRegistry = registry } - 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} } } @@ -196,51 +137,30 @@ func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option { 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 == "" { + for key, route := range routes { + normalized := strings.ToLower(strings.TrimSpace(key)) + if normalized == "" { continue } - s.deps.feeLedgerAccounts[key] = val + s.deps.cardRoutes[normalized] = route } } } -// WithPlanBuilder wires a payment plan builder implementation. -func WithPlanBuilder(builder PlanBuilder) Option { +// WithFeeLedgerAccounts maps gateway IDs to fee ledger accounts. +func WithFeeLedgerAccounts(accounts map[string]string) Option { return func(s *Service) { - if builder != nil { - s.deps.planBuilder = builder + if len(accounts) == 0 { + return } - } -} - -// 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) + s.deps.feeLedgerAccounts = make(map[string]string, len(accounts)) + for key, account := range accounts { + normalized := strings.ToLower(strings.TrimSpace(key)) + value := strings.TrimSpace(account) + if normalized == "" || value == "" { + continue } + s.deps.feeLedgerAccounts[normalized] = value } } } @@ -254,46 +174,139 @@ func WithClock(clock clockpkg.Clock) Option { } } -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, +// WithLedgerClient is retained for backward compatibility and is currently a no-op. +func WithLedgerClient(_ ledgerclient.Client) Option { + return func(*Service) {} +} + +// WithProviderSettlementGatewayClient is retained for backward compatibility and is currently a no-op. +func WithProviderSettlementGatewayClient(_ chainclient.Client) Option { + return func(*Service) {} +} + +// WithProviderSettlementGatewayResolver is retained for backward compatibility and is currently a no-op. +func WithProviderSettlementGatewayResolver(_ ChainGatewayResolver) Option { + return func(*Service) {} +} + +// WithRailGateways is retained for backward compatibility and is currently a no-op. +func WithRailGateways(_ map[string]rail.RailGateway) Option { + return func(*Service) {} +} + +type discoveryGatewayRegistry struct { + registry *discovery.Registry +} + +// NewDiscoveryGatewayRegistry adapts discovery entries into gateway descriptors. +func NewDiscoveryGatewayRegistry(_ mlogger.Logger, registry *discovery.Registry) GatewayRegistry { + if registry == nil { + return nil } - if len(gateways) == 0 { - return result + return &discoveryGatewayRegistry{registry: registry} +} + +func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if r == nil || r.registry == nil { + return nil, nil } - 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()) + entries := r.registry.List(time.Now(), true) + items := make([]*model.GatewayInstanceDescriptor, 0, len(entries)) + for _, entry := range entries { + railID := railFromDiscovery(entry.Rail) if railID == model.RailUnspecified { continue } - itemsByRail[railID] = append(itemsByRail[railID], item{id: cleanID, gw: gw}) + operations := operationsFromDiscovery(entry.Operations) + items = append(items, &model.GatewayInstanceDescriptor{ + ID: strings.TrimSpace(entry.ID), + InstanceID: strings.TrimSpace(entry.InstanceID), + Rail: railID, + Network: strings.ToUpper(strings.TrimSpace(entry.Network)), + InvokeURI: strings.TrimSpace(entry.InvokeURI), + Currencies: currenciesFromDiscovery(entry.Currencies), + Operations: operations, + Capabilities: model.RailCapabilitiesFromOperations(operations), + Limits: limitsFromDiscovery(entry.Limits), + IsEnabled: entry.Healthy, + }) + } + return items, nil +} + +func railFromDiscovery(value string) model.Rail { + switch discovery.NormalizeRail(value) { + case discovery.RailCrypto: + return model.RailCrypto + case discovery.RailProviderSettlement: + return model.RailProviderSettlement + case discovery.RailLedger: + return model.RailLedger + case discovery.RailCardPayout: + return model.RailCardPayout + case discovery.RailFiatOnRamp: + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} + +func operationsFromDiscovery(values []string) []model.RailOperation { + return model.NormalizeRailOperationStrings(discovery.NormalizeRailOperations(values)) +} + +func currenciesFromDiscovery(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + currency := strings.ToUpper(strings.TrimSpace(value)) + if currency == "" || seen[currency] { + continue + } + seen[currency] = true + result = append(result, currency) + } + if len(result) == 0 { + return nil + } + return result +} + +func limitsFromDiscovery(src *discovery.Limits) model.Limits { + limits := model.Limits{} + if src == nil { + return limits } - 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) + limits.MinAmount = strings.TrimSpace(src.MinAmount) + limits.MaxAmount = strings.TrimSpace(src.MaxAmount) + + if len(src.VolumeLimit) > 0 { + limits.VolumeLimit = map[string]string{} + for bucket, value := range src.VolumeLimit { + key := strings.TrimSpace(bucket) + amount := strings.TrimSpace(value) + if key == "" || amount == "" { + continue + } + limits.VolumeLimit[key] = amount } } - return result + if len(src.VelocityLimit) > 0 { + limits.VelocityLimit = map[string]int{} + for bucket, value := range src.VelocityLimit { + key := strings.TrimSpace(bucket) + if key == "" || value <= 0 { + continue + } + limits.VelocityLimit[key] = value + } + } + + return limits } diff --git a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go b/api/payments/quotation/internal/service/quotation/payment_plan_factory.go deleted file mode 100644 index c171c54c..00000000 --- a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go +++ /dev/null @@ -1,161 +0,0 @@ -package quotation - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/quotation/internal/shared" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (s *Service) buildPaymentPlan( - ctx context.Context, - orgID bson.ObjectID, - intent *sharedv1.PaymentIntent, - idempotencyKey string, - quote *sharedv1.PaymentQuote, -) (*model.PaymentPlan, error) { - if s == nil || s.storage == nil { - return nil, errStorageUnavailable - } - if err := requireNonNilIntent(intent); err != nil { - return nil, err - } - - routeStore := s.storage.Routes() - if routeStore == nil { - return nil, merrors.InvalidArgument("routes store is required") - } - planTemplates := s.storage.PlanTemplates() - if planTemplates == nil { - return nil, merrors.InvalidArgument("plan templates store is required") - } - - builder := s.deps.planBuilder - if builder == nil { - builder = newDefaultPlanBuilder(s.logger.Named("plan_builder")) - } - - planQuote := quote - if planQuote == nil { - planQuote = &sharedv1.PaymentQuote{} - } - payment := newPayment(orgID, intent, strings.TrimSpace(idempotencyKey), nil, planQuote) - if ref := strings.TrimSpace(planQuote.GetQuoteRef()); ref != "" { - payment.PaymentRef = ref - } - - plan, err := builder.Build(ctx, payment, planQuote, routeStore, planTemplates, s.deps.gatewayRegistry) - if err != nil { - return nil, err - } - if plan == nil || len(plan.Steps) == 0 { - return nil, merrors.InvalidArgument("payment plan is required") - } - return plan, nil -} - -func cloneStoredPaymentPlans(plans []*model.PaymentPlan) []*model.PaymentPlan { - if len(plans) == 0 { - return nil - } - out := make([]*model.PaymentPlan, 0, len(plans)) - for _, p := range plans { - if p == nil { - out = append(out, nil) - continue - } - out = append(out, cloneStoredPaymentPlan(p)) - } - return out -} - -func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan { - if src == nil { - return nil - } - clone := &model.PaymentPlan{ - ID: strings.TrimSpace(src.ID), - IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), - CreatedAt: src.CreatedAt, - FXQuote: cloneStoredFXQuote(src.FXQuote), - Fees: cloneStoredFeeLines(src.Fees), - } - if len(src.Steps) > 0 { - clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps)) - for _, step := range src.Steps { - if step == nil { - clone.Steps = append(clone.Steps, nil) - continue - } - stepClone := &model.PaymentStep{ - StepID: strings.TrimSpace(step.StepID), - Rail: step.Rail, - GatewayID: strings.TrimSpace(step.GatewayID), - InstanceID: strings.TrimSpace(step.InstanceID), - GatewayInvokeURI: strings.TrimSpace(step.GatewayInvokeURI), - Action: step.Action, - DependsOn: cloneStringList(step.DependsOn), - CommitPolicy: step.CommitPolicy, - CommitAfter: cloneStringList(step.CommitAfter), - Amount: cloneMoney(step.Amount), - FromRole: shared.CloneAccountRole(step.FromRole), - ToRole: shared.CloneAccountRole(step.ToRole), - } - clone.Steps = append(clone.Steps, stepClone) - } - } - return clone -} - -func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote { - if src == nil { - return nil - } - result := &paymenttypes.FXQuote{ - QuoteRef: strings.TrimSpace(src.QuoteRef), - Side: src.Side, - ExpiresAtUnixMs: src.ExpiresAtUnixMs, - PricedAtUnixMs: src.PricedAtUnixMs, - Provider: strings.TrimSpace(src.Provider), - RateRef: strings.TrimSpace(src.RateRef), - Firm: src.Firm, - BaseAmount: cloneMoney(src.BaseAmount), - QuoteAmount: cloneMoney(src.QuoteAmount), - } - if src.Pair != nil { - result.Pair = &paymenttypes.CurrencyPair{ - Base: strings.TrimSpace(src.Pair.Base), - Quote: strings.TrimSpace(src.Pair.Quote), - } - } - if src.Price != nil { - result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)} - } - return result -} - -func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine { - if len(lines) == 0 { - return nil - } - result := make([]*paymenttypes.FeeLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - result = append(result, nil) - continue - } - result = append(result, &paymenttypes.FeeLine{ - LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), - Money: cloneMoney(line.Money), - LineType: line.LineType, - Side: line.Side, - Meta: cloneMetadata(line.Meta), - }) - } - return result -} diff --git a/api/payments/quotation/internal/service/quotation/plan_builder.go b/api/payments/quotation/internal/service/quotation/plan_builder.go deleted file mode 100644 index ab4f4671..00000000 --- a/api/payments/quotation/internal/service/quotation/plan_builder.go +++ /dev/null @@ -1,28 +0,0 @@ -package quotation - -import ( - "context" - - "github.com/tech/sendico/payments/storage/model" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) -} diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go index 5a0bfc5e..30217ced 100644 --- a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go @@ -6,21 +6,8 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/plan" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) -type defaultPlanBuilder struct { - inner plan.Builder -} - -func newDefaultPlanBuilder(logger mlogger.Logger) PlanBuilder { - return &defaultPlanBuilder{inner: plan.NewDefaultBuilder(logger)} -} - -func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { - return b.inner.Build(ctx, payment, quote, routes, templates, gateways) -} - func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { return plan.RailFromEndpoint(endpoint, attrs, isSource) } @@ -29,6 +16,13 @@ func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork str return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork) } -func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { +func selectPlanTemplate( + ctx context.Context, + logger mlogger.Logger, + templates plan.PlanTemplateStore, + sourceRail model.Rail, + destRail model.Rail, + network string, +) (*model.PaymentPlanTemplate, error) { return plan.SelectTemplate(ctx, logger, templates, sourceRail, destRail, network) } diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_compat.go b/api/payments/quotation/internal/service/quotation/plan_builder_compat.go index 9fc9d32a..aa8ac01e 100644 --- a/api/payments/quotation/internal/service/quotation/plan_builder_compat.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder_compat.go @@ -13,7 +13,3 @@ func sendDirectionForRail(rail model.Rail) plan.SendDirection { func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir plan.SendDirection, amount decimal.Decimal) error { return plan.IsGatewayEligible(gw, rail, network, currency, action, dir, amount) } - -func parseRailValue(value string) model.Rail { - return plan.ParseRailValue(value) -} diff --git a/api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go b/api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go deleted file mode 100644 index 43f00cca..00000000 --- a/api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go +++ /dev/null @@ -1,180 +0,0 @@ -package quotation - -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/quotation/quotation_app.go b/api/payments/quotation/internal/service/quotation/quotation_app.go index d2e8d08c..9320f00b 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_app.go +++ b/api/payments/quotation/internal/service/quotation/quotation_app.go @@ -1,35 +1,36 @@ package quotation import ( + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quotation_service_v2" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/mlogger" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "google.golang.org/grpc" ) -// QuotationService exposes only quotation RPCs as a standalone gRPC service. +// QuotationService exposes quotation-v2 RPCs as a standalone gRPC service. type QuotationService struct { - core *Service - quote *quotationService + core *Service + v2 *quotation_service_v2.QuotationServiceV2 } // 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), + core: core, + v2: newQuotationServiceV2(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 { + if s == nil || s.v2 == nil { return nil } return router.Register(func(reg grpc.ServiceRegistrar) { - quotationv1.RegisterQuotationServiceServer(reg, s.quote) + quotationv2.RegisterQuotationServiceServer(reg, s.v2) }) } diff --git a/api/payments/quotation/internal/service/quotation/quotation_app_test.go b/api/payments/quotation/internal/service/quotation/quotation_app_test.go new file mode 100644 index 00000000..b08f69cb --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_app_test.go @@ -0,0 +1,56 @@ +package quotation + +import ( + "context" + "net" + "testing" + + "github.com/tech/sendico/pkg/api/routers" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.uber.org/zap" + "google.golang.org/grpc" +) + +func TestQuotationService_RegisterV2Only(t *testing.T) { + svc := NewQuotationService(zap.NewNop(), nil) + router := newGRPCCaptureRouter() + + if err := svc.Register(router); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + services := router.server.GetServiceInfo() + if _, ok := services[quotationv2.QuotationService_ServiceDesc.ServiceName]; !ok { + t.Fatalf("expected %q service to be registered", quotationv2.QuotationService_ServiceDesc.ServiceName) + } + if len(services) != 1 { + t.Fatalf("expected exactly one registered service, got %d", len(services)) + } +} + +type grpcCaptureRouter struct { + server *grpc.Server + done chan error +} + +func newGRPCCaptureRouter() *grpcCaptureRouter { + return &grpcCaptureRouter{ + server: grpc.NewServer(), + done: make(chan error), + } +} + +func (r *grpcCaptureRouter) Register(registration routers.GRPCServiceRegistration) error { + registration(r.server) + return nil +} + +func (r *grpcCaptureRouter) Start(context.Context) error { return nil } + +func (r *grpcCaptureRouter) Finish(context.Context) error { return nil } + +func (r *grpcCaptureRouter) Addr() net.Addr { return nil } + +func (r *grpcCaptureRouter) Done() <-chan error { return r.done } + +var _ routers.GRPC = (*grpcCaptureRouter)(nil) diff --git a/api/payments/quotation/internal/service/quotation/quotation_service.go b/api/payments/quotation/internal/service/quotation/quotation_service.go deleted file mode 100644 index 95f9b55f..00000000 --- a/api/payments/quotation/internal/service/quotation/quotation_service.go +++ /dev/null @@ -1,24 +0,0 @@ -package quotation - -import ( - "context" - - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" -) - -type quotationService struct { - svc *Service - quotationv1.UnimplementedQuotationServiceServer -} - -func newQuotationService(svc *Service) *quotationService { - return "ationService{svc: svc} -} - -func (s *quotationService) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) { - return s.svc.QuotePayment(ctx, req) -} - -func (s *quotationService) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) { - return s.svc.QuotePayments(ctx, req) -} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/logging.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/logging.go new file mode 100644 index 00000000..df90ceda --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/logging.go @@ -0,0 +1,143 @@ +package quotation_service_v2 + +import ( + "fmt" + "strings" + + "github.com/tech/sendico/pkg/mlogger" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +type endpointLogSummary struct { + ResolutionType string `json:"resolutionType"` + MethodRef string `json:"methodRef,omitempty"` + MethodType string `json:"methodType,omitempty"` + RecipientRef string `json:"recipientRef,omitempty"` + PayeeRef string `json:"payeeRef,omitempty"` + DataBytes int `json:"dataBytes,omitempty"` +} + +type quoteIntentLogSummary struct { + Source endpointLogSummary `json:"source"` + Destination endpointLogSummary `json:"destination"` + Amount string `json:"amount,omitempty"` + SettlementMode string `json:"settlementMode,omitempty"` + FeeTreatment string `json:"feeTreatment,omitempty"` + SettlementCurrency string `json:"settlementCurrency,omitempty"` + HasComment bool `json:"hasComment"` +} + +func (s *QuotationServiceV2) quotePaymentLogger(req *quotationv2.QuotePaymentRequest) mlogger.Logger { + return s.logger.With( + zap.String("flow_ref", bson.NewObjectID().Hex()), + zap.String("rpc_method", "QuotePayment"), + zap.String("organization_ref", strings.TrimSpace(req.GetMeta().GetOrganizationRef())), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + zap.Bool("preview_only", req.GetPreviewOnly()), + zap.String("initiator_ref", strings.TrimSpace(req.GetInitiatorRef())), + ) +} + +func (s *QuotationServiceV2) quotePaymentsLogger(req *quotationv2.QuotePaymentsRequest) mlogger.Logger { + return s.logger.With( + zap.String("flow_ref", bson.NewObjectID().Hex()), + zap.String("rpc_method", "QuotePayments"), + zap.String("organization_ref", strings.TrimSpace(req.GetMeta().GetOrganizationRef())), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + zap.Bool("preview_only", req.GetPreviewOnly()), + zap.String("initiator_ref", strings.TrimSpace(req.GetInitiatorRef())), + zap.Int("intent_count", len(req.GetIntents())), + ) +} + +func summarizeQuoteIntent(intent *quotationv2.QuoteIntent) *quoteIntentLogSummary { + if intent == nil { + return nil + } + return "eIntentLogSummary{ + Source: summarizeEndpoint(intent.GetSource()), + Destination: summarizeEndpoint(intent.GetDestination()), + Amount: moneyLogValue(intent.GetAmount()), + SettlementMode: enumLogValue(intent.GetSettlementMode().String()), + FeeTreatment: enumLogValue(intent.GetFeeTreatment().String()), + SettlementCurrency: strings.ToUpper(strings.TrimSpace(intent.GetSettlementCurrency())), + HasComment: strings.TrimSpace(intent.GetComment()) != "", + } +} + +func summarizeQuoteIntentList(intents []*quotationv2.QuoteIntent, limit int) ([]quoteIntentLogSummary, int, int) { + total := len(intents) + if total == 0 || limit <= 0 { + return nil, total, 0 + } + if limit > total { + limit = total + } + + items := make([]quoteIntentLogSummary, 0, limit) + for i := 0; i < limit; i++ { + if summary := summarizeQuoteIntent(intents[i]); summary != nil { + items = append(items, *summary) + continue + } + items = append(items, quoteIntentLogSummary{}) + } + return items, total, total - limit +} + +func summarizeEndpoint(endpoint *endpointv1.PaymentEndpoint) endpointLogSummary { + if endpoint == nil { + return endpointLogSummary{ResolutionType: "unspecified"} + } + switch source := endpoint.GetSource().(type) { + case *endpointv1.PaymentEndpoint_PaymentMethodRef: + return endpointLogSummary{ + ResolutionType: "payment_method_ref", + MethodRef: strings.TrimSpace(source.PaymentMethodRef), + } + case *endpointv1.PaymentEndpoint_PaymentMethod: + method := source.PaymentMethod + if method == nil { + return endpointLogSummary{ResolutionType: "payment_method"} + } + return endpointLogSummary{ + ResolutionType: "payment_method", + MethodType: enumLogValue(method.GetType().String()), + RecipientRef: strings.TrimSpace(method.GetRecipientRef()), + DataBytes: len(method.GetData()), + } + case *endpointv1.PaymentEndpoint_PayeeRef: + return endpointLogSummary{ + ResolutionType: "payee_ref", + PayeeRef: strings.TrimSpace(source.PayeeRef), + } + default: + return endpointLogSummary{ResolutionType: "unspecified"} + } +} + +func moneyLogValue(m *moneyv1.Money) string { + if m == nil { + return "" + } + amount := strings.TrimSpace(m.GetAmount()) + currency := strings.ToUpper(strings.TrimSpace(m.GetCurrency())) + switch { + case amount == "" && currency == "": + return "" + case amount == "": + return currency + case currency == "": + return amount + default: + return fmt.Sprintf("%s %s", amount, currency) + } +} + +func enumLogValue(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go index 24e4d80f..7663a37a 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go @@ -13,24 +13,45 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.uber.org/zap" ) func (s *QuotationServiceV2) ProcessQuotePayments( ctx context.Context, req *quotationv2.QuotePaymentsRequest, ) (*QuotePaymentsResult, error) { + logger := s.quotePaymentsLogger(req).Named("processor").With(zap.String("mode", "batch")) + startedAt := time.Now() + logger.Info("QuotePayments request received") + summaries, totalIntents, truncatedIntents := summarizeQuoteIntentList(req.GetIntents(), 10) + logger.Debug("QuotePayments request payload", + zap.Int("intent_count", totalIntents), + zap.Int("intent_count_truncated", truncatedIntents), + zap.Any("intents", summaries), + ) + logger.Debug("ProcessQuotePayments started") + if err := s.validateDependencies(); err != nil { + logger.Warn("ProcessQuotePayments failed on dependency validation", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayments dependencies validated") requestCtx, err := s.deps.Validator.ValidateQuotePayments(req) if err != nil { + logger.Debug("ProcessQuotePayments failed on request validation", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayments request validated", + zap.String("organization_ref", requestCtx.OrganizationRef), + zap.Bool("preview_only", requestCtx.PreviewOnly), + zap.Int("intent_count", requestCtx.IntentCount), + ) fingerprint := "" if !requestCtx.PreviewOnly { fingerprint = s.deps.Idempotency.FingerprintQuotePayments(req) + logger.Debug("ProcessQuotePayments checking idempotency reuse") reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{ OrganizationID: requestCtx.OrganizationID, IdempotencyKey: requestCtx.IdempotencyKey, @@ -38,11 +59,29 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Shape: quote_idempotency_service.QuoteShapeBatch, }) if reuseErr != nil { + logger.Warn("ProcessQuotePayments idempotency reuse check failed", zap.Error(reuseErr)) return nil, reuseErr } if reused { - return s.batchResultFromRecord(reusedRecord) + logger.Info("ProcessQuotePayments served from idempotency reuse", + zap.String("quote_ref", strings.TrimSpace(reusedRecord.QuoteRef)), + zap.Duration("elapsed", time.Since(startedAt)), + ) + reusedResult, mapErr := s.batchResultFromRecord(reusedRecord) + if mapErr != nil { + logger.Warn("ProcessQuotePayments failed to map reused record", zap.Error(mapErr)) + return nil, mapErr + } + reusedResponse := reusedResult.Response + logger.Info("QuotePayments response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(reusedResponse.GetQuoteRef())), + zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())), + zap.Int("quotes_count", len(reusedResponse.GetQuotes())), + ) + return reusedResult, nil } + logger.Debug("ProcessQuotePayments idempotency reuse miss") } hydrated, err := s.deps.Hydrator.HydrateMany(ctx, transfer_intent_hydrator.HydrateManyInput{ @@ -51,13 +90,16 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Intents: req.GetIntents(), }) if err != nil { + logger.Debug("ProcessQuotePayments failed during intent hydration", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayments intents hydrated", zap.Int("intent_count", len(hydrated))) quoteRef := "" if !requestCtx.PreviewOnly { quoteRef = normalizeQuoteRef(s.deps.NewRef()) if quoteRef == "" { + logger.Warn("ProcessQuotePayments generated empty quote_ref") return nil, merrors.InvalidArgument("quote_ref is required") } } @@ -70,6 +112,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( quoteRef, s.deps.Now().UTC(), collector, + logger, ) batch := batch_quote_processor_v2.New(single) @@ -78,22 +121,27 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Intents: hydrated, }) if err != nil { + logger.Warn("ProcessQuotePayments failed during computation pipeline", zap.Error(err)) return nil, err } if batchOut == nil || len(batchOut.Items) != len(hydrated) { + logger.Warn("ProcessQuotePayments returned invalid batch output") return nil, merrors.InvalidArgument("batch quote output is invalid") } quotes := make([]*quotationv2.PaymentQuote, 0, len(batchOut.Items)) for _, item := range batchOut.Items { if item == nil || item.Quote == nil { + logger.Warn("ProcessQuotePayments contains empty quote item") return nil, merrors.InvalidArgument("batch item quote is required") } quotes = append(quotes, item.Quote) } + logger.Debug("ProcessQuotePayments computation completed", zap.Int("quotes_count", len(quotes))) details := collector.Ordered(len(batchOut.Items)) if len(details) != len(batchOut.Items) { + logger.Warn("ProcessQuotePayments missing item details", zap.Int("details_count", len(details)), zap.Int("items_count", len(batchOut.Items))) return nil, merrors.InvalidArgument("batch processing details are incomplete") } @@ -106,6 +154,17 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Response: response, } if requestCtx.PreviewOnly { + logger.Info("ProcessQuotePayments preview completed", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())), + zap.Int("quotes_count", len(response.GetQuotes())), + ) + logger.Info("QuotePayments response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())), + zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())), + zap.Int("quotes_count", len(response.GetQuotes())), + ) return result, nil } @@ -115,6 +174,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( statuses := make([]*quote_persistence_service.StatusInput, 0, len(details)) for _, detail := range details { if detail == nil || detail.Intent.Amount == nil || detail.Quote == nil { + logger.Warn("ProcessQuotePayments contains incomplete detail") return nil, merrors.InvalidArgument("batch processing detail is incomplete") } expires = append(expires, detail.ExpiresAt) @@ -125,6 +185,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( expiresAt, ok := minExpiry(expires) if !ok { + logger.Warn("ProcessQuotePayments produced empty expires_at") return nil, merrors.InvalidArgument("expires_at is required") } @@ -139,6 +200,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Statuses: statuses, }) if err != nil { + logger.Warn("ProcessQuotePayments failed to build persistence record", zap.Error(err)) return nil, err } @@ -152,6 +214,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( }, }) if err != nil { + logger.Warn("ProcessQuotePayments failed while storing idempotent record", zap.Error(err)) if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) || errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) { return nil, merrors.InvalidArgument(err.Error()) @@ -159,9 +222,37 @@ func (s *QuotationServiceV2) ProcessQuotePayments( return nil, err } if reused { - return s.batchResultFromRecord(stored) + logger.Info("ProcessQuotePayments reused concurrent idempotent record", + zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), + zap.Duration("elapsed", time.Since(startedAt)), + ) + reusedResult, mapErr := s.batchResultFromRecord(stored) + if mapErr != nil { + logger.Warn("ProcessQuotePayments failed to map reused concurrent record", zap.Error(mapErr)) + return nil, mapErr + } + reusedResponse := reusedResult.Response + logger.Info("QuotePayments response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(reusedResponse.GetQuoteRef())), + zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())), + zap.Int("quotes_count", len(reusedResponse.GetQuotes())), + ) + return reusedResult, nil } result.Record = stored + logger.Info("ProcessQuotePayments persisted quote batch", + zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), + zap.Int("quotes_count", len(stored.Quotes)), + zap.Time("expires_at", stored.ExpiresAt), + zap.Duration("elapsed", time.Since(startedAt)), + ) + logger.Info("QuotePayments response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())), + zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())), + zap.Int("quotes_count", len(response.GetQuotes())), + ) return result, nil } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go index a3a41c12..29fa44d0 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "time" "github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2" "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service" @@ -12,24 +13,40 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.uber.org/zap" ) func (s *QuotationServiceV2) ProcessQuotePayment( ctx context.Context, req *quotationv2.QuotePaymentRequest, ) (*QuotePaymentResult, error) { + logger := s.quotePaymentLogger(req).Named("processor").With(zap.String("mode", "single")) + startedAt := time.Now() + logger.Info("QuotePayment request received") + logger.Debug("QuotePayment request payload", zap.Any("intent", summarizeQuoteIntent(req.GetIntent()))) + logger.Debug("ProcessQuotePayment started") + if err := s.validateDependencies(); err != nil { + logger.Warn("ProcessQuotePayment failed on dependency validation", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayment dependencies validated") requestCtx, err := s.deps.Validator.ValidateQuotePayment(req) if err != nil { + logger.Debug("ProcessQuotePayment failed on request validation", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayment request validated", + zap.String("organization_ref", requestCtx.OrganizationRef), + zap.Bool("preview_only", requestCtx.PreviewOnly), + zap.Int("intent_count", requestCtx.IntentCount), + ) fingerprint := "" if !requestCtx.PreviewOnly { fingerprint = s.deps.Idempotency.FingerprintQuotePayment(req) + logger.Debug("ProcessQuotePayment checking idempotency reuse") reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{ OrganizationID: requestCtx.OrganizationID, IdempotencyKey: requestCtx.IdempotencyKey, @@ -37,11 +54,32 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Shape: quote_idempotency_service.QuoteShapeSingle, }) if reuseErr != nil { + logger.Warn("ProcessQuotePayment idempotency reuse check failed", zap.Error(reuseErr)) return nil, reuseErr } if reused { - return s.singleResultFromRecord(reusedRecord) + logger.Info("ProcessQuotePayment served from idempotency reuse", + zap.String("quote_ref", strings.TrimSpace(reusedRecord.QuoteRef)), + zap.Duration("elapsed", time.Since(startedAt)), + ) + reusedResult, mapErr := s.singleResultFromRecord(reusedRecord) + if mapErr != nil { + logger.Warn("ProcessQuotePayment failed to map reused record", zap.Error(mapErr)) + return nil, mapErr + } + reusedResponse := reusedResult.Response + reusedQuote := reusedResponse.GetQuote() + logger.Info("QuotePayment response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(reusedQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(reusedQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(reusedQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(reusedQuote.GetBlockReason().String())), + zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())), + ) + return reusedResult, nil } + logger.Debug("ProcessQuotePayment idempotency reuse miss") } hydrated, err := s.deps.Hydrator.HydrateOne(ctx, transfer_intent_hydrator.HydrateOneInput{ @@ -50,13 +88,19 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Intent: req.GetIntent(), }) if err != nil { + logger.Debug("ProcessQuotePayment failed during intent hydration", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayment intent hydrated", + zap.String("intent_ref", strings.TrimSpace(hydrated.Ref)), + zap.String("kind", strings.TrimSpace(string(hydrated.Kind))), + ) quoteRef := "" if !requestCtx.PreviewOnly { quoteRef = normalizeQuoteRef(s.deps.NewRef()) if quoteRef == "" { + logger.Warn("ProcessQuotePayment generated empty quote_ref") return nil, merrors.InvalidArgument("quote_ref is required") } } @@ -69,6 +113,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment( quoteRef, s.deps.Now().UTC(), collector, + logger, ) batch := batch_quote_processor_v2.New(single) @@ -77,11 +122,20 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Intents: []*transfer_intent_hydrator.QuoteIntent{hydrated}, }) if err != nil { + logger.Warn("ProcessQuotePayment failed during computation pipeline", zap.Error(err)) return nil, err } if batchOut == nil || len(batchOut.Items) != 1 || batchOut.Items[0] == nil || batchOut.Items[0].Quote == nil { + logger.Warn("ProcessQuotePayment returned invalid single output") return nil, merrors.InvalidArgument("single quote output is invalid") } + computedQuote := batchOut.Items[0].Quote + logger.Debug("ProcessQuotePayment computation completed", + zap.String("quote_ref", strings.TrimSpace(computedQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(computedQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(computedQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(computedQuote.GetBlockReason().String())), + ) response := "ationv2.QuotePaymentResponse{ Quote: batchOut.Items[0].Quote, @@ -97,11 +151,27 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Response: response, } if requestCtx.PreviewOnly { + previewQuote := response.GetQuote() + logger.Info("ProcessQuotePayment preview completed", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(previewQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(previewQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(previewQuote.GetState().String())), + ) + logger.Info("QuotePayment response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(previewQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(previewQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(previewQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(previewQuote.GetBlockReason().String())), + zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())), + ) return result, nil } expiresAt := detail.ExpiresAt if expiresAt.IsZero() { + logger.Warn("ProcessQuotePayment produced empty expires_at") return nil, merrors.InvalidArgument("expires_at is required") } @@ -116,6 +186,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Status: statusInputFromStatus(detail.Status), }) if err != nil { + logger.Warn("ProcessQuotePayment failed to build persistence record", zap.Error(err)) return nil, err } @@ -129,6 +200,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment( }, }) if err != nil { + logger.Warn("ProcessQuotePayment failed while storing idempotent record", zap.Error(err)) if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) || errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) { return nil, merrors.InvalidArgument(err.Error()) @@ -137,9 +209,41 @@ func (s *QuotationServiceV2) ProcessQuotePayment( } if reused { - return s.singleResultFromRecord(stored) + logger.Info("ProcessQuotePayment reused concurrent idempotent record", + zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), + zap.Duration("elapsed", time.Since(startedAt)), + ) + reusedResult, mapErr := s.singleResultFromRecord(stored) + if mapErr != nil { + logger.Warn("ProcessQuotePayment failed to map reused concurrent record", zap.Error(mapErr)) + return nil, mapErr + } + reusedResponse := reusedResult.Response + reusedQuote := reusedResponse.GetQuote() + logger.Info("QuotePayment response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(reusedQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(reusedQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(reusedQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(reusedQuote.GetBlockReason().String())), + zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())), + ) + return reusedResult, nil } result.Record = stored + logger.Info("ProcessQuotePayment persisted quote", + zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), + zap.Time("expires_at", stored.ExpiresAt), + zap.Duration("elapsed", time.Since(startedAt)), + ) + logger.Info("QuotePayment response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(response.GetQuote().GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(response.GetQuote().GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(response.GetQuote().GetState().String())), + zap.String("block_reason", strings.TrimSpace(response.GetQuote().GetBlockReason().String())), + zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())), + ) return result, nil } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go index ab3554ff..5c9fff8b 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go @@ -15,11 +15,13 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" ) type Dependencies struct { + Logger mlogger.Logger QuotesStore quotestorage.QuotesStore Validator *quote_request_validator_v2.QuoteRequestValidatorV2 Hydrator *transfer_intent_hydrator.TransferIntentHydrator @@ -33,7 +35,8 @@ type Dependencies struct { } type QuotationServiceV2 struct { - deps Dependencies + deps Dependencies + logger mlogger.Logger quotationv2.UnimplementedQuotationServiceServer } @@ -59,7 +62,13 @@ func New(deps Dependencies) *QuotationServiceV2 { if deps.NewRef == nil { deps.NewRef = func() string { return bson.NewObjectID().Hex() } } - return &QuotationServiceV2{deps: deps} + if deps.Logger == nil { + panic("quotation_service_v2: logger is required") + } + return &QuotationServiceV2{ + deps: deps, + logger: deps.Logger.Named("v2"), + } } func (s *QuotationServiceV2) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) { diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go index 238a789e..b8e6a494 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go @@ -24,6 +24,7 @@ import ( quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap/zaptest" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" @@ -36,6 +37,7 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { store := newInMemoryQuotesStore() core := &fakeQuoteCore{now: now} svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), QuotesStore: store, Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { return "q-intent-single" @@ -88,7 +90,7 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want { t.Fatalf("unexpected destination currency: got=%q want=%q", got, want) } - if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "101.8"; got != want { + if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "102.4"; got != want { t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want) } if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want { @@ -197,6 +199,7 @@ func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) { fxTTL: 5 * time.Minute, } svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), QuotesStore: store, Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { return "q-intent-single" @@ -254,6 +257,7 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) { store := newInMemoryQuotesStore() core := &fakeQuoteCore{now: now} svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), QuotesStore: store, Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { return fmt.Sprintf("q-intent-%d", time.Now().UnixNano()) @@ -380,6 +384,7 @@ func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T) store := newInMemoryQuotesStore() core := &fakeQuoteCore{now: now} svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), QuotesStore: store, Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { return "q-intent-topology" diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go index 80eea7b0..3e6cfa99 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go @@ -13,6 +13,8 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) type itemProcessDetail struct { @@ -70,6 +72,7 @@ type singleIntentProcessorV2 struct { quoteRef string pricedAt time.Time collector *itemCollector + logger mlogger.Logger } func newSingleIntentProcessorV2( @@ -79,6 +82,7 @@ func newSingleIntentProcessorV2( quoteRef string, pricedAt time.Time, collector *itemCollector, + logger mlogger.Logger, ) *singleIntentProcessorV2 { return &singleIntentProcessorV2{ computation: computation, @@ -87,6 +91,7 @@ func newSingleIntentProcessorV2( quoteRef: quoteRef, pricedAt: pricedAt, collector: collector, + logger: logger.Named("single"), } } @@ -94,17 +99,29 @@ func (p *singleIntentProcessorV2) Process( ctx context.Context, in batch_quote_processor_v2.SingleProcessInput, ) (*batch_quote_processor_v2.SingleProcessOutput, error) { + if p == nil { + return nil, merrors.InvalidArgument("single processor is required") + } + logger := p.logger.With( + zap.Int("intent_index", in.Item.Index), + zap.Int("intent_count", in.Item.Count), + ) + logger.Debug("Single intent processing started") - if p == nil || p.computation == nil { + if p.computation == nil { + logger.Warn("Single intent processing failed: missing computation service") return nil, merrors.InvalidArgument("quote computation service is required") } if p.classifier == nil { + logger.Warn("Single intent processing failed: missing classifier") return nil, merrors.InvalidArgument("quote executability classifier is required") } if p.mapper == nil { + logger.Warn("Single intent processing failed: missing mapper") return nil, merrors.InvalidArgument("quote response mapper is required") } if in.Item.Intent == nil { + logger.Debug("Single intent processing failed: empty intent") return nil, merrors.InvalidArgument("intent is required") } @@ -116,17 +133,24 @@ func (p *singleIntentProcessorV2) Process( Intents: []*transfer_intent_hydrator.QuoteIntent{in.Item.Intent}, }) if err != nil { + logger.Warn("Single intent computation failed", zap.Error(err)) return nil, err } if computed == nil || computed.Plan == nil || len(computed.Results) != 1 || len(computed.Plan.Items) != 1 { + logger.Warn("Single intent computation returned invalid shape") return nil, merrors.InvalidArgument("invalid computation output for single item") } result := computed.Results[0] planItem := computed.Plan.Items[0] if result == nil || planItem == nil || result.Quote == nil || planItem.Intent.Amount == nil { + logger.Warn("Single intent computation returned incomplete payload") return nil, merrors.InvalidArgument("incomplete computation output") } + logger.Debug("Single intent computation completed", + zap.Int("steps_count", len(planItem.Steps)), + zap.String("intent_ref", strings.TrimSpace(planItem.Intent.Ref)), + ) state := p.classifier.BuildState(in.Context.PreviewOnly, result.BlockReason) status := quote_response_mapper_v2.QuoteStatus{ @@ -161,9 +185,11 @@ func (p *singleIntentProcessorV2) Process( Status: status, }) if mapErr != nil { + logger.Warn("Single intent response mapping failed", zap.Error(mapErr)) return nil, mapErr } if mapped == nil || mapped.Quote == nil { + logger.Warn("Single intent response mapping returned empty quote") return nil, merrors.InvalidArgument("mapped quote is required") } mapped.Quote.IntentRef = firstNonEmpty( @@ -182,6 +208,13 @@ func (p *singleIntentProcessorV2) Process( ExpiresAt: expiresAt, Status: status, }) + mappedQuote := mapped.Quote + logger.Debug("Single intent processing completed", + zap.String("quote_ref", strings.TrimSpace(mappedQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(mappedQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(mappedQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(mappedQuote.GetBlockReason().String())), + ) return &batch_quote_processor_v2.SingleProcessOutput{ Quote: mapped.Quote, diff --git a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go new file mode 100644 index 00000000..1eb8981b --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go @@ -0,0 +1,186 @@ +package quotation + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quotation_service_v2" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "google.golang.org/protobuf/proto" +) + +func newQuotationServiceV2(core *Service) *quotation_service_v2.QuotationServiceV2 { + if core == nil { + return nil + } + + return quotation_service_v2.New(quotation_service_v2.Dependencies{ + Logger: core.logger, + QuotesStore: quoteStore(core), + Hydrator: transfer_intent_hydrator.New(nil), + Computation: newQuoteComputationService(core), + }) +} + +func quoteStore(core *Service) quotestorage.QuotesStore { + if core == nil || core.storage == nil { + return nil + } + return core.storage.Quotes() +} + +func newQuoteComputationService(core *Service) *quote_computation_service.QuoteComputationService { + opts := make([]quote_computation_service.Option, 0, 3) + if core != nil && core.storage != nil { + if routes := core.storage.Routes(); routes != nil { + opts = append( + opts, + quote_computation_service.WithRouteStore(routes), + quote_computation_service.WithLogger(core.logger), + ) + } + } + if core != nil && core.deps.gatewayRegistry != nil { + opts = append(opts, quote_computation_service.WithGatewayRegistry(core.deps.gatewayRegistry)) + } + if resolver := fundingProfileResolver(core); resolver != nil { + opts = append(opts, quote_computation_service.WithFundingProfileResolver(resolver)) + } + return quote_computation_service.New(legacyQuoteComputationCore{core: core}, opts...) +} + +func fundingProfileResolver(core *Service) gateway_funding_profile.FundingProfileResolver { + if core == nil { + return nil + } + + cardRoutes := make(map[string]gateway_funding_profile.CardGatewayFundingRoute, len(core.deps.cardRoutes)) + for key, route := range core.deps.cardRoutes { + routeKey := strings.TrimSpace(key) + if routeKey == "" { + continue + } + cardRoutes[routeKey] = gateway_funding_profile.CardGatewayFundingRoute{ + FundingAddress: strings.TrimSpace(route.FundingAddress), + FeeAddress: strings.TrimSpace(route.FeeAddress), + FeeWalletRef: strings.TrimSpace(route.FeeWalletRef), + } + } + + feeLedgerAccounts := make(map[string]string, len(core.deps.feeLedgerAccounts)) + for key, accountRef := range core.deps.feeLedgerAccounts { + accountKey := strings.TrimSpace(key) + account := strings.TrimSpace(accountRef) + if accountKey == "" || account == "" { + continue + } + feeLedgerAccounts[accountKey] = account + } + + if len(cardRoutes) == 0 && len(feeLedgerAccounts) == 0 { + return nil + } + return gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{ + DefaultCardGateway: defaultCardGateway, + DefaultMode: model.FundingModeNone, + CardRoutes: cardRoutes, + FeeLedgerAccounts: feeLedgerAccounts, + }) +} + +type legacyQuoteComputationCore struct { + core *Service +} + +func (c legacyQuoteComputationCore) BuildQuote(ctx context.Context, in quote_computation_service.BuildQuoteInput) (*quote_computation_service.ComputedQuote, time.Time, error) { + if c.core == nil { + return nil, time.Time{}, errStorageUnavailable + } + + request := "eRequest{ + Meta: &sharedv1.RequestMeta{ + OrganizationRef: strings.TrimSpace(in.OrganizationRef), + }, + IdempotencyKey: strings.TrimSpace(in.IdempotencyKey), + Intent: protoIntentFromModel(in.Intent), + } + + legacyQuote, expiresAt, err := c.core.buildPaymentQuote(ctx, strings.TrimSpace(in.OrganizationRef), request) + if err != nil { + return nil, time.Time{}, err + } + return mapLegacyQuote(in, legacyQuote), expiresAt, nil +} + +func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1.PaymentQuote) *quote_computation_service.ComputedQuote { + if src == nil { + return "e_computation_service.ComputedQuote{} + } + resolvedSettlementMode := settlementModeToProto(in.Intent.SettlementMode) + if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { + resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } + return "e_computation_service.ComputedQuote{ + DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()), + CreditAmount: cloneProtoMoney(src.GetExpectedSettlementAmount()), + TotalCost: cloneProtoMoney(src.GetDebitAmount()), + FeeLines: cloneFeeLines(src.GetFeeLines()), + FeeRules: cloneFeeRules(src.GetFeeRules()), + FXQuote: cloneFXQuote(src.GetFxQuote()), + Route: cloneRouteSpecification(in.Route), + ExecutionConditions: cloneExecutionConditions(in.ExecutionConditions), + ResolvedSettlementMode: resolvedSettlementMode, + ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), + } +} + +func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + } +} + +func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification { + if src == nil { + return nil + } + cloned, ok := proto.Clone(src).(*quotationv2.RouteSpecification) + if !ok { + return nil + } + return cloned +} + +func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions { + if src == nil { + return nil + } + cloned, ok := proto.Clone(src).(*quotationv2.ExecutionConditions) + if !ok { + return nil + } + return cloned +} + +func cloneFXQuote(src *oraclev1.Quote) *oraclev1.Quote { + if src == nil { + return nil + } + cloned, ok := proto.Clone(src).(*oraclev1.Quote) + if !ok { + return nil + } + return cloned +} diff --git a/api/payments/quotation/internal/service/quotation/quote_batch.go b/api/payments/quotation/internal/service/quotation/quote_batch.go deleted file mode 100644 index e7b2b546..00000000 --- a/api/payments/quotation/internal/service/quotation/quote_batch.go +++ /dev/null @@ -1,145 +0,0 @@ -package quotation - -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" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 []*sharedv1.PaymentQuote) (*sharedv1.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 &sharedv1.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 []*sharedv1.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 []*sharedv1.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/quotation/quote_computation_service/compute.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go index 92edafd8..50a9b012 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go @@ -2,9 +2,9 @@ package quote_computation_service import ( "context" - "fmt" "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" ) func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) (*ComputeOutput, error) { @@ -12,8 +12,19 @@ func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) return nil, merrors.InvalidArgument("quote computation core is required") } + s.logger.Debug("Computing quotes", + zap.String("org_ref", in.OrganizationRef), + zap.Int("intent_count", len(in.Intents)), + zap.Bool("preview_only", in.PreviewOnly), + ) + planModel, err := s.BuildPlan(ctx, in) if err != nil { + s.logger.Warn("Quote plan build failed", + zap.String("org_ref", in.OrganizationRef), + zap.Error(err), + ) + return nil, err } @@ -24,11 +35,26 @@ func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) if item == nil { return nil, computeErr } - return nil, fmt.Errorf("Item %d: %w", item.Index, computeErr) + + s.logger.Warn("Quote item computation failed", + zap.String("org_ref", in.OrganizationRef), + zap.Int("item_index", item.Index), + zap.String("intent_ref", item.IntentRef), + zap.Error(computeErr), + ) + + return nil, wrapIndexedError(computeErr, "Item %d", item.Index) } + results = append(results, computed) } + s.logger.Debug("Quote computation completed", + zap.String("org_ref", in.OrganizationRef), + zap.String("plan_mode", string(planModel.Mode)), + zap.Int("item_count", len(results)), + ) + return &ComputeOutput{ Plan: planModel, Results: results, @@ -45,18 +71,38 @@ func (s *QuoteComputationService) computePlanItem( quote, expiresAt, err := s.core.BuildQuote(ctx, item.QuoteInput) if err != nil { + s.logger.Warn("Quote build failed", + zap.Int("item_index", item.Index), + zap.String("intent_ref", item.IntentRef), + zap.Error(err), + ) + return nil, err } + enrichedQuote := ensureComputedQuote(quote, item) if bindErr := validateQuoteRouteBinding(enrichedQuote, item.QuoteInput); bindErr != nil { + s.logger.Warn("Quote route binding validation failed", + zap.Int("item_index", item.Index), + zap.String("intent_ref", item.IntentRef), + zap.Error(bindErr), + ) + return nil, bindErr } - result := &QuoteComputationResult{ + s.logger.Debug("Quote item computed", + zap.Int("item_index", item.Index), + zap.String("intent_ref", item.IntentRef), + zap.String("quote_ref", enrichedQuote.QuoteRef), + zap.Time("expires_at", expiresAt), + zap.String("block_reason", item.BlockReason.String()), + ) + + return &QuoteComputationResult{ ItemIndex: item.Index, Quote: enrichedQuote, ExpiresAt: expiresAt, BlockReason: item.BlockReason, - } - return result, nil + }, nil } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go index c5a1ac9d..b7dc37c7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go @@ -1,8 +1,6 @@ package quote_computation_service import ( - "strings" - "github.com/tech/sendico/payments/storage/model" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -19,17 +17,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle } } -func resolvedSettlementModeFromRouteModelValue(value string) paymentv1.SettlementMode { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED": - return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED - case "FIX_SOURCE", "SETTLEMENT_FIX_SOURCE", "SETTLEMENT_MODE_FIX_SOURCE": - return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE - default: - return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE - } -} - func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { switch mode { case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/error_wrap.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/error_wrap.go new file mode 100644 index 00000000..7dc39dc7 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/error_wrap.go @@ -0,0 +1,16 @@ +package quote_computation_service + +import ( + "errors" + "fmt" + + "github.com/tech/sendico/pkg/merrors" +) + +func wrapIndexedError(err error, format string, args ...any) error { + msg := fmt.Sprintf(format, args...) + if errors.Is(err, merrors.ErrInvalidArg) { + return merrors.InvalidArgumentWrap(err, msg) + } + return merrors.InternalWrap(err, msg) +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go index 0f1ee9d7..a36c505e 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go @@ -2,7 +2,6 @@ package quote_computation_service import ( "context" - "fmt" "sort" "strings" @@ -11,6 +10,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + "go.uber.org/zap" ) func (s *QuoteComputationService) resolveStepGateways( @@ -19,14 +19,28 @@ func (s *QuoteComputationService) resolveStepGateways( routeNetwork string, ) error { if s == nil || s.gatewayRegistry == nil { + s.logger.Debug("Step gateway resolution skipped: no gateway registry configured") + return nil } + s.logger.Debug("Loading gateway registry", + zap.Int("step_count", len(steps)), + zap.String("route_network", routeNetwork), + ) + gateways, err := s.gatewayRegistry.List(ctx) if err != nil { + s.logger.Warn("Step gateway resolution failed: gateway registry list error", + zap.Error(err), + ) + return err } + if len(gateways) == 0 { + s.logger.Warn("Step gateway resolution failed: gateway registry has no entries") + return merrors.InvalidArgument("gateway registry has no entries") } @@ -40,29 +54,54 @@ func (s *QuoteComputationService) resolveStepGateways( return model.LessGatewayDescriptor(sorted[i], sorted[j]) }) + s.logger.Debug("Gateway registry loaded", zap.Int("gateway_count", len(sorted))) + for idx, step := range steps { if step == nil { continue } + if step.Rail == model.RailLedger { step.GatewayID = "internal" step.GatewayInvokeURI = "" + + s.logger.Debug("Step gateway assigned: ledger rail uses internal gateway", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.Int("step_index", idx), + ) + continue } - selected, selectErr := selectGatewayForStep(sorted, step, routeNetwork) + selected, selectErr := s.selectGatewayForStep(sorted, step, routeNetwork) if selectErr != nil { - return fmt.Errorf("Step[%d] %s: %w", idx, strings.TrimSpace(step.StepID), selectErr) + s.logger.Warn("Step gateway resolution failed: no eligible gateway for step", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.Int("step_index", idx), + zap.String("rail", string(step.Rail)), + zap.String("route_network", routeNetwork), + zap.Error(selectErr), + ) + + return selectErr } + step.GatewayID = strings.TrimSpace(selected.ID) step.InstanceID = strings.TrimSpace(selected.InstanceID) step.GatewayInvokeURI = strings.TrimSpace(selected.InvokeURI) + + s.logger.Debug("Gateway selected for step", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("rail", string(step.Rail)), + zap.String("gateway_id", step.GatewayID), + zap.String("instance_id", step.InstanceID), + ) } return nil } -func selectGatewayForStep( +func (s *QuoteComputationService) selectGatewayForStep( gateways []*model.GatewayInstanceDescriptor, step *QuoteComputationStep, routeNetwork string, @@ -82,39 +121,67 @@ func selectGatewayForStep( amount = parsed } } - action := gatewayEligibilityOperation(step.Operation) + action := step.Operation direction := plan.SendDirectionForRail(step.Rail) network := networkForGatewaySelection(step.Rail, routeNetwork) + s.logger.Debug("Selecting gateway for step", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("rail", string(step.Rail)), + zap.String("network", network), + zap.String("currency", currency), + zap.String("action", string(action)), + zap.String("preferred_gateway", step.GatewayID), + ) + eligible := make([]*model.GatewayInstanceDescriptor, 0, len(gateways)) - var lastErr error - for _, gw := range gateways { + for i, gw := range gateways { if gw == nil { + s.logger.Warn("Nil gateway found", zap.Int("gateway_index", i)) continue } - if err := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount); err != nil { - lastErr = err + eligErr := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount) + s.logger.Debug("Gateway eligibility check", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("gateway_id", strings.TrimSpace(gw.ID)), + zap.String("instance_id", strings.TrimSpace(gw.InstanceID)), + zap.Bool("eligible", eligErr == nil), + zap.Error(eligErr), + ) + if eligErr != nil { continue } eligible = append(eligible, gw) } - if selected, _ := model.SelectGatewayByPreference( - eligible, - step.GatewayID, - step.InstanceID, - step.GatewayInvokeURI, - ); selected != nil { + s.logger.Debug("Gateway eligibility evaluated", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.Int("eligible_count", len(eligible)), + zap.Int("total_count", len(gateways)), + ) + + selected, _ := model.SelectGatewayByPreference(eligible, step.GatewayID, step.InstanceID, step.GatewayInvokeURI) + if selected == nil && len(eligible) > 0 { + selected = eligible[0] + } + if selected != nil { + s.logger.Debug("Gateway selected", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("gateway_id", strings.TrimSpace(selected.ID)), + zap.String("instance_id", strings.TrimSpace(selected.InstanceID)), + ) + return selected, nil } - if len(eligible) > 0 { - return eligible[0], nil - } - if lastErr != nil { - return nil, merrors.InvalidArgument("no eligible gateway: " + lastErr.Error()) - } - return nil, merrors.InvalidArgument("no eligible gateway") + s.logger.Warn("No eligible gateway found for step", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("rail", string(step.Rail)), + zap.String("network", network), + zap.String("currency", currency), + ) + + return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(direction))) } func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) { @@ -132,15 +199,6 @@ func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) { return parsed, nil } -func gatewayEligibilityOperation(op model.RailOperation) model.RailOperation { - switch op { - case model.RailOperationExternalDebit, model.RailOperationExternalCredit: - return model.RailOperationSend - default: - return op - } -} - func networkForGatewaySelection(rail model.Rail, routeNetwork string) string { switch rail { case model.RailCrypto, model.RailProviderSettlement, model.RailFiatOnRamp: @@ -150,23 +208,15 @@ func networkForGatewaySelection(rail model.Rail, routeNetwork string) string { } } -func hasExplicitDestinationGateway(attrs map[string]string) bool { - return strings.TrimSpace(firstNonEmpty( - lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"), - lookupAttr(attrs, "destination_gateway", "destinationGateway"), - )) != "" -} - -func clearImplicitDestinationGateway(steps []*QuoteComputationStep) { - if len(steps) == 0 { - return +func toGatewayDirection(dir plan.SendDirection) model.GatewayDirection { + switch dir { + case plan.SendDirectionOut: + return model.GatewayDirectionOut + case plan.SendDirectionIn: + return model.GatewayDirectionIn + default: + return model.GatewayDirectionAny } - last := steps[len(steps)-1] - if last == nil { - return - } - last.GatewayID = "" - last.GatewayInvokeURI = "" } func destinationGatewayFromSteps(steps []*QuoteComputationStep) string { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go index db3698b3..cd3293f6 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go @@ -8,8 +8,6 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) -const defaultCardGateway = "monetix" - func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money { if src == nil { return nil diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index b5ff27d7..b49c45f4 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -13,6 +13,7 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput) (*QuoteComputationPlan, error) { @@ -33,6 +34,14 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput if len(in.Intents) > 1 { mode = PlanModeBatch } + + s.logger.Debug("Building computation plan", + zap.String("org_ref", in.OrganizationRef), + zap.String("plan_mode", string(mode)), + zap.Int("intent_count", len(in.Intents)), + zap.Bool("preview_only", in.PreviewOnly), + ) + planModel := &QuoteComputationPlan{ Mode: mode, OrganizationRef: strings.TrimSpace(in.OrganizationRef), @@ -45,11 +54,24 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput for i, intent := range in.Intents { item, err := s.buildPlanItem(ctx, in, i, intent) if err != nil { - return nil, fmt.Errorf("intents[%d]: %w", i, err) + s.logger.Warn("Computation plan item build failed", + zap.String("org_ref", in.OrganizationRef), + zap.Int("intent_index", i), + zap.Error(err), + ) + + return nil, wrapIndexedError(err, "intents[%d]", i) } + planModel.Items = append(planModel.Items, item) } + s.logger.Debug("Computation plan built", + zap.String("org_ref", in.OrganizationRef), + zap.String("plan_mode", string(planModel.Mode)), + zap.Int("item_count", len(planModel.Items)), + ) + return planModel, nil } @@ -60,56 +82,117 @@ func (s *QuoteComputationService) buildPlanItem( intent *transfer_intent_hydrator.QuoteIntent, ) (*QuoteComputationPlanItem, error) { if intent == nil { + s.logger.Warn("Plan item build failed: intent is nil", zap.Int("index", index)) + return nil, merrors.InvalidArgument("intent is required") } modelIntent := modelIntentFromQuoteIntent(intent) if modelIntent.Amount == nil { + s.logger.Warn("Plan item build failed: intent amount is nil", zap.Int("index", index)) + return nil, merrors.InvalidArgument("intent.amount is required") } + if modelIntent.Source.Type == model.EndpointTypeUnspecified { + s.logger.Warn("Plan item build failed: intent source is unspecified", zap.Int("index", index)) + return nil, merrors.InvalidArgument("intent.source is required") } + if modelIntent.Destination.Type == model.EndpointTypeUnspecified { + s.logger.Warn("Plan item build failed: intent destination is unspecified", zap.Int("index", index)) + return nil, merrors.InvalidArgument("intent.destination is required") } + s.logger.Debug("Plan item intent validated", + zap.Int("index", index), + zap.String("source_type", string(modelIntent.Source.Type)), + zap.String("dest_type", string(modelIntent.Destination.Type)), + zap.String("amount_currency", modelIntent.Amount.GetCurrency()), + ) + itemIdempotencyKey := deriveItemIdempotencyKey(strings.TrimSpace(in.BaseIdempotencyKey), len(in.Intents), index) source := clonePaymentEndpoint(modelIntent.Source) destination := clonePaymentEndpoint(modelIntent.Destination) + sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true) if err != nil { + s.logger.Warn("Plan item build failed: source rail resolution error", + zap.Int("index", index), + zap.Error(err), + ) + return nil, err } + destRail, destNetwork, err := plan.RailFromEndpoint(destination, modelIntent.Attributes, false) if err != nil { + s.logger.Warn("Plan item build failed: destination rail resolution error", + zap.Int("index", index), + zap.Error(err), + ) + return nil, err } + routeNetwork, err := plan.ResolveRouteNetwork(modelIntent.Attributes, sourceNetwork, destNetwork) if err != nil { + s.logger.Warn("Plan item build failed: route network resolution error", + zap.Int("index", index), + zap.String("source_network", sourceNetwork), + zap.String("dest_network", destNetwork), + zap.Error(err), + ) + return nil, err } + s.logger.Debug("Plan item rails resolved", + zap.Int("index", index), + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destRail)), + zap.String("route_network", firstNonEmpty(routeNetwork, destNetwork, sourceNetwork)), + ) + routeRails, err := s.resolveRouteRails(ctx, sourceRail, destRail, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork)) if err != nil { + s.logger.Warn("Plan item build failed: route rails resolution error", + zap.Int("index", index), + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destRail)), + zap.Error(err), + ) + return nil, err } + s.logger.Debug("Plan item route rails resolved", + zap.Int("index", index), + zap.Int("route_rails_count", len(routeRails)), + ) + steps := buildComputationSteps(index, modelIntent, destination, routeRails) - if modelIntent.Destination.Type == model.EndpointTypeCard && - s.gatewayRegistry != nil && - !hasExplicitDestinationGateway(modelIntent.Attributes) { - // Avoid sticky default provider when registry-driven selection is available. - clearImplicitDestinationGateway(steps) - } + + s.logger.Debug("Plan item steps built", zap.Int("index", index), zap.Int("step_count", len(steps))) + if err := s.resolveStepGateways( ctx, steps, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork), ); err != nil { + s.logger.Warn("Plan item build failed: step gateway resolution error", + zap.Int("index", index), + zap.Error(err), + ) + return nil, err } + + s.logger.Debug("Plan item step gateways resolved", zap.Int("index", index)) + provider := firstNonEmpty( destinationGatewayFromSteps(steps), gatewayKeyForFunding(modelIntent.Attributes, destination), @@ -117,6 +200,7 @@ func (s *QuoteComputationService) buildPlanItem( if provider == "" && destRail == model.RailLedger { provider = "internal" } + funding, err := s.resolveFundingGate(ctx, resolveFundingGateInput{ OrganizationRef: strings.TrimSpace(in.OrganizationRef), Rail: destRail, @@ -133,17 +217,36 @@ func (s *QuoteComputationService) buildPlanItem( InstanceID: instanceIDForFunding(modelIntent.Attributes), }) if err != nil { + s.logger.Warn("Plan item build failed: funding gate resolution error", + zap.Int("index", index), + zap.String("provider", provider), + zap.Error(err), + ) + return nil, err } + + s.logger.Debug("Plan item funding gate resolved", + zap.Int("index", index), + zap.String("provider", provider), + zap.Bool("has_funding", funding != nil), + ) + route := buildRouteSpecification( modelIntent, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork), steps, ) conditions, blockReason := buildExecutionConditions(in.PreviewOnly, steps, funding) + if route == nil || len(route.GetHops()) == 0 { blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE + + s.logger.Debug("Plan item route unavailable, item will be blocked", + zap.Int("index", index), + ) } + quoteInput := BuildQuoteInput{ OrganizationRef: strings.TrimSpace(in.OrganizationRef), IdempotencyKey: itemIdempotencyKey, @@ -160,6 +263,15 @@ func (s *QuoteComputationService) buildPlanItem( intentRef = fmt.Sprintf("intent-%d", index) } + s.logger.Debug("Computation plan item built", + zap.Int("index", index), + zap.String("intent_ref", intentRef), + zap.Int("step_count", len(steps)), + zap.String("block_reason", blockReason.String()), + zap.Bool("has_funding", funding != nil), + zap.Bool("preview_only", quoteInput.PreviewOnly), + ) + return &QuoteComputationPlanItem{ Index: index, IdempotencyKey: itemIdempotencyKey, @@ -187,14 +299,11 @@ func deriveItemIdempotencyKey(base string, total, index int) string { return fmt.Sprintf("%s:%d", base, index+1) } -func gatewayKeyForFunding(attrs map[string]string, destination model.PaymentEndpoint) string { +func gatewayKeyForFunding(attrs map[string]string, _ model.PaymentEndpoint) string { key := firstNonEmpty( lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"), lookupAttr(attrs, "destination_gateway", "destinationGateway"), ) - if key == "" && destination.Card != nil { - return defaultCardGateway - } return normalizeGatewayKey(key) } @@ -225,9 +334,23 @@ func (s *QuoteComputationService) resolveFundingGate( in resolveFundingGateInput, ) (*gateway_funding_profile.QuoteFundingGate, error) { if s == nil || s.fundingResolver == nil { + s.logger.Debug("Funding gate resolution skipped: no funding resolver configured", + zap.String("gateway_id", in.GatewayID), + zap.String("rail", string(in.Rail)), + ) + return nil, nil } + s.logger.Debug("Resolving funding gate", + zap.String("org_ref", in.OrganizationRef), + zap.String("gateway_id", in.GatewayID), + zap.String("instance_id", in.InstanceID), + zap.String("rail", string(in.Rail)), + zap.String("network", in.Network), + zap.String("currency", in.Currency), + ) + profile, err := s.fundingResolver.ResolveGatewayFundingProfile(ctx, gateway_funding_profile.FundingProfileRequest{ OrganizationRef: strings.TrimSpace(in.OrganizationRef), GatewayID: normalizeGatewayKey(in.GatewayID), @@ -241,10 +364,40 @@ func (s *QuoteComputationService) resolveFundingGate( Attributes: in.Attributes, }) if err != nil { + s.logger.Warn("Funding gate resolution failed", + zap.String("org_ref", in.OrganizationRef), + zap.String("gateway_id", in.GatewayID), + zap.String("rail", string(in.Rail)), + zap.Error(err), + ) + return nil, err } + if profile == nil { + s.logger.Debug("Funding gate resolution returned no profile", + zap.String("gateway_id", in.GatewayID), + zap.String("rail", string(in.Rail)), + ) + return nil, nil } - return gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount) + + gate, err := gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount) + if err != nil { + s.logger.Warn("Funding gate build from profile failed", + zap.String("gateway_id", in.GatewayID), + zap.Error(err), + ) + + return nil, err + } + + s.logger.Debug("Funding gate resolved", + zap.String("gateway_id", in.GatewayID), + zap.String("rail", string(in.Rail)), + zap.Bool("has_gate", gate != nil), + ) + + return gate, nil } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go index c2f27722..11797093 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" ) func (s *QuoteComputationService) resolveRouteRails( @@ -15,26 +16,69 @@ func (s *QuoteComputationService) resolveRouteRails( destinationRail model.Rail, network string, ) ([]model.Rail, error) { + s.logger.Debug("Resolving route rails", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + ) + if sourceRail == model.RailUnspecified { + s.logger.Warn("Route rails resolution failed: source rail is unspecified") + return nil, merrors.InvalidArgument("source rail is required") } + if destinationRail == model.RailUnspecified { + s.logger.Warn("Route rails resolution failed: destination rail is unspecified") + return nil, merrors.InvalidArgument("destination rail is required") } + if sourceRail == destinationRail { + s.logger.Debug("Route rails resolved: same rail, no path finding needed", + zap.String("rail", string(sourceRail)), + ) + return []model.Rail{sourceRail}, nil } strictGraph := s != nil && s.routeStore != nil + + s.logger.Debug("Loading route graph edges", + zap.Bool("strict_graph", strictGraph), + ) + edges, err := s.routeGraphEdges(ctx) if err != nil { + s.logger.Warn("Route rails resolution failed: route graph edges load error", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.Error(err), + ) + return nil, err } + s.logger.Debug("Route graph edges loaded", + zap.Int("edge_count", len(edges)), + zap.Bool("strict_graph", strictGraph), + ) + if len(edges) == 0 { if strictGraph { + s.logger.Warn("Route rails resolution failed: route graph has no edges", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + ) + return nil, merrors.InvalidArgument("route graph has no edges") } + + s.logger.Debug("Route graph has no edges, using fallback path", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + ) + return fallbackRouteRails(sourceRail, destinationRail), nil } @@ -43,6 +87,12 @@ func (s *QuoteComputationService) resolveRouteRails( pathFinder = graph_path_finder.New() } + s.logger.Debug("Finding route path", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + ) + path, findErr := pathFinder.Find(graph_path_finder.FindInput{ SourceRail: sourceRail, DestinationRail: destinationRail, @@ -51,31 +101,75 @@ func (s *QuoteComputationService) resolveRouteRails( }) if findErr != nil { if strictGraph { + s.logger.Warn("Route rails resolution failed: path finding error", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + zap.Error(findErr), + ) + return nil, findErr } + + s.logger.Debug("Route path finding failed, using fallback path", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + zap.Error(findErr), + ) + return fallbackRouteRails(sourceRail, destinationRail), nil } if path == nil || len(path.Rails) == 0 { if strictGraph { + s.logger.Warn("Route rails resolution failed: path is empty", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + ) + return nil, merrors.InvalidArgument("route path is empty") } + + s.logger.Debug("Route path is empty, using fallback path", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + ) + return fallbackRouteRails(sourceRail, destinationRail), nil } + + s.logger.Debug("Route rails resolved", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.Int("rail_count", len(path.Rails)), + ) + return append([]model.Rail(nil), path.Rails...), nil } func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_path_finder.Edge, error) { if s == nil || s.routeStore == nil { + s.logger.Debug("Route graph edges skipped: no route store configured") + return nil, nil } enabled := true routes, err := s.routeStore.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled}) if err != nil { + s.logger.Warn("Route graph edges load failed", + zap.Error(err), + ) + return nil, err } + if routes == nil || len(routes.Items) == 0 { + s.logger.Debug("Route graph edges: no routes found") + return nil, nil } @@ -84,17 +178,26 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_ if route == nil || !route.IsEnabled { continue } + from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail)))) to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail)))) + if from == model.RailUnspecified || to == model.RailUnspecified { continue } + edges = append(edges, graph_path_finder.Edge{ FromRail: from, ToRail: to, Network: strings.ToUpper(strings.TrimSpace(route.Network)), }) } + + s.logger.Debug("Route graph edges built", + zap.Int("route_count", len(routes.Items)), + zap.Int("edge_count", len(edges)), + ) + return edges, nil } @@ -102,8 +205,10 @@ func fallbackRouteRails(sourceRail, destinationRail model.Rail) []model.Rail { if sourceRail == destinationRail { return []model.Rail{sourceRail} } + if requiresTransitBridgeStep(sourceRail, destinationRail) { return []model.Rail{sourceRail, model.RailLedger, destinationRail} } + return []model.Rail{sourceRail, destinationRail} } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go index 8ae9b9fa..b6d80ffa 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go @@ -66,10 +66,6 @@ func normalizeProvider(value string) string { return strings.ToLower(strings.TrimSpace(value)) } -func normalizePayoutMethod(value string) string { - return strings.ToUpper(strings.TrimSpace(value)) -} - func normalizeAsset(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go index beee457e..3f225399 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go @@ -7,6 +7,8 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/plan" "github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile" "github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) type Core interface { @@ -21,12 +23,14 @@ type QuoteComputationService struct { gatewayRegistry plan.GatewayRegistry routeStore plan.RouteStore pathFinder *graph_path_finder.GraphPathFinder + logger mlogger.Logger } func New(core Core, opts ...Option) *QuoteComputationService { svc := &QuoteComputationService{ core: core, pathFinder: graph_path_finder.New(), + logger: zap.NewNop(), } for _, opt := range opts { if opt != nil { @@ -67,3 +71,11 @@ func WithPathFinder(pathFinder *graph_path_finder.GraphPathFinder) Option { } } } + +func WithLogger(logger mlogger.Logger) Option { + return func(svc *QuoteComputationService) { + if svc != nil && logger != nil { + svc.logger = logger.Named("computation") + } + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index b71f5c97..20d55a54 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -16,13 +16,39 @@ import ( 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" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" "go.uber.org/zap" "google.golang.org/protobuf/types/known/timestamppb" ) -func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { +type quoteRequest struct { + Meta *sharedv1.RequestMeta + IdempotencyKey string + Intent *sharedv1.PaymentIntent +} + +func (r *quoteRequest) GetMeta() *sharedv1.RequestMeta { + if r == nil { + return nil + } + return r.Meta +} + +func (r *quoteRequest) GetIdempotencyKey() string { + if r == nil { + return "" + } + return r.IdempotencyKey +} + +func (r *quoteRequest) GetIntent() *sharedv1.PaymentIntent { + if r == nil { + return nil + } + return r.Intent +} + +func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quoteRequest) (*sharedv1.PaymentQuote, time.Time, error) { intent := req.GetIntent() amount := intent.GetAmount() fxSide := fxv1.Side_SIDE_UNSPECIFIED @@ -117,7 +143,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo return quote, expiresAt, nil } -func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { +func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { if !s.deps.fees.available() { return &feesv1.PrecomputeFeesResponse{}, nil } @@ -153,7 +179,7 @@ func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quotationv1 return resp, nil } -func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { +func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { if !s.deps.fees.available() { return &feesv1.PrecomputeFeesResponse{}, nil } @@ -454,7 +480,7 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.Payme return resp, nil } -func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*oraclev1.Quote, error) { +func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteRequest) (*oraclev1.Quote, error) { if !s.deps.oracle.available() { if req.GetIntent().GetRequiresFx() { return nil, merrors.Internal("fx_oracle_unavailable") diff --git a/api/payments/quotation/internal/service/quotation/service.go b/api/payments/quotation/internal/service/quotation/service.go index 90512bac..d0195418 100644 --- a/api/payments/quotation/internal/service/quotation/service.go +++ b/api/payments/quotation/internal/service/quotation/service.go @@ -1,17 +1,10 @@ package quotation import ( - "context" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/pkg/api/routers" clockpkg "github.com/tech/sendico/pkg/clock" - msg "github.com/tech/sendico/pkg/messaging" - mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" - orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - "google.golang.org/grpc" + "go.uber.org/zap" ) type serviceError string @@ -29,49 +22,37 @@ var ( errStorageUnavailable = serviceError("payments.quotation: storage not initialised") ) -// Service handles payment quotation and read models. +// Service hosts quotation-v2 runtime dependencies. type Service struct { logger mlogger.Logger storage storage.Repository clock clockpkg.Clock deps serviceDependencies - h handlerSet - - gatewayBroker mb.Broker - gatewayConsumers []msg.Consumer - - orchestrationv1.UnimplementedPaymentExecutionServiceServer } type serviceDependencies struct { fees feesDependency - ledger ledgerDependency gateway gatewayDependency - railGateways railGatewayDependency - providerGateway providerGatewayDependency oracle oracleDependency gatewayRegistry GatewayRegistry gatewayInvokeResolver GatewayInvokeResolver cardRoutes map[string]CardGatewayRoute feeLedgerAccounts map[string]string - planBuilder PlanBuilder -} - -type handlerSet struct { - commands *paymentCommandFactory } // NewService constructs the quotation service core. func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { + if logger == nil { + logger = zap.NewNop() + } + svc := &Service{ - logger: logger.Named("payments.quotation"), + logger: logger.Named("service"), storage: repo, clock: clockpkg.NewSystem(), } - initMetrics() - for _, opt := range opts { if opt != nil { opt(svc) @@ -81,34 +62,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) if svc.clock == nil { svc.clock = clockpkg.NewSystem() } - - engine := defaultPaymentEngine{svc: svc} - svc.h.commands = newPaymentCommandFactory(engine, svc.logger) - return svc } -func (s *Service) ensureHandlers() { - if s.h.commands == nil { - s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger) - } -} - -// Register attaches the service to the supplied gRPC router. -func (s *Service) Register(router routers.GRPC) error { - return router.Register(func(reg grpc.ServiceRegistrar) { - orchestrationv1.RegisterPaymentExecutionServiceServer(reg, s) - }) -} - -// QuotePayment aggregates downstream quotes. -func (s *Service) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.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 *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req) -} +// Shutdown releases runtime resources. Quotation v2 has no background workers. +func (s *Service) Shutdown() {} diff --git a/api/payments/quotation/internal/service/quotation/service_helpers.go b/api/payments/quotation/internal/service/quotation/service_helpers.go deleted file mode 100644 index 0a3c1667..00000000 --- a/api/payments/quotation/internal/service/quotation/service_helpers.go +++ /dev/null @@ -1,191 +0,0 @@ -package quotation - -import ( - "context" - "errors" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/merrors" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "google.golang.org/protobuf/proto" -) - -func validateMetaAndOrgRef(meta *sharedv1.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 requireNonNilIntent(intent *sharedv1.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 ensureQuotesStore(repo storage.Repository) (quotestorage.QuotesStore, error) { - if repo == nil { - return nil, errStorageUnavailable - } - store := repo.Quotes() - if store == nil { - return nil, errStorageUnavailable - } - return store, nil -} - -type quoteResolutionInput struct { - OrgRef string - OrgID bson.ObjectID - Meta *sharedv1.RequestMeta - Intent *sharedv1.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) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - if ref := strings.TrimSpace(in.QuoteRef); ref != "" { - quotesStore, err := ensureQuotesStore(s.storage) - if err != nil { - return nil, nil, nil, err - } - record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) - if err != nil { - if errors.Is(err, quotestorage.ErrQuoteNotFound) { - return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} - } - return nil, nil, nil, err - } - if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { - return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} - } - if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return nil, nil, nil, quoteResolutionError{code: "quote_not_executable", err: merrors.InvalidArgument(note)} - } - intent, err := recordIntentFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - if in.Intent != nil && !proto.Equal(intent, in.Intent) { - return nil, nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} - } - quote, err := recordQuoteFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - quote.QuoteRef = ref - plan, err := recordPlanFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - return quote, intent, plan, nil - } - - if in.Intent == nil { - return nil, nil, nil, merrors.InvalidArgument("intent is required") - } - req := "ationv1.QuotePaymentRequest{ - Meta: in.Meta, - IdempotencyKey: in.IdempotencyKey, - Intent: in.Intent, - PreviewOnly: false, - } - quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req) - if err != nil { - return nil, nil, nil, err - } - plan, err := s.buildPaymentPlan(ctx, in.OrgID, in.Intent, in.IdempotencyKey, quote) - if err != nil { - return nil, nil, nil, err - } - return quote, in.Intent, plan, nil -} - -func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.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) (*sharedv1.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 recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) { - if record == nil { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - if len(record.Plans) > 0 { - if len(record.Plans) != 1 { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return cloneStoredPaymentPlan(record.Plans[0]), nil - } - if record.Plan != nil { - return cloneStoredPaymentPlan(record.Plan), nil - } - return nil, nil -} - -func newPayment(orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *sharedv1.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 -} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go index ff787924..17e744b4 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -2,6 +2,7 @@ package transfer_intent_hydrator import ( "context" + "errors" "fmt" "strings" @@ -157,13 +158,21 @@ func (h *TransferIntentHydrator) HydrateMany(ctx context.Context, in HydrateMany Intent: intent, }) if err != nil { - return nil, fmt.Errorf("intents[%d]: %w", i, err) + return nil, wrapIndexedIntentError(i, err) } out = append(out, item) } return out, nil } +func wrapIndexedIntentError(index int, err error) error { + msg := fmt.Sprintf("intents[%d]", index) + if errors.Is(err, merrors.ErrInvalidArg) { + return merrors.InvalidArgumentWrap(err, msg) + } + return merrors.InternalWrap(err, msg) +} + func resolveEconomics( mode paymentv1.SettlementMode, feeTreatment quotationv2.FeeTreatment, diff --git a/api/payments/storage/go.mod b/api/payments/storage/go.mod index cf28916a..4b4b10bc 100644 --- a/api/payments/storage/go.mod +++ b/api/payments/storage/go.mod @@ -5,6 +5,7 @@ go 1.25.7 replace github.com/tech/sendico/pkg => ../../pkg require ( + github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/pkg v0.1.0 go.mongodb.org/mongo-driver/v2 v2.5.0 go.uber.org/zap v1.27.1 diff --git a/api/payments/storage/go.sum b/api/payments/storage/go.sum index a719c09c..198d8f8b 100644 --- a/api/payments/storage/go.sum +++ b/api/payments/storage/go.sum @@ -91,6 +91,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i 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= diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go b/api/payments/storage/model/gateway_eligibility.go similarity index 52% rename from api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go rename to api/payments/storage/model/gateway_eligibility.go index 1c2eb824..4d8d3dba 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go +++ b/api/payments/storage/model/gateway_eligibility.go @@ -1,31 +1,49 @@ -package orchestrator +package model import ( "fmt" "strings" "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" ) -type sendDirection int +type GatewayDirection int const ( - sendDirectionAny sendDirection = iota - sendDirectionOut - sendDirectionIn + GatewayDirectionAny GatewayDirection = iota + GatewayDirectionOut + GatewayDirectionIn ) -func sendDirectionForRail(rail model.Rail) sendDirection { - switch rail { - case model.RailFiatOnRamp: - return sendDirectionIn +func (d GatewayDirection) String() string { + switch d { + case GatewayDirectionOut: + return "out" + case GatewayDirectionIn: + return "in" default: - return sendDirectionOut + return "any" } } -func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) error { +func NoEligibleGatewayMessage(network, currency string, action RailOperation, dir GatewayDirection) string { + return fmt.Sprintf( + "plan builder: no eligible gateway found for %s %s %s for direction %s", + strings.ToUpper(strings.TrimSpace(network)), + strings.ToUpper(strings.TrimSpace(currency)), + ParseRailOperation(string(action)), + dir.String(), + ) +} + +func IsGatewayEligible( + gw *GatewayInstanceDescriptor, + rail Rail, + network, currency string, + action RailOperation, + dir GatewayDirection, + amount decimal.Decimal, +) error { if gw == nil { return gatewayIneligible(gw, "gateway instance is required") } @@ -51,8 +69,8 @@ func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, net } } - if !capabilityAllowsAction(gw.Capabilities, action, dir) { - return gatewayIneligible(gw, fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir))) + if !gatewayAllowsAction(gw.Operations, gw.Capabilities, action, dir) { + return gatewayIneligible(gw, fmt.Sprintf("gateway does not allow action=%s dir=%s", action, dir.String())) } if currency != "" { @@ -71,49 +89,89 @@ func (e gatewayIneligibleError) Error() string { return e.reason } -func gatewayIneligible(gw *model.GatewayInstanceDescriptor, reason string) error { +func gatewayIneligible(gw *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" + instanceID := "" + if gw != nil { + instanceID = gw.InstanceID } + return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", instanceID, reason)} } -func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool { +func gatewayAllowsAction(operations []RailOperation, cap RailCapabilities, action RailOperation, dir GatewayDirection) bool { + normalized := NormalizeRailOperations(operations) + if len(normalized) > 0 { + return operationsAllowAction(normalized, action, dir) + } + return capabilityAllowsAction(cap, action, dir) +} + +func capabilityAllowsAction(cap RailCapabilities, action RailOperation, dir GatewayDirection) bool { switch action { - case model.RailOperationSend: + case RailOperationSend: switch dir { - case sendDirectionOut: + case GatewayDirectionOut: return cap.CanPayOut - case sendDirectionIn: + case GatewayDirectionIn: return cap.CanPayIn default: return cap.CanPayIn || cap.CanPayOut } - case model.RailOperationFee: + case RailOperationExternalDebit, RailOperationExternalCredit: + switch dir { + case GatewayDirectionOut: + return cap.CanPayOut + case GatewayDirectionIn: + return cap.CanPayIn + default: + return cap.CanPayIn || cap.CanPayOut + } + case RailOperationFee: return cap.CanSendFee - case model.RailOperationObserveConfirm: + case RailOperationObserveConfirm: return cap.RequiresObserveConfirm - case model.RailOperationBlock: + case RailOperationBlock: return cap.CanBlock - case model.RailOperationRelease: + case RailOperationRelease: return cap.CanRelease default: return true } } -func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) error { +func operationsAllowAction(operations []RailOperation, action RailOperation, dir GatewayDirection) bool { + action = ParseRailOperation(string(action)) + if action == RailOperationUnspecified { + return false + } + + if HasRailOperation(operations, action) { + return true + } + + switch action { + case RailOperationSend: + switch dir { + case GatewayDirectionIn: + return HasRailOperation(operations, RailOperationExternalDebit) + case GatewayDirectionOut: + return HasRailOperation(operations, RailOperationExternalCredit) + default: + return HasRailOperation(operations, RailOperationExternalDebit) || + HasRailOperation(operations, RailOperationExternalCredit) + } + case RailOperationExternalDebit: + return HasRailOperation(operations, RailOperationSend) + case RailOperationExternalCredit: + return HasRailOperation(operations, RailOperationSend) + default: + return false + } +} + +func amountWithinLimits(gw *GatewayInstanceDescriptor, limits Limits, currency string, amount decimal.Decimal, action RailOperation) error { min := firstLimitValue(limits.MinAmount, "") max := firstLimitValue(limits.MaxAmount, "") perTxMin := firstLimitValue(limits.PerTxMinAmount, "") @@ -123,7 +181,7 @@ func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits if override, ok := limits.CurrencyLimits[currency]; ok { min = firstLimitValue(override.MinAmount, min) max = firstLimitValue(override.MaxAmount, max) - if action == model.RailOperationFee { + if action == RailOperationFee { maxFee = firstLimitValue(override.MaxFee, maxFee) } } @@ -148,7 +206,7 @@ func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits 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 action == 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())) } @@ -164,21 +222,3 @@ func firstLimitValue(primary, fallback string) string { } return strings.TrimSpace(fallback) } - -func parseRailValue(value string) model.Rail { - val := strings.ToUpper(strings.TrimSpace(value)) - switch val { - case string(model.RailCrypto): - return model.RailCrypto - case string(model.RailProviderSettlement): - return model.RailProviderSettlement - case string(model.RailLedger): - return model.RailLedger - case string(model.RailCardPayout): - return model.RailCardPayout - case string(model.RailFiatOnRamp): - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } -} diff --git a/api/payments/storage/model/gateway_eligibility_test.go b/api/payments/storage/model/gateway_eligibility_test.go new file mode 100644 index 00000000..1cfd432d --- /dev/null +++ b/api/payments/storage/model/gateway_eligibility_test.go @@ -0,0 +1,49 @@ +package model + +import ( + "testing" + + "github.com/shopspring/decimal" +) + +func TestIsGatewayEligible_AllowsMatchingGateway(t *testing.T) { + gw := &GatewayInstanceDescriptor{ + ID: "gw-1", + InstanceID: "inst-1", + Rail: RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Operations: []RailOperation{RailOperationSend, RailOperationExternalCredit}, + IsEnabled: true, + } + + err := IsGatewayEligible(gw, RailCrypto, "TRON", "USDT", RailOperationSend, GatewayDirectionOut, decimal.RequireFromString("10")) + if err != nil { + t.Fatalf("expected gateway to be eligible, got err=%v", err) + } +} + +func TestIsGatewayEligible_RejectsNetworkMismatch(t *testing.T) { + gw := &GatewayInstanceDescriptor{ + ID: "gw-1", + InstanceID: "inst-1", + Rail: RailCrypto, + Network: "ETH", + Currencies: []string{"USDT"}, + Operations: []RailOperation{RailOperationSend}, + IsEnabled: true, + } + + err := IsGatewayEligible(gw, RailCrypto, "TRON", "USDT", RailOperationSend, GatewayDirectionOut, decimal.RequireFromString("10")) + if err == nil { + t.Fatalf("expected network mismatch error") + } +} + +func TestNoEligibleGatewayMessage(t *testing.T) { + got := NoEligibleGatewayMessage("tron", "usdt", RailOperationSend, GatewayDirectionOut) + want := "plan builder: no eligible gateway found for TRON USDT SEND for direction out" + if got != want { + t.Fatalf("unexpected message: got=%q want=%q", got, want) + } +} diff --git a/api/payments/storage/model/payment.go b/api/payments/storage/model/payment.go index b21b55cf..0837e488 100644 --- a/api/payments/storage/model/payment.go +++ b/api/payments/storage/model/payment.go @@ -139,6 +139,7 @@ type GatewayInstanceDescriptor struct { Network string `bson:"network,omitempty" json:"network,omitempty"` InvokeURI string `bson:"invokeUri,omitempty" json:"invokeUri,omitempty"` Currencies []string `bson:"currencies,omitempty" json:"currencies,omitempty"` + Operations []RailOperation `bson:"operations,omitempty" json:"operations,omitempty"` Capabilities RailCapabilities `bson:"capabilities,omitempty" json:"capabilities,omitempty"` Limits Limits `bson:"limits,omitempty" json:"limits,omitempty"` Version string `bson:"version,omitempty" json:"version,omitempty"` @@ -305,18 +306,18 @@ type PaymentPlan struct { // ExecutionStep describes a planned or executed payment step for reporting. type ExecutionStep struct { - Code string `bson:"code,omitempty" json:"code,omitempty"` - Description string `bson:"description,omitempty" json:"description,omitempty"` - Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` - NetworkFee *paymenttypes.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"` - SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"` - DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"` - TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"` - OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"` - ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"` - Error string `bson:"error,omitempty" json:"error,omitempty"` - State OperationState `bson:"state,omitempty" json:"state,omitempty"` - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + Code string `bson:"code,omitempty" json:"code,omitempty"` + Description string `bson:"description,omitempty" json:"description,omitempty"` + Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` + NetworkFee *paymenttypes.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"` + SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"` + DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"` + TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"` + OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"` + ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + State OperationState `bson:"state,omitempty" json:"state,omitempty"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` } func (s *ExecutionStep) IsTerminal() bool { diff --git a/api/payments/storage/model/rail_operations.go b/api/payments/storage/model/rail_operations.go new file mode 100644 index 00000000..8a156559 --- /dev/null +++ b/api/payments/storage/model/rail_operations.go @@ -0,0 +1,93 @@ +package model + +import "strings" + +var supportedRailOperations = map[RailOperation]struct{}{ + RailOperationDebit: {}, + RailOperationCredit: {}, + RailOperationExternalDebit: {}, + RailOperationExternalCredit: {}, + RailOperationMove: {}, + RailOperationSend: {}, + RailOperationFee: {}, + RailOperationObserveConfirm: {}, + RailOperationFXConvert: {}, + RailOperationBlock: {}, + RailOperationRelease: {}, +} + +// ParseRailOperation canonicalizes string values into a RailOperation token. +func ParseRailOperation(value string) RailOperation { + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" { + return RailOperationUnspecified + } + return RailOperation(clean) +} + +// IsSupportedRailOperation reports whether op is recognized by payment planning. +func IsSupportedRailOperation(op RailOperation) bool { + _, ok := supportedRailOperations[ParseRailOperation(string(op))] + return ok +} + +// NormalizeRailOperations trims, uppercases, deduplicates, and filters unknown values. +func NormalizeRailOperations(values []RailOperation) []RailOperation { + if len(values) == 0 { + return nil + } + result := make([]RailOperation, 0, len(values)) + seen := map[RailOperation]bool{} + for _, value := range values { + op := ParseRailOperation(string(value)) + if op == RailOperationUnspecified || !IsSupportedRailOperation(op) || seen[op] { + continue + } + seen[op] = true + result = append(result, op) + } + if len(result) == 0 { + return nil + } + return result +} + +// NormalizeRailOperationStrings normalizes string operation values. +func NormalizeRailOperationStrings(values []string) []RailOperation { + if len(values) == 0 { + return nil + } + ops := make([]RailOperation, 0, len(values)) + for _, value := range values { + ops = append(ops, ParseRailOperation(value)) + } + return NormalizeRailOperations(ops) +} + +// HasRailOperation checks whether ops includes action. +func HasRailOperation(ops []RailOperation, action RailOperation) bool { + want := ParseRailOperation(string(action)) + if want == RailOperationUnspecified { + return false + } + for _, op := range ops { + if ParseRailOperation(string(op)) == want { + return true + } + } + return false +} + +// RailCapabilitiesFromOperations derives legacy capability flags from explicit operations. +func RailCapabilitiesFromOperations(ops []RailOperation) RailCapabilities { + normalized := NormalizeRailOperations(ops) + return RailCapabilities{ + CanPayIn: HasRailOperation(normalized, RailOperationExternalDebit), + CanPayOut: HasRailOperation(normalized, RailOperationSend) || HasRailOperation(normalized, RailOperationExternalCredit), + CanReadBalance: false, + CanSendFee: HasRailOperation(normalized, RailOperationFee), + RequiresObserveConfirm: HasRailOperation(normalized, RailOperationObserveConfirm), + CanBlock: HasRailOperation(normalized, RailOperationBlock), + CanRelease: HasRailOperation(normalized, RailOperationRelease), + } +} diff --git a/api/payments/storage/model/rail_operations_test.go b/api/payments/storage/model/rail_operations_test.go new file mode 100644 index 00000000..aca5f732 --- /dev/null +++ b/api/payments/storage/model/rail_operations_test.go @@ -0,0 +1,65 @@ +package model + +import "testing" + +func TestNormalizeRailOperations(t *testing.T) { + ops := NormalizeRailOperations([]RailOperation{ + "send", + "SEND", + " external_credit ", + "unknown", + "", + }) + if len(ops) != 2 { + t.Fatalf("unexpected operations count: got=%d want=2", len(ops)) + } + if ops[0] != RailOperationSend { + t.Fatalf("unexpected first operation: got=%q want=%q", ops[0], RailOperationSend) + } + if ops[1] != RailOperationExternalCredit { + t.Fatalf("unexpected second operation: got=%q want=%q", ops[1], RailOperationExternalCredit) + } +} + +func TestHasRailOperation(t *testing.T) { + ops := []RailOperation{RailOperationSend, RailOperationExternalCredit} + if !HasRailOperation(ops, RailOperationSend) { + t.Fatalf("expected send operation to be present") + } + if !HasRailOperation(ops, " external_credit ") { + t.Fatalf("expected external credit operation to be present") + } + if HasRailOperation(ops, RailOperationObserveConfirm) { + t.Fatalf("did not expect observe confirm operation to be present") + } +} + +func TestRailCapabilitiesFromOperations(t *testing.T) { + cap := RailCapabilitiesFromOperations([]RailOperation{ + RailOperationExternalDebit, + RailOperationExternalCredit, + RailOperationFee, + RailOperationObserveConfirm, + RailOperationBlock, + RailOperationRelease, + }) + + if !cap.CanPayIn { + t.Fatalf("expected can pay in to be true") + } + if !cap.CanPayOut { + t.Fatalf("expected can pay out to be true") + } + if !cap.CanSendFee { + t.Fatalf("expected can send fee to be true") + } + if !cap.RequiresObserveConfirm { + t.Fatalf("expected requires observe confirm to be true") + } + if !cap.CanBlock { + t.Fatalf("expected can block to be true") + } + if !cap.CanRelease { + t.Fatalf("expected can release to be true") + } +} diff --git a/api/payments/storage/mongo/repository.go b/api/payments/storage/mongo/repository.go index ebc68cbd..d9c1cd74 100644 --- a/api/payments/storage/mongo/repository.go +++ b/api/payments/storage/mongo/repository.go @@ -16,6 +16,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" ) // Store implements storage.Repository backed by MongoDB. @@ -23,6 +24,7 @@ type Store struct { logger mlogger.Logger ping func(context.Context) error + database *mongo.Database payments storage.PaymentsStore methods storage.PaymentMethodsStore quotes quotestorage.QuotesStore @@ -71,17 +73,18 @@ func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Stor plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection()) methodsRepo := repository.CreateMongoRepository(conn.Database(), mservice.PaymentMethods) - return newWithRepository(logger, conn.Ping, paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...) + return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...) } // NewWithRepository constructs a payments repository using the provided primitives. func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, plansRepo repository.Repository, opts ...Option) (*Store, error) { - return newWithRepository(logger, ping, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...) + return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...) } func newWithRepository( logger mlogger.Logger, ping func(context.Context) error, + database *mongo.Database, paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo repository.Repository, opts ...Option, ) (*Store, error) { @@ -147,6 +150,7 @@ func newWithRepository( result := &Store{ logger: childLogger, ping: ping, + database: database, payments: paymentsStore, methods: methodsStore, quotes: quotesRepoStore.Quotes(), @@ -190,4 +194,12 @@ func (s *Store) PlanTemplates() storage.PlanTemplatesStore { return s.plans } +// MongoDatabase returns underlying Mongo database when available. +func (s *Store) MongoDatabase() *mongo.Database { + if s == nil { + return nil + } + return s.database +} + var _ storage.Repository = (*Store)(nil) diff --git a/api/pkg/discovery/rail_vocab.go b/api/pkg/discovery/rail_vocab.go new file mode 100644 index 00000000..61163cd7 --- /dev/null +++ b/api/pkg/discovery/rail_vocab.go @@ -0,0 +1,142 @@ +package discovery + +import "strings" + +const ( + RailCrypto = "CRYPTO" + RailProviderSettlement = "PROVIDER_SETTLEMENT" + RailLedger = "LEDGER" + RailCardPayout = "CARD_PAYOUT" + RailFiatOnRamp = "FIAT_ONRAMP" +) + +const ( + RailOperationDebit = "DEBIT" + RailOperationCredit = "CREDIT" + RailOperationExternalDebit = "EXTERNAL_DEBIT" + RailOperationExternalCredit = "EXTERNAL_CREDIT" + RailOperationMove = "MOVE" + RailOperationSend = "SEND" + RailOperationFee = "FEE" + RailOperationObserveConfirm = "OBSERVE_CONFIRM" + RailOperationFXConvert = "FX_CONVERT" + RailOperationBlock = "BLOCK" + RailOperationRelease = "RELEASE" +) + +var knownRails = map[string]struct{}{ + RailCrypto: {}, + RailProviderSettlement: {}, + RailLedger: {}, + RailCardPayout: {}, + RailFiatOnRamp: {}, +} + +var knownRailOperations = map[string]struct{}{ + RailOperationDebit: {}, + RailOperationCredit: {}, + RailOperationExternalDebit: {}, + RailOperationExternalCredit: {}, + RailOperationMove: {}, + RailOperationSend: {}, + RailOperationFee: {}, + RailOperationObserveConfirm: {}, + RailOperationFXConvert: {}, + RailOperationBlock: {}, + RailOperationRelease: {}, +} + +// NormalizeRail canonicalizes a rail token. +func NormalizeRail(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +// IsKnownRail reports whether the value is a recognized payment rail. +func IsKnownRail(value string) bool { + _, ok := knownRails[NormalizeRail(value)] + return ok +} + +// NormalizeRailOperation canonicalizes a rail operation token. +func NormalizeRailOperation(value string) string { + clean := strings.ToUpper(strings.TrimSpace(value)) + if strings.HasPrefix(clean, "RAIL_OPERATION_") { + clean = strings.TrimPrefix(clean, "RAIL_OPERATION_") + } + return clean +} + +// IsKnownRailOperation reports whether the value is a recognized rail operation. +func IsKnownRailOperation(value string) bool { + _, ok := knownRailOperations[NormalizeRailOperation(value)] + return ok +} + +// ExpandRailOperation maps canonical and legacy names to normalized rail operations. +func ExpandRailOperation(value string) []string { + if op := NormalizeRailOperation(value); op != "" { + if IsKnownRailOperation(op) { + return []string{op} + } + } + + switch strings.ToLower(strings.TrimSpace(value)) { + case "payin", "payin.crypto", "payin.fiat", "payin.card": + return []string{RailOperationExternalDebit} + case "payout", "payout.crypto", "payout.fiat", "payout.card": + return []string{RailOperationExternalCredit, RailOperationSend} + case "fee.send", "fees.send": + return []string{RailOperationFee} + case "observe.confirm", "observe_confirm": + return []string{RailOperationObserveConfirm} + case "funds.block", "hold.balance", "block": + return []string{RailOperationBlock} + case "funds.release", "release", "unblock": + return []string{RailOperationRelease} + default: + return nil + } +} + +// NormalizeRailOperations canonicalizes and deduplicates rail operation values. +func NormalizeRailOperations(values []string) []string { + if len(values) == 0 { + return nil + } + + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + for _, op := range ExpandRailOperation(value) { + if op == "" || seen[op] { + continue + } + seen[op] = true + result = append(result, op) + } + } + if len(result) == 0 { + return nil + } + return result +} + +// CryptoRailGatewayOperations returns canonical operations for crypto rail gateways. +func CryptoRailGatewayOperations() []string { + return []string{ + RailOperationSend, + RailOperationExternalDebit, + RailOperationExternalCredit, + RailOperationFee, + RailOperationObserveConfirm, + } +} + +// CardPayoutRailGatewayOperations returns canonical operations for card payout gateways. +func CardPayoutRailGatewayOperations() []string { + return []string{ + RailOperationSend, + RailOperationExternalCredit, + RailOperationObserveConfirm, + } +} diff --git a/api/pkg/discovery/rail_vocab_test.go b/api/pkg/discovery/rail_vocab_test.go new file mode 100644 index 00000000..8c5e7d26 --- /dev/null +++ b/api/pkg/discovery/rail_vocab_test.go @@ -0,0 +1,49 @@ +package discovery + +import "testing" + +func TestNormalizeRailOperations(t *testing.T) { + got := NormalizeRailOperations([]string{ + "send", + "payout.crypto", + "observe.confirm", + "unknown", + "EXTERNAL_CREDIT", + }) + + want := []string{ + RailOperationSend, + RailOperationExternalCredit, + RailOperationObserveConfirm, + } + if len(got) != len(want) { + t.Fatalf("unexpected operations count: got=%d want=%d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected operation[%d]: got=%q want=%q", i, got[i], want[i]) + } + } +} + +func TestExpandRailOperationLegacyAliases(t *testing.T) { + got := ExpandRailOperation("payout.fiat") + if len(got) != 2 { + t.Fatalf("unexpected operations count: got=%d want=2", len(got)) + } + if got[0] != RailOperationExternalCredit { + t.Fatalf("unexpected first operation: got=%q want=%q", got[0], RailOperationExternalCredit) + } + if got[1] != RailOperationSend { + t.Fatalf("unexpected second operation: got=%q want=%q", got[1], RailOperationSend) + } +} + +func TestIsKnownRail(t *testing.T) { + if !IsKnownRail("crypto") { + t.Fatalf("expected crypto rail to be known") + } + if IsKnownRail("telegram") { + t.Fatalf("did not expect telegram rail to be known") + } +} diff --git a/api/pkg/discovery/registry.go b/api/pkg/discovery/registry.go index e4e60281..921c7f2e 100644 --- a/api/pkg/discovery/registry.go +++ b/api/pkg/discovery/registry.go @@ -229,7 +229,7 @@ func normalizeEntry(entry RegistryEntry) RegistryEntry { entry.InstanceID = entry.ID } entry.Service = strings.TrimSpace(entry.Service) - entry.Rail = strings.ToUpper(strings.TrimSpace(entry.Rail)) + entry.Rail = NormalizeRail(entry.Rail) entry.Network = strings.ToUpper(strings.TrimSpace(entry.Network)) entry.Operations = normalizeStrings(entry.Operations, false) entry.CurrencyMeta = normalizeCurrencyAnnouncements(entry.CurrencyMeta) @@ -259,7 +259,7 @@ func normalizeAnnouncement(announce Announcement) Announcement { announce.InstanceID = announce.ID } announce.Service = strings.TrimSpace(announce.Service) - announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail)) + announce.Rail = NormalizeRail(announce.Rail) announce.Operations = normalizeStrings(announce.Operations, false) announce.Currencies = normalizeCurrencyAnnouncements(announce.Currencies) announce.InvokeURI = strings.TrimSpace(announce.InvokeURI) diff --git a/api/pkg/go.mod b/api/pkg/go.mod index 547bd1a2..39a0374a 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -9,7 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/mattn/go-colorable v0.1.14 github.com/mitchellh/mapstructure v1.5.0 - github.com/nats-io/nats.go v1.48.0 + github.com/nats-io/nats.go v1.49.0 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.33.0 @@ -92,6 +92,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.5.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/pkg/go.sum b/api/pkg/go.sum index 9e50f44e..4e72b695 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -106,8 +106,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -271,8 +271,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/pkg/messaging/internal/natsb/broker.go b/api/pkg/messaging/internal/natsb/broker.go index 4ea7fef2..daa7a7d2 100644 --- a/api/pkg/messaging/internal/natsb/broker.go +++ b/api/pkg/messaging/internal/natsb/broker.go @@ -20,9 +20,9 @@ import ( type natsSubscriotions = map[string]*TopicSubscription type NatsBroker struct { + logger mlogger.Logger nc *nats.Conn js nats.JetStreamContext - logger *zap.Logger topicSubs natsSubscriotions mu sync.Mutex bufferSize int @@ -73,7 +73,7 @@ func sanitizeNATSURL(rawURL string) string { // loadEnv gathers and validates connection details from environment variables // listed in the Settings struct. Invalid or missing values surface as a typed // InvalidArgument error so callers can decide how to handle them. -func loadEnv(settings *nc.Settings, l *zap.Logger) (*envConfig, error) { +func loadEnv(settings *nc.Settings, l mlogger.Logger) (*envConfig, error) { get := func(key, label string) (string, error) { if v := os.Getenv(key); v != "" { return v, nil diff --git a/api/pkg/server/grpcapp/app.go b/api/pkg/server/grpcapp/app.go index d40bb606..bdd12d73 100644 --- a/api/pkg/server/grpcapp/app.go +++ b/api/pkg/server/grpcapp/app.go @@ -3,7 +3,6 @@ package grpcapp import ( "context" "errors" - "fmt" "net/http" "sync" "time" @@ -203,16 +202,16 @@ func (a *App[T]) Start() error { } if addr := a.grpc.Addr(); addr != nil { - a.logger.Info(fmt.Sprintf("%s gRPC server started", a.name), zap.String("network", addr.Network()), zap.String("address", addr.String()), zap.Bool("debug_mode", a.debug)) + a.logger.Info("Server started", zap.String("server_name", a.name), zap.String("network", addr.Network()), zap.String("address", addr.String()), zap.Bool("debug_mode", a.debug)) } else { - a.logger.Info(fmt.Sprintf("%s gRPC server started", a.name), zap.Bool("debug_mode", a.debug)) + a.logger.Info("Server started", zap.String("server_name", a.name), zap.Bool("debug_mode", a.debug)) } err = <-a.grpc.Done() if err != nil && !errors.Is(err, context.Canceled) { - a.logger.Error("GRPC server stopped with error", zap.Error(err)) + a.logger.Error("Server stopped with error", zap.Error(err)) } else { - a.logger.Info("GRPC server finished") + a.logger.Info("Server finished") } a.cleanup(context.Background()) diff --git a/api/proto/payments/orchestration/v1/orchestration.proto b/api/proto/payments/orchestration/v1/orchestration.proto deleted file mode 100644 index 38e94bdf..00000000 --- a/api/proto/payments/orchestration/v1/orchestration.proto +++ /dev/null @@ -1,155 +0,0 @@ -syntax = "proto3"; - -package payments.orchestration.v1; - -option go_package = "github.com/tech/sendico/pkg/proto/payments/orchestration/v1;orchestrationv1"; - -import "api/proto/common/pagination/v1/cursor.proto"; -import "api/proto/billing/fees/v1/fees.proto"; -import "api/proto/gateway/chain/v1/chain.proto"; -import "api/proto/gateway/mntx/v1/mntx.proto"; -import "api/proto/payments/shared/v1/shared.proto"; - -// InitiatePaymentsRequest triggers execution of all payment intents within -// a previously accepted quote. -message InitiatePaymentsRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - string quote_ref = 3; - map metadata = 4; -} - -// InitiatePaymentsResponse returns the created payments. -message InitiatePaymentsResponse { - repeated payments.shared.v1.Payment payments = 1; -} - -// InitiatePaymentRequest creates a single payment from a standalone intent. -message InitiatePaymentRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - payments.shared.v1.PaymentIntent intent = 3; - map metadata = 4; - string quote_ref = 5; -} - -// InitiatePaymentResponse returns the created payment. -message InitiatePaymentResponse { - payments.shared.v1.Payment payment = 1; -} - -// GetPaymentRequest fetches a payment by its reference. -message GetPaymentRequest { - payments.shared.v1.RequestMeta meta = 1; - string payment_ref = 2; -} - -// GetPaymentResponse returns the requested payment. -message GetPaymentResponse { - payments.shared.v1.Payment payment = 1; -} - -// ListPaymentsRequest queries payments with optional state and endpoint filters. -message ListPaymentsRequest { - payments.shared.v1.RequestMeta meta = 1; - repeated payments.shared.v1.PaymentState filter_states = 2; - string source_ref = 3; - string destination_ref = 4; - common.pagination.v1.CursorPageRequest page = 5; - string organization_ref = 6; -} - -// ListPaymentsResponse returns a page of matching payments. -message ListPaymentsResponse { - repeated payments.shared.v1.Payment payments = 1; - common.pagination.v1.CursorPageResponse page = 2; -} - -// CancelPaymentRequest requests cancellation of a payment that has not yet -// been settled. -message CancelPaymentRequest { - payments.shared.v1.RequestMeta meta = 1; - string payment_ref = 2; - string reason = 3; -} - -// CancelPaymentResponse returns the updated payment after cancellation. -message CancelPaymentResponse { - payments.shared.v1.Payment payment = 1; -} - -// ProcessTransferUpdateRequest handles a blockchain transfer status change -// event from the chain gateway. -message ProcessTransferUpdateRequest { - payments.shared.v1.RequestMeta meta = 1; - chain.gateway.v1.TransferStatusChangedEvent event = 2; -} - -// ProcessTransferUpdateResponse returns the payment after processing. -message ProcessTransferUpdateResponse { - payments.shared.v1.Payment payment = 1; -} - -// ProcessDepositObservedRequest handles a wallet deposit observation event -// from the chain gateway. -message ProcessDepositObservedRequest { - payments.shared.v1.RequestMeta meta = 1; - chain.gateway.v1.WalletDepositObservedEvent event = 2; -} - -// ProcessDepositObservedResponse returns the payment after processing. -message ProcessDepositObservedResponse { - payments.shared.v1.Payment payment = 1; -} - -// ProcessCardPayoutUpdateRequest handles a card payout status change event -// from the card gateway. -message ProcessCardPayoutUpdateRequest { - payments.shared.v1.RequestMeta meta = 1; - mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2; -} - -// ProcessCardPayoutUpdateResponse returns the payment after processing. -message ProcessCardPayoutUpdateResponse { - payments.shared.v1.Payment payment = 1; -} - -// InitiateConversionRequest creates an FX conversion payment between two -// ledger endpoints. -message InitiateConversionRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - payments.shared.v1.PaymentEndpoint source = 3; - payments.shared.v1.PaymentEndpoint destination = 4; - payments.shared.v1.FXIntent fx = 5; - fees.v1.PolicyOverrides fee_policy = 6; - map metadata = 7; -} - -// InitiateConversionResponse returns the created conversion payment. -message InitiateConversionResponse { - payments.shared.v1.Payment conversion = 1; -} - -// PaymentExecutionService orchestrates payment lifecycle operations across -// ledger, blockchain, card, and FX rails. -service PaymentExecutionService { - // InitiatePayments executes all intents within a quote. - rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse); - // InitiatePayment creates and executes a single payment. - rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse); - // CancelPayment cancels a pending payment. - rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse); - // GetPayment retrieves a payment by reference. - rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse); - // ListPayments queries payments with filters and pagination. - rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse); - // InitiateConversion creates an FX conversion payment. - rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse); - // ProcessTransferUpdate handles blockchain transfer status callbacks. - rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse); - // ProcessDepositObserved handles deposit observation callbacks. - rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse); - // ProcessCardPayoutUpdate handles card payout status callbacks. - rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse); -} diff --git a/api/proto/payments/quotation/v1/quotation.proto b/api/proto/payments/quotation/v1/quotation.proto deleted file mode 100644 index 20c8d9cb..00000000 --- a/api/proto/payments/quotation/v1/quotation.proto +++ /dev/null @@ -1,47 +0,0 @@ -syntax = "proto3"; - -package payments.quotation.v1; - -option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v1;quotationv1"; - -import "api/proto/payments/shared/v1/shared.proto"; - -// QuotePaymentRequest is the request to quote a single payment. -message QuotePaymentRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - payments.shared.v1.PaymentIntent intent = 3; - bool preview_only = 4; -} - -// QuotePaymentResponse is the response for QuotePayment. -message QuotePaymentResponse { - payments.shared.v1.PaymentQuote quote = 1; - string idempotency_key = 2; - // Non-empty when quote is valid for pricing but cannot be executed. - string execution_note = 3; -} - -// QuotePaymentsRequest is the request to quote multiple payments in a batch. -message QuotePaymentsRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - repeated payments.shared.v1.PaymentIntent intents = 3; - bool preview_only = 4; -} - -// QuotePaymentsResponse is the response for QuotePayments. -message QuotePaymentsResponse { - string quote_ref = 1; - payments.shared.v1.PaymentQuoteAggregate aggregate = 2; - repeated payments.shared.v1.PaymentQuote quotes = 3; - string idempotency_key = 4; -} - -// QuotationService provides payment quoting capabilities. -service QuotationService { - // QuotePayment returns a quote for a single payment request. - rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); - // QuotePayments returns quotes for multiple payment requests. - rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse); -} diff --git a/api/server/go.mod b/api/server/go.mod index 0eae9b59..eb24f4a1 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -15,10 +15,10 @@ replace github.com/tech/sendico/payments/storage => ../payments/storage replace github.com/tech/sendico/gateway/tron => ../gateway/tron require ( - github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.9 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 + github.com/aws/aws-sdk-go-v2 v1.41.2 + github.com/aws/aws-sdk-go-v2/config v1.32.10 + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.3.3 @@ -53,21 +53,21 @@ require ( dario.cat/mergo v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect - github.com/aws/smithy-go v1.24.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect + github.com/aws/smithy-go v1.24.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -76,7 +76,7 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v27.3.1+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect @@ -107,7 +107,7 @@ require ( github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nats.go v1.49.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -144,5 +144,5 @@ require ( 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-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect ) diff --git a/api/server/go.sum b/api/server/go.sum index 75928d9a..af103f5f 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -6,44 +6,44 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 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/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= -github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= -github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= +github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= +github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= +github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 h1:IJRzQTvdpjHRPItx9gzNcz7Y1F+xqAR+xiy9rr5ZYl8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 h1:giB30dEeoar5bgDnkE0q+z7cFjcHaCjulpmPVmuKR84= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= +github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -73,8 +73,8 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 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= @@ -175,8 +175,8 @@ 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/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= 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= @@ -363,8 +363,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index 9bf988f1..9b835318 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -1,6 +1,8 @@ package srequest import ( + "strings" + "github.com/tech/sendico/pkg/merrors" ) @@ -23,8 +25,7 @@ type QuotePayment struct { } func (r *QuotePayment) Validate() error { - // base checks - if err := r.PaymentBase.Validate(); err != nil { + if err := validateQuoteIdempotency(r.PreviewOnly, r.IdempotencyKey); err != nil { return err } @@ -43,7 +44,7 @@ type QuotePayments struct { } func (r *QuotePayments) Validate() error { - if err := r.PaymentBase.Validate(); err != nil { + if err := validateQuoteIdempotency(r.PreviewOnly, r.IdempotencyKey); err != nil { return err } if len(r.Intents) == 0 { @@ -57,6 +58,20 @@ func (r *QuotePayments) Validate() error { return nil } +func validateQuoteIdempotency(previewOnly bool, idempotencyKey string) error { + key := strings.TrimSpace(idempotencyKey) + if previewOnly { + if key != "" { + return merrors.InvalidArgument("previewOnly requests must not include idempotencyKey", "idempotencyKey") + } + return nil + } + if key == "" { + return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey") + } + return nil +} + type InitiatePayment struct { PaymentBase `json:",inline"` Intent *PaymentIntent `json:"intent,omitempty"` diff --git a/api/server/interface/api/srequest/payment_validate_test.go b/api/server/interface/api/srequest/payment_validate_test.go new file mode 100644 index 00000000..3a801cef --- /dev/null +++ b/api/server/interface/api/srequest/payment_validate_test.go @@ -0,0 +1,29 @@ +package srequest + +import "testing" + +func TestValidateQuoteIdempotency(t *testing.T) { + t.Run("non-preview requires idempotency key", func(t *testing.T) { + if err := validateQuoteIdempotency(false, ""); err == nil { + t.Fatalf("expected error for empty idempotency key") + } + }) + + t.Run("preview rejects idempotency key", func(t *testing.T) { + if err := validateQuoteIdempotency(true, "idem-1"); err == nil { + t.Fatalf("expected error when preview request has idempotency key") + } + }) + + t.Run("preview accepts empty idempotency key", func(t *testing.T) { + if err := validateQuoteIdempotency(true, ""); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("non-preview accepts idempotency key", func(t *testing.T) { + if err := validateQuoteIdempotency(false, "idem-1"); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 27a3fdc4..4d79b82f 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -2,6 +2,7 @@ package sresponse import ( "net/http" + "strconv" "strings" "time" @@ -11,9 +12,9 @@ import ( feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "google.golang.org/protobuf/types/known/timestamppb" ) type FeeLine struct { @@ -96,7 +97,7 @@ type paymentResponse struct { } // PaymentQuote wraps a payment quote with refreshed access token. -func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *sharedv1.PaymentQuote, token *TokenData) http.HandlerFunc { +func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *quotationv2.PaymentQuote, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentQuoteResponse{ Quote: toPaymentQuote(quote), IdempotencyKey: idempotencyKey, @@ -105,7 +106,7 @@ func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *s } // PaymentQuotes wraps batch quotes with refreshed access token. -func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv1.QuotePaymentsResponse, token *TokenData) http.HandlerFunc { +func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv2.QuotePaymentsResponse, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentQuotesResponse{ Quote: toPaymentQuotes(resp), authResponse: authResponse{AccessToken: *token}, @@ -113,7 +114,7 @@ func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv1.QuotePayment } // Payments wraps a list of payments with refreshed access token. -func PaymentsResponse(logger mlogger.Logger, payments []*sharedv1.Payment, token *TokenData) http.HandlerFunc { +func PaymentsResponse(logger mlogger.Logger, payments []*orchestrationv2.Payment, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentsResponse{ Payments: toPayments(payments), authResponse: authResponse{AccessToken: *token}, @@ -121,7 +122,7 @@ func PaymentsResponse(logger mlogger.Logger, payments []*sharedv1.Payment, token } // PaymentsList wraps a list of payments with refreshed access token and pagination data. -func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymentsResponse, token *TokenData) http.HandlerFunc { +func PaymentsListResponse(logger mlogger.Logger, resp *orchestrationv2.ListPaymentsResponse, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentsResponse{ Payments: toPayments(resp.GetPayments()), Page: resp.GetPage(), @@ -130,7 +131,7 @@ func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymen } // Payment wraps a payment with refreshed access token. -func PaymentResponse(logger mlogger.Logger, payment *sharedv1.Payment, token *TokenData) http.HandlerFunc { +func PaymentResponse(logger mlogger.Logger, payment *orchestrationv2.Payment, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentResponse{ Payment: toPayment(payment), authResponse: authResponse{AccessToken: *token}, @@ -191,33 +192,20 @@ func toFxQuote(q *oraclev1.Quote) *FxQuote { } } -func toPaymentQuote(q *sharedv1.PaymentQuote) *PaymentQuote { +func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote { if q == nil { return nil } return &PaymentQuote{ QuoteRef: q.GetQuoteRef(), - DebitAmount: toMoney(q.GetDebitAmount()), - DebitSettlementAmount: toMoney(q.GetDebitSettlementAmount()), - ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()), - ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()), + DebitAmount: toMoney(q.GetPayerTotalDebitAmount()), + ExpectedSettlementAmount: toMoney(q.GetDestinationAmount()), FeeLines: toFeeLines(q.GetFeeLines()), FxQuote: toFxQuote(q.GetFxQuote()), } } -func toPaymentQuoteAggregate(q *sharedv1.PaymentQuoteAggregate) *PaymentQuoteAggregate { - if q == nil { - return nil - } - return &PaymentQuoteAggregate{ - DebitAmounts: toMoneyList(q.GetDebitAmounts()), - ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()), - ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()), - } -} - -func toPaymentQuotes(resp *quotationv1.QuotePaymentsResponse) *PaymentQuotes { +func toPaymentQuotes(resp *quotationv2.QuotePaymentsResponse) *PaymentQuotes { if resp == nil { return nil } @@ -233,12 +221,11 @@ func toPaymentQuotes(resp *quotationv1.QuotePaymentsResponse) *PaymentQuotes { return &PaymentQuotes{ IdempotencyKey: resp.GetIdempotencyKey(), QuoteRef: resp.GetQuoteRef(), - Aggregate: toPaymentQuoteAggregate(resp.GetAggregate()), Quotes: quotes, } } -func toPayments(items []*sharedv1.Payment) []Payment { +func toPayments(items []*orchestrationv2.Payment) []Payment { if len(items) == 0 { return nil } @@ -254,22 +241,65 @@ func toPayments(items []*sharedv1.Payment) []Payment { return result } -func toPayment(p *sharedv1.Payment) *Payment { +func toPayment(p *orchestrationv2.Payment) *Payment { if p == nil { return nil } + failureCode, failureReason := firstFailure(p.GetStepExecutions()) return &Payment{ PaymentRef: p.GetPaymentRef(), - IdempotencyKey: p.GetIdempotencyKey(), State: enumJSONName(p.GetState().String()), - FailureCode: enumJSONName(p.GetFailureCode().String()), - FailureReason: p.GetFailureReason(), - LastQuote: toPaymentQuote(p.GetLastQuote()), - CreatedAt: p.GetCreatedAt().AsTime(), - Meta: p.GetMetadata(), + FailureCode: failureCode, + FailureReason: failureReason, + LastQuote: toPaymentQuote(p.GetQuoteSnapshot()), + CreatedAt: timestampAsTime(p.GetCreatedAt()), + Meta: paymentMeta(p), + IdempotencyKey: "", } } +func firstFailure(steps []*orchestrationv2.StepExecution) (string, string) { + for _, step := range steps { + if step == nil || step.GetFailure() == nil { + continue + } + failure := step.GetFailure() + message := strings.TrimSpace(failure.GetMessage()) + if message == "" { + message = strings.TrimSpace(failure.GetCode()) + } + return enumJSONName(failure.GetCategory().String()), message + } + return "", "" +} + +func paymentMeta(p *orchestrationv2.Payment) map[string]string { + if p == nil { + return nil + } + meta := make(map[string]string) + if quotationRef := strings.TrimSpace(p.GetQuotationRef()); quotationRef != "" { + meta["quotationRef"] = quotationRef + } + if clientPaymentRef := strings.TrimSpace(p.GetClientPaymentRef()); clientPaymentRef != "" { + meta["clientPaymentRef"] = clientPaymentRef + } + if version := p.GetVersion(); version > 0 { + meta["version"] = strconv.FormatUint(version, 10) + } + if len(meta) == 0 { + return nil + } + return meta +} + +func timestampAsTime(ts *timestamppb.Timestamp) time.Time { + if ts == nil { + return time.Time{} + } + return ts.AsTime() +} + func enumJSONName(value string) string { return strings.ToLower(strings.TrimSpace(value)) } diff --git a/api/server/internal/server/paymentapiimp/list.go b/api/server/internal/server/paymentapiimp/list.go index d6a471cc..b1362823 100644 --- a/api/server/internal/server/paymentapiimp/list.go +++ b/api/server/internal/server/paymentapiimp/list.go @@ -4,18 +4,19 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mutil/mzap" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" + "google.golang.org/protobuf/types/known/timestamppb" ) const maxInt32 = int64(1<<31 - 1) @@ -38,9 +39,7 @@ func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token return response.AccessDenied(a.logger, a.Name(), "payments read permission denied") } - req := &orchestratorv1.ListPaymentsRequest{ - OrganizationRef: orgRef.Hex(), - } + req := &orchestrationv2.ListPaymentsRequest{Meta: requestMeta(orgRef.Hex(), "")} if page, err := listPaymentsPage(r); err != nil { return response.Auto(a.logger, a.Name(), err) @@ -49,17 +48,33 @@ func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token } query := r.URL.Query() - if sourceRef := strings.TrimSpace(query.Get("source_ref")); sourceRef != "" { - req.SourceRef = sourceRef + if quotationRef := firstNonEmpty(query.Get("quotation_ref"), query.Get("quote_ref")); quotationRef != "" { + req.QuotationRef = quotationRef } - if destinationRef := strings.TrimSpace(query.Get("destination_ref")); destinationRef != "" { - req.DestinationRef = destinationRef + createdFrom, err := parseRFC3339Timestamp(firstNonEmpty(query.Get("created_from"), query.Get("createdFrom")), "created_from") + if err != nil { + return response.Auto(a.logger, a.Name(), err) + } + if createdFrom != nil { + req.CreatedFrom = createdFrom + } + createdTo, err := parseRFC3339Timestamp(firstNonEmpty(query.Get("created_to"), query.Get("createdTo")), "created_to") + if err != nil { + return response.Auto(a.logger, a.Name(), err) + } + if createdTo != nil { + req.CreatedTo = createdTo + } + if req.GetCreatedFrom() != nil && req.GetCreatedTo() != nil { + if !req.GetCreatedTo().AsTime().After(req.GetCreatedFrom().AsTime()) { + return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("created_to must be after created_from", "created_to")) + } } if states, err := parsePaymentStateFilters(r); err != nil { return response.Auto(a.logger, a.Name(), err) } else if len(states) > 0 { - req.FilterStates = states + req.States = states } resp, err := a.execution.ListPayments(ctx, req) @@ -106,7 +121,7 @@ func listPaymentsPage(r *http.Request) (*paginationv1.CursorPageRequest, error) return page, nil } -func parsePaymentStateFilters(r *http.Request) ([]sharedv1.PaymentState, error) { +func parsePaymentStateFilters(r *http.Request) ([]orchestrationv2.OrchestrationState, error) { query := r.URL.Query() values := append([]string{}, query["state"]...) values = append(values, query["states"]...) @@ -115,14 +130,14 @@ func parsePaymentStateFilters(r *http.Request) ([]sharedv1.PaymentState, error) return nil, nil } - states := make([]sharedv1.PaymentState, 0, len(values)) + states := make([]orchestrationv2.OrchestrationState, 0, len(values)) for _, raw := range values { for _, part := range strings.Split(raw, ",") { trimmed := strings.TrimSpace(part) if trimmed == "" { continue } - state, ok := paymentStateFromString(trimmed) + state, ok := orchestrationStateFromString(trimmed) if !ok { return nil, merrors.InvalidArgument("unsupported payment state: "+trimmed, "state") } @@ -136,17 +151,49 @@ func parsePaymentStateFilters(r *http.Request) ([]sharedv1.PaymentState, error) return states, nil } -func paymentStateFromString(value string) (sharedv1.PaymentState, bool) { +func orchestrationStateFromString(value string) (orchestrationv2.OrchestrationState, bool) { upper := strings.ToUpper(strings.TrimSpace(value)) if upper == "" { return 0, false } - if !strings.HasPrefix(upper, "PAYMENT_STATE_") { - upper = "PAYMENT_STATE_" + upper + switch upper { + case "PAYMENT_STATE_ACCEPTED", "ACCEPTED": + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED, true + case "PAYMENT_STATE_FUNDS_RESERVED", "FUNDS_RESERVED", "PAYMENT_STATE_SUBMITTED", "SUBMITTED": + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, true + case "PAYMENT_STATE_SETTLED", "SETTLED": + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED, true + case "PAYMENT_STATE_FAILED", "FAILED", "PAYMENT_STATE_CANCELLED", "CANCELLED": + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED, true } - enumValue, ok := sharedv1.PaymentState_value[upper] + if !strings.HasPrefix(upper, "ORCHESTRATION_STATE_") { + upper = "ORCHESTRATION_STATE_" + upper + } + enumValue, ok := orchestrationv2.OrchestrationState_value[upper] if !ok { return 0, false } - return sharedv1.PaymentState(enumValue), true + return orchestrationv2.OrchestrationState(enumValue), true +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func parseRFC3339Timestamp(raw string, field string) (*timestamppb.Timestamp, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, trimmed) + if err != nil { + return nil, merrors.InvalidArgument("invalid "+field+", expected RFC3339", field) + } + return timestamppb.New(parsed), nil } diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 883a9799..a9be4fff 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -1,26 +1,26 @@ package paymentapiimp import ( + "strconv" "strings" - "github.com/google/uuid" "github.com/tech/sendico/pkg/merrors" + pkgmodel "github.com/tech/sendico/pkg/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "github.com/tech/sendico/server/interface/api/srequest" + "go.mongodb.org/mongo-driver/v2/bson" ) -func mapPaymentIntent(intent *srequest.PaymentIntent) (*sharedv1.PaymentIntent, error) { +func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, error) { if intent == nil { return nil, merrors.InvalidArgument("intent is required") } - - kind, err := mapPaymentKind(intent.Kind) - if err != nil { + if err := validatePaymentKind(intent.Kind); err != nil { return nil, err } @@ -33,33 +33,35 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*sharedv1.PaymentIntent, settlementCurrency = resolveSettlementCurrency(intent) } - source, err := mapPaymentEndpoint(intent.Source, "source") + source, err := mapQuoteEndpoint(intent.Source, "intent.source") if err != nil { return nil, err } - destination, err := mapPaymentEndpoint(intent.Destination, "destination") + destination, err := mapQuoteEndpoint(intent.Destination, "intent.destination") if err != nil { return nil, err } - fx, err := mapFXIntent(intent.FX) - if err != nil { - return nil, err - } - - return &sharedv1.PaymentIntent{ - Ref: uuid.New().String(), - Kind: kind, + quoteIntent := "ationv2.QuoteIntent{ Source: source, Destination: destination, Amount: mapMoney(intent.Amount), - RequiresFx: fx != nil, - Fx: fx, SettlementMode: settlementMode, SettlementCurrency: settlementCurrency, - Attributes: copyStringMap(intent.Attributes), - Customer: mapCustomer(intent.Customer), - }, nil + } + if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" { + quoteIntent.Comment = comment + } + return quoteIntent, nil +} + +func validatePaymentKind(kind srequest.PaymentKind) error { + switch strings.TrimSpace(string(kind)) { + case string(srequest.PaymentKindPayout), string(srequest.PaymentKindInternalTransfer), string(srequest.PaymentKindFxConversion): + return nil + default: + return merrors.InvalidArgument("unsupported payment kind: " + string(kind)) + } } func resolveSettlementCurrency(intent *srequest.PaymentIntent) string { @@ -81,150 +83,147 @@ func resolveSettlementCurrency(intent *srequest.PaymentIntent) string { return quote } } - if intent.Amount != nil { - amountCurrency := strings.TrimSpace(intent.Amount.Currency) - if amountCurrency != "" { - switch { - case strings.EqualFold(amountCurrency, base) && quote != "": - return quote - case strings.EqualFold(amountCurrency, quote) && base != "": - return base - default: - return amountCurrency - } - } - } - if quote != "" { - return quote - } - if base != "" { - return base - } } if intent.Amount != nil { return strings.TrimSpace(intent.Amount.Currency) } - return "" } -func mapPaymentEndpoint(endpoint *srequest.Endpoint, field string) (*sharedv1.PaymentEndpoint, error) { +func mapQuoteEndpoint(endpoint *srequest.Endpoint, field string) (*endpointv1.PaymentEndpoint, error) { if endpoint == nil { - return nil, nil + return nil, merrors.InvalidArgument(field + " is required") } - var result sharedv1.PaymentEndpoint switch endpoint.Type { case srequest.EndpointTypeLedger: payload, err := endpoint.DecodeLedger() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - result.Endpoint = &sharedv1.PaymentEndpoint_Ledger{ - Ledger: mapLedgerEndpoint(&payload), + method := &ledgerMethodData{ + LedgerAccountRef: strings.TrimSpace(payload.LedgerAccountRef), + ContraLedgerAccountRef: strings.TrimSpace(payload.ContraLedgerAccountRef), } + if method.LedgerAccountRef == "" { + return nil, merrors.InvalidArgument(field + ".ledger_account_ref is required") + } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, method) + case srequest.EndpointTypeManagedWallet: payload, err := endpoint.DecodeManagedWallet() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - mw, err := mapManagedWalletEndpoint(&payload) + method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.ManagedWalletRef)} + if method.WalletID == "" { + return nil, merrors.InvalidArgument(field + ".managed_wallet_ref is required") + } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method) + + case srequest.EndpointTypeWallet: + payload, err := endpoint.DecodeWallet() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - result.Endpoint = &sharedv1.PaymentEndpoint_ManagedWallet{ - ManagedWallet: mw, + method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.WalletID)} + if method.WalletID == "" { + return nil, merrors.InvalidArgument(field + ".walletId is required") } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method) + case srequest.EndpointTypeExternalChain: payload, err := endpoint.DecodeExternalChain() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - ext, err := mapExternalChainEndpoint(&payload) - if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) - } - result.Endpoint = &sharedv1.PaymentEndpoint_ExternalChain{ - ExternalChain: ext, + method, mapErr := mapExternalChainMethod(payload, field) + if mapErr != nil { + return nil, mapErr } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, method) + case srequest.EndpointTypeCard: payload, err := endpoint.DecodeCard() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - result.Endpoint = &sharedv1.PaymentEndpoint_Card{ - Card: mapCardEndpoint(&payload), + method := &pkgmodel.CardPaymentData{ + Pan: strings.TrimSpace(payload.Pan), + FirstName: strings.TrimSpace(payload.FirstName), + LastName: strings.TrimSpace(payload.LastName), + ExpMonth: uint32ToString(payload.ExpMonth), + ExpYear: uint32ToString(payload.ExpYear), + Country: strings.TrimSpace(payload.Country), } + if method.Pan == "" { + return nil, merrors.InvalidArgument(field + ".pan is required") + } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, method) + case srequest.EndpointTypeCardToken: payload, err := endpoint.DecodeCardToken() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - result.Endpoint = &sharedv1.PaymentEndpoint_Card{ - Card: mapCardTokenEndpoint(&payload), + method := &pkgmodel.TokenPaymentData{ + Token: strings.TrimSpace(payload.Token), + Last4: strings.TrimSpace(payload.MaskedPan), } + if method.Token == "" { + return nil, merrors.InvalidArgument(field + ".token is required") + } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, method) + case "": return nil, merrors.InvalidArgument(field + " endpoint type is required") + default: - return nil, merrors.InvalidArgument(field + " endpoint has unsupported type: " + string(endpoint.Type)) - } - - result.Metadata = copyStringMap(endpoint.Metadata) - return &result, nil -} - -func mapLedgerEndpoint(endpoint *srequest.LedgerEndpoint) *sharedv1.LedgerEndpoint { - if endpoint == nil { - return nil - } - return &sharedv1.LedgerEndpoint{ - LedgerAccountRef: endpoint.LedgerAccountRef, - ContraLedgerAccountRef: endpoint.ContraLedgerAccountRef, + return nil, merrors.InvalidArgument(field + " endpoint type is unsupported in v2: " + string(endpoint.Type)) } } -func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) (*sharedv1.ManagedWalletEndpoint, error) { - if endpoint == nil { - return nil, nil +func mapExternalChainMethod(payload srequest.ExternalChainEndpoint, field string) (*pkgmodel.CryptoAddressPaymentData, error) { + address := strings.TrimSpace(payload.Address) + if address == "" { + return nil, merrors.InvalidArgument(field + ".address is required") } - asset, err := mapAsset(endpoint.Asset) + if payload.Asset == nil { + return nil, merrors.InvalidArgument(field + ".asset is required") + } + token := strings.ToUpper(strings.TrimSpace(payload.Asset.TokenSymbol)) + if token == "" { + return nil, merrors.InvalidArgument(field + ".asset.token_symbol is required") + } + if _, err := mapChainNetwork(payload.Asset.Chain); err != nil { + return nil, merrors.InvalidArgument(field + ".asset.chain: " + err.Error()) + } + + result := &pkgmodel.CryptoAddressPaymentData{ + Currency: pkgmodel.Currency(token), + Address: address, + Network: strings.ToUpper(strings.TrimSpace(string(payload.Asset.Chain))), + } + if memo := strings.TrimSpace(payload.Memo); memo != "" { + result.DestinationTag = &memo + } + return result, nil +} + +func endpointFromMethod(methodType endpointv1.PaymentMethodType, data any) (*endpointv1.PaymentEndpoint, error) { + raw, err := bson.Marshal(data) if err != nil { - return nil, err + return nil, merrors.InternalWrap(err, "failed to encode payment method data") } - return &sharedv1.ManagedWalletEndpoint{ - ManagedWalletRef: endpoint.ManagedWalletRef, - Asset: asset, - }, nil -} - -func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) (*sharedv1.ExternalChainEndpoint, error) { - if endpoint == nil { - return nil, nil + method := &endpointv1.PaymentMethod{ + Type: methodType, + Data: raw, } - asset, err := mapAsset(endpoint.Asset) - if err != nil { - return nil, err - } - return &sharedv1.ExternalChainEndpoint{ - Asset: asset, - Address: endpoint.Address, - Memo: endpoint.Memo, - }, nil -} - -func mapAsset(asset *srequest.Asset) (*chainv1.Asset, error) { - if asset == nil { - return nil, nil - } - chain, err := mapChainNetwork(asset.Chain) - if err != nil { - return nil, err - } - return &chainv1.Asset{ - Chain: chain, - TokenSymbol: asset.TokenSymbol, - ContractAddress: asset.ContractAddress, + return &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: method, + }, }, nil } @@ -238,94 +237,6 @@ func mapMoney(m *paymenttypes.Money) *moneyv1.Money { } } -func mapFXIntent(fx *srequest.FXIntent) (*sharedv1.FXIntent, error) { - if fx == nil { - return nil, nil - } - side, err := mapFXSide(fx.Side) - if err != nil { - return nil, err - } - return &sharedv1.FXIntent{ - Pair: mapCurrencyPair(fx.Pair), - Side: side, - Firm: fx.Firm, - TtlMs: fx.TTLms, - PreferredProvider: fx.PreferredProvider, - MaxAgeMs: fx.MaxAgeMs, - }, nil -} - -func mapCustomer(customer *srequest.Customer) *sharedv1.Customer { - if customer == nil { - return nil - } - return &sharedv1.Customer{ - Id: strings.TrimSpace(customer.ID), - FirstName: strings.TrimSpace(customer.FirstName), - MiddleName: strings.TrimSpace(customer.MiddleName), - LastName: strings.TrimSpace(customer.LastName), - Ip: strings.TrimSpace(customer.IP), - Zip: strings.TrimSpace(customer.Zip), - Country: strings.TrimSpace(customer.Country), - State: strings.TrimSpace(customer.State), - City: strings.TrimSpace(customer.City), - Address: strings.TrimSpace(customer.Address), - } -} - -func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair { - if pair == nil { - return nil - } - return &fxv1.CurrencyPair{ - Base: pair.Base, - Quote: pair.Quote, - } -} - -func mapCardEndpoint(card *srequest.CardEndpoint) *sharedv1.CardEndpoint { - if card == nil { - return nil - } - result := &sharedv1.CardEndpoint{ - CardholderName: strings.TrimSpace(card.FirstName), - CardholderSurname: strings.TrimSpace(card.LastName), - ExpMonth: card.ExpMonth, - ExpYear: card.ExpYear, - Country: strings.TrimSpace(card.Country), - } - if pan := strings.TrimSpace(card.Pan); pan != "" { - result.Card = &sharedv1.CardEndpoint_Pan{Pan: pan} - } - return result -} - -func mapCardTokenEndpoint(card *srequest.CardTokenEndpoint) *sharedv1.CardEndpoint { - if card == nil { - return nil - } - return &sharedv1.CardEndpoint{ - Card: &sharedv1.CardEndpoint_Token{Token: strings.TrimSpace(card.Token)}, - MaskedPan: strings.TrimSpace(card.MaskedPan), - } -} - -func mapPaymentKind(kind srequest.PaymentKind) (sharedv1.PaymentKind, error) { - switch strings.TrimSpace(string(kind)) { - case "", string(srequest.PaymentKindUnspecified): - return sharedv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, nil - case string(srequest.PaymentKindPayout): - return sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, nil - case string(srequest.PaymentKindInternalTransfer): - return sharedv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER, nil - case string(srequest.PaymentKindFxConversion): - return sharedv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, nil - default: - return sharedv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, merrors.InvalidArgument("unsupported payment kind: " + string(kind)) - } -} - func mapSettlementMode(mode srequest.SettlementMode) (paymentv1.SettlementMode, error) { switch strings.TrimSpace(string(mode)) { case "", string(srequest.SettlementModeUnspecified): @@ -339,19 +250,6 @@ func mapSettlementMode(mode srequest.SettlementMode) (paymentv1.SettlementMode, } } -func mapFXSide(side srequest.FXSide) (fxv1.Side, error) { - switch strings.TrimSpace(string(side)) { - case "", string(srequest.FXSideUnspecified): - return fxv1.Side_SIDE_UNSPECIFIED, nil - case string(srequest.FXSideBuyBaseSellQuote): - return fxv1.Side_BUY_BASE_SELL_QUOTE, nil - case string(srequest.FXSideSellBaseBuyQuote): - return fxv1.Side_SELL_BASE_BUY_QUOTE, nil - default: - return fxv1.Side_SIDE_UNSPECIFIED, merrors.InvalidArgument("unsupported fx side: " + string(side)) - } -} - func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) { switch strings.TrimSpace(string(chain)) { case "", string(srequest.ChainNetworkUnspecified): @@ -369,13 +267,14 @@ func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) } } -func copyStringMap(src map[string]string) map[string]string { - if len(src) == 0 { - return nil +func uint32ToString(v uint32) string { + if v == 0 { + return "" } - dst := make(map[string]string, len(src)) - for k, v := range src { - dst[k] = v - } - return dst + return strconv.FormatUint(uint64(v), 10) +} + +type ledgerMethodData struct { + LedgerAccountRef string `bson:"ledgerAccountRef"` + ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"` } diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go index 762be860..f5e680c5 100644 --- a/api/server/internal/server/paymentapiimp/pay.go +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -9,7 +9,9 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mutil/mzap" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" + tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" @@ -58,26 +60,41 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to } } - var intent *sharedv1.PaymentIntent + quotationRef := strings.TrimSpace(payload.QuoteRef) + intentRef := metadataValue(payload.Metadata, "intent_ref") if payload.Intent != nil { applyCustomerIP(payload.Intent, r.RemoteAddr) - intent, err = mapPaymentIntent(payload.Intent) + intent, err := mapQuoteIntent(payload.Intent) if err != nil { return response.BadPayload(a.logger, a.Name(), err) } + quoteResp, qErr := a.quotation.QuotePayment(ctx, "ationv2.QuotePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), + IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), + Intent: intent, + InitiatorRef: initiatorRef(account), + }) + if qErr != nil { + a.logger.Warn("Failed to quote payment before execution", zap.Error(qErr), mzap.ObjRef("organization_ref", orgRef)) + return response.Auto(a.logger, a.Name(), qErr) + } + quotationRef = strings.TrimSpace(quoteResp.GetQuote().GetQuoteRef()) + if quotationRef == "" { + return response.Auto(a.logger, a.Name(), merrors.DataConflict("quotation service returned empty quote_ref")) + } + if derived := strings.TrimSpace(quoteResp.GetQuote().GetIntentRef()); derived != "" { + intentRef = derived + } } - req := &orchestratorv1.InitiatePaymentRequest{ - Meta: &sharedv1.RequestMeta{ - OrganizationRef: orgRef.Hex(), - }, - IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), - Intent: intent, - QuoteRef: strings.TrimSpace(payload.QuoteRef), - Metadata: payload.Metadata, + req := &orchestrationv2.ExecutePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), + QuotationRef: quotationRef, + IntentRef: intentRef, + ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"), } - resp, err := a.execution.InitiatePayment(ctx, req) + resp, err := a.execution.ExecutePayment(ctx, req) if err != nil { a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) return response.Auto(a.logger, a.Name(), err) @@ -101,3 +118,29 @@ func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) { } return payload, nil } + +func requestMeta(organizationRef string, idempotencyKey string) *sharedv1.RequestMeta { + return &sharedv1.RequestMeta{ + OrganizationRef: strings.TrimSpace(organizationRef), + Trace: &tracev1.TraceContext{ + IdempotencyKey: strings.TrimSpace(idempotencyKey), + }, + } +} + +func metadataValue(meta map[string]string, key string) string { + if len(meta) == 0 { + return "" + } + return strings.TrimSpace(meta[strings.TrimSpace(key)]) +} + +func initiatorRef(account *model.Account) string { + if account == nil { + return "" + } + if account.ID != bson.NilObjectID { + return account.ID.Hex() + } + return strings.TrimSpace(account.Login) +} diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go index dcd8cf14..7b1fe533 100644 --- a/api/server/internal/server/paymentapiimp/paybatch.go +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -8,8 +8,7 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" @@ -40,22 +39,24 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc return response.BadPayload(a.logger, a.Name(), err) } - req := &orchestratorv1.InitiatePaymentsRequest{ - Meta: &sharedv1.RequestMeta{ - OrganizationRef: orgRef.Hex(), - }, - IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), - QuoteRef: strings.TrimSpace(payload.QuoteRef), - Metadata: payload.Metadata, + req := &orchestrationv2.ExecutePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), + QuotationRef: strings.TrimSpace(payload.QuoteRef), + IntentRef: metadataValue(payload.Metadata, "intent_ref"), + ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"), } - resp, err := a.execution.InitiatePayments(ctx, req) + resp, err := a.execution.ExecutePayment(ctx, req) if err != nil { a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) return response.Auto(a.logger, a.Name(), err) } - return sresponse.PaymentsResponse(a.logger, resp.GetPayments(), token) + payments := make([]*orchestrationv2.Payment, 0, 1) + if payment := resp.GetPayment(); payment != nil { + payments = append(payments, payment) + } + return sresponse.PaymentsResponse(a.logger, payments, token) } func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) { diff --git a/api/server/internal/server/paymentapiimp/quote.go b/api/server/internal/server/paymentapiimp/quote.go index 80c59660..56cf1119 100644 --- a/api/server/internal/server/paymentapiimp/quote.go +++ b/api/server/internal/server/paymentapiimp/quote.go @@ -8,8 +8,7 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" @@ -46,18 +45,18 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token } applyCustomerIP(&payload.Intent, r.RemoteAddr) - intent, err := mapPaymentIntent(&payload.Intent) + intent, err := mapQuoteIntent(&payload.Intent) if err != nil { a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r)) return response.BadPayload(a.logger, a.Name(), err) } - req := "ationv1.QuotePaymentRequest{ - Meta: &sharedv1.RequestMeta{ - OrganizationRef: orgRef.Hex(), - }, + req := "ationv2.QuotePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), IdempotencyKey: payload.IdempotencyKey, Intent: intent, + PreviewOnly: payload.PreviewOnly, + InitiatorRef: initiatorRef(account), } resp, err := a.quotation.QuotePayment(ctx, req) @@ -97,10 +96,10 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke return response.Auto(a.logger, a.Name(), err) } - intents := make([]*sharedv1.PaymentIntent, 0, len(payload.Intents)) + intents := make([]*quotationv2.QuoteIntent, 0, len(payload.Intents)) for i := range payload.Intents { applyCustomerIP(&payload.Intents[i], r.RemoteAddr) - intent, err := mapPaymentIntent(&payload.Intents[i]) + intent, err := mapQuoteIntent(&payload.Intents[i]) if err != nil { a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r)) return response.BadPayload(a.logger, a.Name(), err) @@ -108,13 +107,12 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke intents = append(intents, intent) } - req := "ationv1.QuotePaymentsRequest{ - Meta: &sharedv1.RequestMeta{ - OrganizationRef: orgRef.Hex(), - }, + req := "ationv2.QuotePaymentsRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), IdempotencyKey: payload.IdempotencyKey, Intents: intents, PreviewOnly: payload.PreviewOnly, + InitiatorRef: initiatorRef(account), } resp, err := a.quotation.QuotePayments(ctx, req) diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index 7edab491..1bdacbc5 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -18,8 +18,8 @@ import ( msgconsumer "github.com/tech/sendico/pkg/messaging/consumer" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" eapi "github.com/tech/sendico/server/interface/api" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/v2/bson" @@ -30,15 +30,14 @@ import ( ) type executionClient interface { - InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) - InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) - ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) + ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) Close() error } type quotationClient interface { - QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) - QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) + QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) + QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) Close() error } @@ -203,7 +202,7 @@ func (c *quotationClientConfig) setDefaults() { type grpcQuotationClient struct { conn *grpc.ClientConn - client quotationv1.QuotationServiceClient + client quotationv2.QuotationServiceClient callTimeout time.Duration } @@ -230,7 +229,7 @@ func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ... } return &grpcQuotationClient{ conn: conn, - client: quotationv1.NewQuotationServiceClient(conn), + client: quotationv2.NewQuotationServiceClient(conn), callTimeout: cfg.CallTimeout, }, nil } @@ -242,13 +241,13 @@ func (c *grpcQuotationClient) Close() error { return c.conn.Close() } -func (c *grpcQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) { +func (c *grpcQuotationClient) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) { callCtx, cancel := c.callContext(ctx) defer cancel() return c.client.QuotePayment(callCtx, req) } -func (c *grpcQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) { +func (c *grpcQuotationClient) QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) { callCtx, cancel := c.callContext(ctx) defer cancel() return c.client.QuotePayments(callCtx, req) diff --git a/ci/scripts/proto/generate.sh b/ci/scripts/proto/generate.sh index b1520ffc..a0108475 100755 --- a/ci/scripts/proto/generate.sh +++ b/ci/scripts/proto/generate.sh @@ -143,10 +143,10 @@ if [ -f "${PROTO_DIR}/payments/shared/v1/shared.proto" ]; then generate_go_with_grpc "${PROTO_DIR}/payments/shared/v1/shared.proto" fi -if [ -f "${PROTO_DIR}/payments/orchestration/v1/orchestration.proto" ]; then +if [ -f "${PROTO_DIR}/payments/orchestration/v2/orchestration.proto" ]; then info "Compiling payments orchestration protos" clean_pb_files "./pkg/proto/payments/orchestration" - generate_go_with_grpc "${PROTO_DIR}/payments/orchestration/v1/orchestration.proto" + generate_go_with_grpc "${PROTO_DIR}/payments/orchestration/v2/orchestration.proto" fi if [ -f "${PROTO_DIR}/payments/transfer/v1/transfer.proto" ]; then @@ -155,14 +155,10 @@ if [ -f "${PROTO_DIR}/payments/transfer/v1/transfer.proto" ]; then generate_go "${PROTO_DIR}/payments/transfer/v1/transfer.proto" fi -if [ -f "${PROTO_DIR}/payments/quotation/v1/quotation.proto" ] || \ - [ -f "${PROTO_DIR}/payments/quotation/v2/interface.proto" ] || \ +if [ -f "${PROTO_DIR}/payments/quotation/v2/interface.proto" ] || \ [ -f "${PROTO_DIR}/payments/quotation/v2/quotation.proto" ]; then info "Compiling payments quotation protos" clean_pb_files "./pkg/proto/payments/quotation" - if [ -f "${PROTO_DIR}/payments/quotation/v1/quotation.proto" ]; then - generate_go_with_grpc "${PROTO_DIR}/payments/quotation/v1/quotation.proto" - fi if [ -f "${PROTO_DIR}/payments/quotation/v2/interface.proto" ]; then generate_go "api/proto/payments/quotation/v2/interface.proto" fi diff --git a/frontend/pshared/lib/data/mapper/payment/enums.dart b/frontend/pshared/lib/data/mapper/payment/enums.dart index cd3f236d..65581395 100644 --- a/frontend/pshared/lib/data/mapper/payment/enums.dart +++ b/frontend/pshared/lib/data/mapper/payment/enums.dart @@ -5,7 +5,6 @@ import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; - PaymentKind paymentKindFromValue(String? value) { switch (value) { case 'payout': @@ -166,6 +165,7 @@ PaymentType endpointTypeFromValue(String? value) { case 'managedWallet': case 'managed_wallet': return PaymentType.managedWallet; + case 'cryptoAddress': case 'externalChain': case 'external_chain': return PaymentType.externalChain; @@ -193,15 +193,15 @@ String endpointTypeToValue(PaymentType type) { case PaymentType.ledger: return 'ledger'; case PaymentType.managedWallet: - return 'managed_wallet'; + return 'managedWallet'; case PaymentType.externalChain: - return 'external_chain'; + return 'cryptoAddress'; case PaymentType.card: return 'card'; case PaymentType.cardToken: - return 'card'; + return 'cardToken'; case PaymentType.bankAccount: - return 'bank_account'; + return 'bankAccount'; case PaymentType.iban: return 'iban'; case PaymentType.wallet: diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index 1c5a15fc..88b3eb02 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -1,30 +1,31 @@ import 'package:pshared/data/dto/payment/payment.dart'; import 'package:pshared/data/mapper/payment/payment_quote.dart'; import 'package:pshared/models/payment/payment.dart'; - +import 'package:pshared/models/payment/state.dart'; extension PaymentDTOMapper on PaymentDTO { Payment toDomain() => Payment( - paymentRef: paymentRef, - idempotencyKey: idempotencyKey, - state: state, - failureCode: failureCode, - failureReason: failureReason, - lastQuote: lastQuote?.toDomain(), - metadata: metadata, - createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!), - ); + paymentRef: paymentRef, + idempotencyKey: idempotencyKey, + state: state, + orchestrationState: paymentOrchestrationStateFromValue(state), + failureCode: failureCode, + failureReason: failureReason, + lastQuote: lastQuote?.toDomain(), + metadata: metadata, + createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!), + ); } extension PaymentMapper on Payment { PaymentDTO toDTO() => PaymentDTO( - paymentRef: paymentRef, - idempotencyKey: idempotencyKey, - state: state, - failureCode: failureCode, - failureReason: failureReason, - lastQuote: lastQuote?.toDTO(), - metadata: metadata, - createdAt: createdAt?.toUtc().toIso8601String(), - ); + paymentRef: paymentRef, + idempotencyKey: idempotencyKey, + state: state ?? paymentOrchestrationStateToValue(orchestrationState), + failureCode: failureCode, + failureReason: failureReason, + lastQuote: lastQuote?.toDTO(), + metadata: metadata, + createdAt: createdAt?.toUtc().toIso8601String(), + ); } diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 0d1c9058..97e4d99e 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -1,10 +1,11 @@ import 'package:pshared/models/payment/quote/quote.dart'; - +import 'package:pshared/models/payment/state.dart'; class Payment { final String? paymentRef; final String? idempotencyKey; final String? state; + final PaymentOrchestrationState orchestrationState; final String? failureCode; final String? failureReason; final PaymentQuote? lastQuote; @@ -15,6 +16,7 @@ class Payment { required this.paymentRef, required this.idempotencyKey, required this.state, + required this.orchestrationState, required this.failureCode, required this.failureReason, required this.lastQuote, @@ -22,9 +24,12 @@ class Payment { required this.createdAt, }); + bool get isPending => orchestrationState.isPending; + + bool get isTerminal => orchestrationState.isTerminal; + bool get isFailure { if ((failureCode ?? '').trim().isNotEmpty) return true; - final normalized = (state ?? '').trim().toLowerCase(); - return normalized.contains('fail') || normalized.contains('cancel'); + return orchestrationState == PaymentOrchestrationState.failed; } } diff --git a/frontend/pshared/lib/models/payment/state.dart b/frontend/pshared/lib/models/payment/state.dart new file mode 100644 index 00000000..2ee711cd --- /dev/null +++ b/frontend/pshared/lib/models/payment/state.dart @@ -0,0 +1,81 @@ +enum PaymentOrchestrationState { + created, + executing, + needsAttention, + settled, + failed, + unspecified, +} + +PaymentOrchestrationState paymentOrchestrationStateFromValue(String? value) { + final normalized = _normalizePaymentState(value); + switch (normalized) { + case 'CREATED': + case 'ACCEPTED': + return PaymentOrchestrationState.created; + case 'EXECUTING': + case 'PROCESSING': + case 'FUNDS_RESERVED': + case 'SUBMITTED': + return PaymentOrchestrationState.executing; + case 'NEEDS_ATTENTION': + return PaymentOrchestrationState.needsAttention; + case 'SETTLED': + case 'SUCCESS': + return PaymentOrchestrationState.settled; + case 'FAILED': + case 'CANCELLED': + return PaymentOrchestrationState.failed; + default: + return PaymentOrchestrationState.unspecified; + } +} + +String paymentOrchestrationStateToValue(PaymentOrchestrationState state) { + switch (state) { + case PaymentOrchestrationState.created: + return 'orchestration_state_created'; + case PaymentOrchestrationState.executing: + return 'orchestration_state_executing'; + case PaymentOrchestrationState.needsAttention: + return 'orchestration_state_needs_attention'; + case PaymentOrchestrationState.settled: + return 'orchestration_state_settled'; + case PaymentOrchestrationState.failed: + return 'orchestration_state_failed'; + case PaymentOrchestrationState.unspecified: + return 'orchestration_state_unspecified'; + } +} + +extension PaymentOrchestrationStateX on PaymentOrchestrationState { + bool get isTerminal { + switch (this) { + case PaymentOrchestrationState.settled: + case PaymentOrchestrationState.failed: + return true; + case PaymentOrchestrationState.created: + case PaymentOrchestrationState.executing: + case PaymentOrchestrationState.needsAttention: + case PaymentOrchestrationState.unspecified: + return false; + } + } + + bool get isPending => !isTerminal; +} + +String _normalizePaymentState(String? value) { + final trimmed = (value ?? '').trim().toUpperCase(); + if (trimmed.isEmpty) { + return ''; + } + + if (trimmed.startsWith('ORCHESTRATION_STATE_')) { + return trimmed.substring('ORCHESTRATION_STATE_'.length); + } + if (trimmed.startsWith('PAYMENT_STATE_')) { + return trimmed.substring('PAYMENT_STATE_'.length); + } + return trimmed; +} diff --git a/frontend/pshared/lib/models/storable.dart b/frontend/pshared/lib/models/storable.dart index dfd59e3f..da8baf49 100644 --- a/frontend/pshared/lib/models/storable.dart +++ b/frontend/pshared/lib/models/storable.dart @@ -1,12 +1,11 @@ import 'package:flutter/foundation.dart'; -import 'package:pshared/config/web.dart'; - +import 'package:pshared/config/constants.dart'; abstract class Storable { String get id; DateTime get createdAt; - DateTime get updatedAt; + DateTime get updatedAt; } @immutable @@ -23,11 +22,11 @@ class _StorableImp implements Storable { required this.createdAt, required this.updatedAt, }); - } -Storable newStorable({String? id, DateTime? createdAt, DateTime? updatedAt}) => _StorableImp( - id: id ?? Constants.nilObjectRef, - createdAt: createdAt ?? DateTime.now().toUtc(), - updatedAt: updatedAt ?? DateTime.now().toUtc(), -); +Storable newStorable({String? id, DateTime? createdAt, DateTime? updatedAt}) => + _StorableImp( + id: id ?? Constants.nilObjectRef, + createdAt: createdAt ?? DateTime.now().toUtc(), + updatedAt: updatedAt ?? DateTime.now().toUtc(), + ); diff --git a/frontend/pshared/lib/provider/payment/payments.dart b/frontend/pshared/lib/provider/payment/payments.dart index a5a3ba60..7bcc44e9 100644 --- a/frontend/pshared/lib/provider/payment/payments.dart +++ b/frontend/pshared/lib/provider/payment/payments.dart @@ -8,7 +8,6 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/service.dart'; import 'package:pshared/utils/exception.dart'; - class PaymentsProvider with ChangeNotifier { static const Duration _pendingRefreshInterval = Duration(seconds: 10); @@ -20,8 +19,9 @@ class PaymentsProvider with ChangeNotifier { bool _isLoadingMore = false; String? _nextCursor; int? _limit; - String? _sourceRef; - String? _destinationRef; + String? _quotationRef; + DateTime? _createdFrom; + DateTime? _createdTo; List? _states; int _opSeq = 0; @@ -32,7 +32,8 @@ class PaymentsProvider with ChangeNotifier { List get payments => _resource.data ?? []; bool get isLoading => _resource.isLoading; Exception? get error => _resource.error; - bool get isReady => _isLoaded && !_resource.isLoading && _resource.error == null; + bool get isReady => + _isLoaded && !_resource.isLoading && _resource.error == null; bool get isLoadingMore => _isLoadingMore; String? get nextCursor => _nextCursor; @@ -54,14 +55,16 @@ class PaymentsProvider with ChangeNotifier { Future refresh({ int? limit, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, }) async { await _refresh( limit: limit, - sourceRef: sourceRef, - destinationRef: destinationRef, + quotationRef: quotationRef, + createdFrom: createdFrom, + createdTo: createdTo, states: states, showLoading: true, updateError: true, @@ -70,14 +73,16 @@ class PaymentsProvider with ChangeNotifier { Future refreshSilently({ int? limit, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, }) async { await _refresh( limit: limit, - sourceRef: sourceRef, - destinationRef: destinationRef, + quotationRef: quotationRef, + createdFrom: createdFrom, + createdTo: createdTo, states: states, showLoading: false, updateError: false, @@ -87,10 +92,7 @@ class PaymentsProvider with ChangeNotifier { void mergePayments(List incoming) { if (incoming.isEmpty) return; final existing = List.from(_resource.data ?? const []); - final combined = [ - ...incoming, - ...existing, - ]; + final combined = [...incoming, ...existing]; final seen = {}; final merged = []; @@ -110,8 +112,9 @@ class PaymentsProvider with ChangeNotifier { Future _refresh({ int? limit, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, required bool showLoading, required bool updateError, @@ -120,8 +123,9 @@ class PaymentsProvider with ChangeNotifier { if (org == null || !org.isOrganizationSet) return; _limit = limit; - _sourceRef = _normalize(sourceRef); - _destinationRef = _normalize(destinationRef); + _quotationRef = _normalize(quotationRef); + _createdFrom = createdFrom?.toUtc(); + _createdTo = createdTo?.toUtc(); _states = _normalizeStates(states); _nextCursor = null; _isLoadingMore = false; @@ -129,7 +133,10 @@ class PaymentsProvider with ChangeNotifier { final seq = ++_opSeq; if (showLoading) { - _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + _applyResource( + _resource.copyWith(isLoading: true, error: null), + notify: true, + ); } try { @@ -137,8 +144,9 @@ class PaymentsProvider with ChangeNotifier { org.current.id, limit: _limit, cursor: null, - sourceRef: _sourceRef, - destinationRef: _destinationRef, + quotationRef: _quotationRef, + createdFrom: _createdFrom, + createdTo: _createdTo, states: _states, ); @@ -147,11 +155,7 @@ class PaymentsProvider with ChangeNotifier { _isLoaded = true; _nextCursor = _normalize(page.nextCursor); _applyResource( - Resource( - data: page.items, - isLoading: false, - error: null, - ), + Resource(data: page.items, isLoading: false, error: null), notify: true, ); } catch (e) { @@ -162,10 +166,7 @@ class PaymentsProvider with ChangeNotifier { notify: true, ); } else if (showLoading) { - _applyResource( - _resource.copyWith(isLoading: false), - notify: true, - ); + _applyResource(_resource.copyWith(isLoading: false), notify: true); } } } @@ -189,8 +190,9 @@ class PaymentsProvider with ChangeNotifier { org.current.id, limit: _limit, cursor: cursor, - sourceRef: _sourceRef, - destinationRef: _destinationRef, + quotationRef: _quotationRef, + createdFrom: _createdFrom, + createdTo: _createdTo, states: _states, ); @@ -206,10 +208,7 @@ class PaymentsProvider with ChangeNotifier { } catch (e) { if (seq != _opSeq) return; - _applyResource( - _resource.copyWith(error: toException(e)), - notify: false, - ); + _applyResource(_resource.copyWith(error: toException(e)), notify: false); } finally { if (seq == _opSeq) { _isLoadingMore = false; @@ -224,15 +223,19 @@ class PaymentsProvider with ChangeNotifier { _isLoadingMore = false; _nextCursor = null; _limit = null; - _sourceRef = null; - _destinationRef = null; + _quotationRef = null; + _createdFrom = null; + _createdTo = null; _states = null; _resource = Resource(data: []); _stopPendingRefreshTimer(); notifyListeners(); } - void _applyResource(Resource> newResource, {required bool notify}) { + void _applyResource( + Resource> newResource, { + required bool notify, + }) { _resource = newResource; _syncPendingRefresh(); if (notify) notifyListeners(); @@ -253,15 +256,15 @@ class PaymentsProvider with ChangeNotifier { List? _normalizeStates(List? states) { if (states == null || states.isEmpty) return null; final normalized = states - .map((state) => state.trim()) - .where((state) => state.isNotEmpty) - .toList(); + .map((state) => state.trim()) + .where((state) => state.isNotEmpty) + .toList(); if (normalized.isEmpty) return null; return normalized; } void _syncPendingRefresh() { - final hasPending = payments.any(_isPending); + final hasPending = payments.any((payment) => payment.isPending); if (!hasPending) { _stopPendingRefreshTimer(); return; @@ -286,8 +289,9 @@ class PaymentsProvider with ChangeNotifier { try { await refreshSilently( limit: _limit, - sourceRef: _sourceRef, - destinationRef: _destinationRef, + quotationRef: _quotationRef, + createdFrom: _createdFrom, + createdTo: _createdTo, states: _states, ); } finally { @@ -301,29 +305,9 @@ class PaymentsProvider with ChangeNotifier { _isPendingRefreshInFlight = false; } - bool _isPending(Payment payment) { - final raw = payment.state; - final trimmed = (raw ?? '').trim().toUpperCase(); - final normalized = trimmed.startsWith('PAYMENT_STATE_') - ? trimmed.substring('PAYMENT_STATE_'.length) - : trimmed; - - switch (normalized) { - case 'SUCCESS': - case 'FAILED': - case 'CANCELLED': - return false; - case 'PROCESSING': - return true; - default: - return true; - } - } - @override void dispose() { _stopPendingRefreshTimer(); super.dispose(); } - } diff --git a/frontend/pshared/lib/service/authorization/service.dart b/frontend/pshared/lib/service/authorization/service.dart index 817319bc..cf6bb346 100644 --- a/frontend/pshared/lib/service/authorization/service.dart +++ b/frontend/pshared/lib/service/authorization/service.dart @@ -6,7 +6,7 @@ import 'package:pshared/api/requests/login_data.dart'; import 'package:pshared/api/responses/account.dart'; import 'package:pshared/api/responses/login.dart'; import 'package:pshared/api/responses/login_pending.dart'; -import 'package:pshared/config/web.dart'; +import 'package:pshared/config/constants.dart'; import 'package:pshared/data/mapper/account/account.dart'; import 'package:pshared/models/account/account.dart'; import 'package:pshared/models/auth/login_outcome.dart'; @@ -31,21 +31,33 @@ class AuthorizationService { final deviceId = await DeviceIdManager.getDeviceId(); final response = await httpr.getPOSTResponse( service, - '/login', - LoginRequest(login: login, deviceId: deviceId, clientId: Constants.clientId).toJson(), + '/login', + LoginRequest( + login: login, + deviceId: deviceId, + clientId: Constants.clientId, + ).toJson(), ); if (response.containsKey('refreshToken')) { - return LoginOutcome.completed((await completeLogin(response)).account.toDomain()); + return LoginOutcome.completed( + (await completeLogin(response)).account.toDomain(), + ); } if (response.containsKey('pendingToken')) { final pending = PendingLogin.fromResponse( PendingLoginResponse.fromJson(response), - session: SessionIdentifier(clientId: Constants.clientId, deviceId: deviceId), + session: SessionIdentifier( + clientId: Constants.clientId, + deviceId: deviceId, + ), ); return LoginOutcome.pending(pending); } - throw AuthenticationFailedException('Unexpected login response', Exception(response.toString())); + throw AuthenticationFailedException( + 'Unexpected login response', + Exception(response.toString()), + ); } static Future _updateAccessToken(AccountResponse response) async { @@ -57,13 +69,16 @@ class AuthorizationService { return AuthorizationStorage.updateRefreshToken(response.refreshToken); } - static Future _completeLogin(Map response) async { + static Future _completeLogin( + Map response, + ) async { final LoginResponse lr = LoginResponse.fromJson(response); await _updateTokens(lr); return lr; } - static Future completeLogin(Map response) => _completeLogin(response); + static Future completeLogin(Map response) => + _completeLogin(response); static Future restore() async { return (await TokenService.refreshAccessToken()).account.toDomain(); @@ -74,82 +89,123 @@ class AuthorizationService { } // Original AuthorizationService methods - keeping the interface unchanged - static Future> getGETResponse(String service, String url) async { + static Future> getGETResponse( + String service, + String url, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getGETResponse(service, url, authToken: token); } - static Future getGETBinaryResponse(String service, String url) async { + static Future getGETBinaryResponse( + String service, + String url, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getBinaryGETResponse(service, url, authToken: token); } - static Future> getPOSTResponse(String service, String url, Map body) async { + static Future> getPOSTResponse( + String service, + String url, + Map body, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getPOSTResponse(service, url, body, authToken: token); } - static Future> getPUTResponse(String service, String url, Map body) async { + static Future> getPUTResponse( + String service, + String url, + Map body, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getPUTResponse(service, url, body, authToken: token); } - static Future> getPATCHResponse(String service, String url, Map body) async { + static Future> getPATCHResponse( + String service, + String url, + Map body, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getPATCHResponse(service, url, body, authToken: token); } - static Future> getDELETEResponse(String service, String url, Map body) async { + static Future> getDELETEResponse( + String service, + String url, + Map body, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getDELETEResponse(service, url, body, authToken: token); } - static Future getFileUploadResponseAuth(String service, String url, String fileName, String fileType, String mediaType, List bytes) async { + static Future getFileUploadResponseAuth( + String service, + String url, + String fileName, + String fileType, + String mediaType, + List bytes, + ) async { final token = await TokenService.getAccessTokenSafe(); - final res = await httpr.getFileUploadResponse(service, url, fileName, fileType, mediaType, bytes, authToken: token); + final res = await httpr.getFileUploadResponse( + service, + url, + fileName, + fileType, + mediaType, + bytes, + authToken: token, + ); if (res == null) { throw Exception('Upload failed'); } return res.url; } - static Future isAuthorizationStored() async => AuthorizationStorage.isAuthorizationStored(); + static Future isAuthorizationStored() async => + AuthorizationStorage.isAuthorizationStored(); /// Execute an operation with automatic token management and retry logic static Future executeWithAuth( Future Function() operation, String description, { int? maxRetries, - }) async => AuthCircuitBreaker.execute(() async => RetryHelper.withExponentialBackoff( - operation, - maxRetries: maxRetries ?? 3, - initialDelay: Duration(milliseconds: 100), - maxDelay: Duration(seconds: 5), - shouldRetry: (error) => RetryHelper.isRetryableError(error), - )); - + }) async => AuthCircuitBreaker.execute( + () async => RetryHelper.withExponentialBackoff( + operation, + maxRetries: maxRetries ?? 3, + initialDelay: Duration(milliseconds: 100), + maxDelay: Duration(seconds: 5), + shouldRetry: (error) => RetryHelper.isRetryableError(error), + ), + ); /// Handle 401 unauthorized errors with automatic token recovery static Future handleUnauthorized( Future Function() operation, String description, ) async { - _logger.warning('Handling unauthorized error with token recovery: $description'); - - return executeWithAuth( - () async { - try { - // Attempt token recovery first - await TokenService.handleUnauthorized(); - - // Retry the original operation - return await operation(); - } catch (e) { - _logger.severe('Token recovery failed', e); - throw AuthenticationFailedException('Token recovery failed', toException(e)); - } - }, - 'unauthorized recovery: $description', + _logger.warning( + 'Handling unauthorized error with token recovery: $description', ); + + return executeWithAuth(() async { + try { + // Attempt token recovery first + await TokenService.handleUnauthorized(); + + // Retry the original operation + return await operation(); + } catch (e) { + _logger.severe('Token recovery failed', e); + throw AuthenticationFailedException( + 'Token recovery failed', + toException(e), + ); + } + }, 'unauthorized recovery: $description'); } } diff --git a/frontend/pshared/lib/service/device_id.dart b/frontend/pshared/lib/service/device_id.dart index d6cf6351..eb3cb6b7 100644 --- a/frontend/pshared/lib/service/device_id.dart +++ b/frontend/pshared/lib/service/device_id.dart @@ -2,10 +2,9 @@ import 'package:uuid/uuid.dart'; import 'package:logging/logging.dart'; -import 'package:pshared/config/web.dart'; +import 'package:pshared/config/constants.dart'; import 'package:pshared/service/secure_storage.dart'; - class DeviceIdManager { static final _logger = Logger('service.device_id'); @@ -15,7 +14,7 @@ class DeviceIdManager { if (deviceId == null) { _logger.fine('Device id is not set, generating new'); - deviceId = (const Uuid()).v4(); + deviceId = (const Uuid()).v4(); await SecureStorageService.set(_key, deviceId); } diff --git a/frontend/pshared/lib/service/payment/service.dart b/frontend/pshared/lib/service/payment/service.dart index 0c5856ea..844fc95d 100644 --- a/frontend/pshared/lib/service/payment/service.dart +++ b/frontend/pshared/lib/service/payment/service.dart @@ -12,7 +12,6 @@ import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; import 'package:pshared/utils/http/params.dart'; - class PaymentService { static final _logger = Logger('service.payment'); static const String _objectType = Services.payments; @@ -21,17 +20,21 @@ class PaymentService { String organizationRef, { int? limit, String? cursor, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, }) async { _logger.fine('Listing payments for organization $organizationRef'); final queryParams = {}; - if (sourceRef != null && sourceRef.isNotEmpty) { - queryParams['source_ref'] = sourceRef; + if (quotationRef != null && quotationRef.isNotEmpty) { + queryParams['quotation_ref'] = quotationRef; } - if (destinationRef != null && destinationRef.isNotEmpty) { - queryParams['destination_ref'] = destinationRef; + if (createdFrom != null) { + queryParams['created_from'] = createdFrom.toUtc().toIso8601String(); + } + if (createdTo != null) { + queryParams['created_to'] = createdTo.toUtc().toIso8601String(); } if (states != null && states.isNotEmpty) { queryParams['state'] = states.join(','); @@ -43,9 +46,14 @@ class PaymentService { cursor: cursor, queryParams: queryParams, ); - final response = await AuthorizationService.getGETResponse(_objectType, url); + final response = await AuthorizationService.getGETResponse( + _objectType, + url, + ); final parsed = PaymentsResponse.fromJson(response); - final payments = parsed.payments.map((payment) => payment.toDomain()).toList(); + final payments = parsed.payments + .map((payment) => payment.toDomain()) + .toList(); return PaymentPage(items: payments, nextCursor: parsed.nextCursor); } @@ -53,16 +61,18 @@ class PaymentService { String organizationRef, { int? limit, String? cursor, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, }) async { final page = await listPage( organizationRef, limit: limit, cursor: cursor, - sourceRef: sourceRef, - destinationRef: destinationRef, + quotationRef: quotationRef, + createdFrom: createdFrom, + createdTo: createdTo, states: states, ); return page.items; @@ -74,7 +84,9 @@ class PaymentService { String? idempotencyKey, Map? metadata, }) async { - _logger.fine('Executing payment for quotation $quotationRef in $organizationRef'); + _logger.fine( + 'Executing payment for quotation $quotationRef in $organizationRef', + ); final request = InitiatePaymentRequest( idempotencyKey: idempotencyKey ?? Uuid().v4(), quoteRef: quotationRef, @@ -87,5 +99,4 @@ class PaymentService { ); return PaymentResponse.fromJson(response).payment.toDomain(); } - } diff --git a/frontend/pshared/test/payment/payment_state_model_test.dart b/frontend/pshared/test/payment/payment_state_model_test.dart new file mode 100644 index 00000000..f461951a --- /dev/null +++ b/frontend/pshared/test/payment/payment_state_model_test.dart @@ -0,0 +1,122 @@ +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/models/payment/state.dart'; +import 'package:test/test.dart'; + +void main() { + group('PaymentOrchestrationState parser', () { + test('maps v2 orchestration states', () { + expect( + paymentOrchestrationStateFromValue('orchestration_state_created'), + PaymentOrchestrationState.created, + ); + expect( + paymentOrchestrationStateFromValue('ORCHESTRATION_STATE_EXECUTING'), + PaymentOrchestrationState.executing, + ); + expect( + paymentOrchestrationStateFromValue( + 'orchestration_state_needs_attention', + ), + PaymentOrchestrationState.needsAttention, + ); + expect( + paymentOrchestrationStateFromValue('orchestration_state_settled'), + PaymentOrchestrationState.settled, + ); + expect( + paymentOrchestrationStateFromValue('orchestration_state_failed'), + PaymentOrchestrationState.failed, + ); + }); + + test('maps legacy payment states for compatibility', () { + expect( + paymentOrchestrationStateFromValue('payment_state_accepted'), + PaymentOrchestrationState.created, + ); + expect( + paymentOrchestrationStateFromValue('payment_state_submitted'), + PaymentOrchestrationState.executing, + ); + expect( + paymentOrchestrationStateFromValue('payment_state_settled'), + PaymentOrchestrationState.settled, + ); + expect( + paymentOrchestrationStateFromValue('payment_state_cancelled'), + PaymentOrchestrationState.failed, + ); + }); + + test('unknown state maps to unspecified', () { + expect( + paymentOrchestrationStateFromValue('something_else'), + PaymentOrchestrationState.unspecified, + ); + expect( + paymentOrchestrationStateFromValue(null), + PaymentOrchestrationState.unspecified, + ); + }); + }); + + group('Payment model state helpers', () { + test('isPending and isTerminal are derived from typed state', () { + const created = Payment( + paymentRef: 'p-1', + idempotencyKey: 'idem-1', + state: 'orchestration_state_created', + orchestrationState: PaymentOrchestrationState.created, + failureCode: null, + failureReason: null, + lastQuote: null, + metadata: null, + createdAt: null, + ); + const settled = Payment( + paymentRef: 'p-2', + idempotencyKey: 'idem-2', + state: 'orchestration_state_settled', + orchestrationState: PaymentOrchestrationState.settled, + failureCode: null, + failureReason: null, + lastQuote: null, + metadata: null, + createdAt: null, + ); + + expect(created.isPending, isTrue); + expect(created.isTerminal, isFalse); + expect(settled.isPending, isFalse); + expect(settled.isTerminal, isTrue); + }); + + test('isFailure handles both explicit code and failed state', () { + const withFailureCode = Payment( + paymentRef: 'p-3', + idempotencyKey: 'idem-3', + state: 'orchestration_state_executing', + orchestrationState: PaymentOrchestrationState.executing, + failureCode: 'failure_ledger', + failureReason: 'ledger failed', + lastQuote: null, + metadata: null, + createdAt: null, + ); + const failedState = Payment( + paymentRef: 'p-4', + idempotencyKey: 'idem-4', + state: 'orchestration_state_failed', + orchestrationState: PaymentOrchestrationState.failed, + failureCode: null, + failureReason: null, + lastQuote: null, + metadata: null, + createdAt: null, + ); + + expect(withFailureCode.isFailure, isTrue); + expect(failedState.isFailure, isTrue); + }); + }); +} diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart new file mode 100644 index 00000000..8dae1812 --- /dev/null +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; + +import 'package:pshared/api/requests/payment/initiate.dart'; +import 'package:pshared/api/requests/payment/initiate_payments.dart'; +import 'package:pshared/api/requests/payment/quote.dart'; +import 'package:pshared/data/dto/money.dart'; +import 'package:pshared/data/dto/payment/endpoint.dart'; +import 'package:pshared/data/dto/payment/intent/payment.dart'; +import 'package:pshared/data/mapper/payment/payment.dart'; +import 'package:pshared/models/payment/asset.dart'; +import 'package:pshared/models/payment/chain_network.dart'; +import 'package:pshared/models/payment/methods/card_token.dart'; +import 'package:pshared/models/payment/methods/crypto_address.dart'; +import 'package:pshared/models/payment/methods/managed_wallet.dart'; + +void main() { + group('Payment request DTO contract', () { + test('serializes endpoint types to backend canonical values', () { + final managed = ManagedWalletPaymentMethod( + managedWalletRef: 'mw-1', + ).toDTO(); + final external = CryptoAddressPaymentMethod( + asset: const PaymentAsset( + chain: ChainNetwork.tronMainnet, + tokenSymbol: 'USDT', + ), + address: 'TXYZ', + ).toDTO(); + final cardToken = CardTokenPaymentMethod( + token: 'tok_1', + maskedPan: '4111', + ).toDTO(); + + expect(managed.type, equals('managedWallet')); + expect(external.type, equals('cryptoAddress')); + expect(cardToken.type, equals('cardToken')); + }); + + test('quote payment request uses expected backend field names', () { + final request = QuotePaymentRequest( + idempotencyKey: 'idem-1', + previewOnly: true, + intent: const PaymentIntentDTO( + kind: 'payout', + source: PaymentEndpointDTO( + type: 'ledger', + data: {'ledger_account_ref': 'ledger:src'}, + ), + destination: PaymentEndpointDTO( + type: 'cardToken', + data: {'token': 'tok_1', 'masked_pan': '4111'}, + ), + amount: MoneyDTO(amount: '10', currency: 'USD'), + settlementMode: 'fix_received', + settlementCurrency: 'USD', + ), + ); + + final json = + jsonDecode(jsonEncode(request.toJson())) as Map; + + expect(json['idempotencyKey'], equals('idem-1')); + expect(json['previewOnly'], isTrue); + expect(json['intent'], isA>()); + + final intent = json['intent'] as Map; + expect(intent['kind'], equals('payout')); + expect(intent['settlement_mode'], equals('fix_received')); + expect(intent['settlement_currency'], equals('USD')); + + final source = intent['source'] as Map; + final destination = intent['destination'] as Map; + expect(source['type'], equals('ledger')); + expect(destination['type'], equals('cardToken')); + }); + + test('initiate payment by quote keeps expected fields', () { + final request = InitiatePaymentRequest( + idempotencyKey: 'idem-2', + quoteRef: 'q-1', + metadata: const {'intent_ref': 'intent-1'}, + ); + + final json = request.toJson(); + expect(json['idempotencyKey'], equals('idem-2')); + expect(json['quoteRef'], equals('q-1')); + expect( + (json['metadata'] as Map)['intent_ref'], + equals('intent-1'), + ); + expect(json.containsKey('intent'), isTrue); + expect(json['intent'], isNull); + }); + + test('initiate multi payments request keeps expected fields', () { + final request = InitiatePaymentsRequest( + idempotencyKey: 'idem-3', + quoteRef: 'q-2', + metadata: const {'client_payment_ref': 'cp-1'}, + ); + + final json = request.toJson(); + expect(json['idempotencyKey'], equals('idem-3')); + expect(json['quoteRef'], equals('q-2')); + expect( + (json['metadata'] as Map)['client_payment_ref'], + equals('cp-1'), + ); + }); + }); +} diff --git a/frontend/pweb/test/widget_test.dart b/frontend/pweb/test/widget_test.dart index 045c8617..faff1a83 100644 --- a/frontend/pweb/test/widget_test.dart +++ b/frontend/pweb/test/widget_test.dart @@ -1,30 +1,33 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; import 'package:pweb/app/app.dart'; +import 'package:pweb/providers/account.dart'; +import 'package:pweb/providers/locale.dart'; +import 'package:pshared/provider/account.dart'; +import 'package:pshared/provider/locale.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const PayApp()); + testWidgets('PayApp builds with required providers', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => PwebLocaleProvider(null), + ), + ChangeNotifierProxyProvider( + create: (_) => PwebAccountProvider(), + update: (context, localeProvider, provider) => + provider!..updateProvider(localeProvider), + ), + ], + child: const PayApp(), + ), + ); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.byType(PayApp), findsOneWidget); }); } -- 2.49.1 From 2fe90347a8ef5596bbbba55ee1f063b33d70e9a3 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 24 Feb 2026 16:14:09 +0100 Subject: [PATCH 02/23] quotation service fixed --- .woodpecker/gateway_chain.yml | 1 + .woodpecker/gateway_mntx.yml | 1 + .woodpecker/gateway_tgsettle.yml | 1 + .woodpecker/gateway_tron.yml | 1 + .../internal/server/internal/serverimp.go | 2 +- .../mntx/internal/service/gateway/service.go | 4 +- api/gateway/tgsettle/config.dev.yml | 2 +- api/gateway/tgsettle/config.yml | 2 +- .../internal/server/internal/serverimp.go | 5 + .../internal/service/gateway/service.go | 12 +- .../server/internal/discovery_clients.go | 2 +- .../service/orchestrationv2/agg/module.go | 7 +- .../orchestrationv2/agg/service_test.go | 4 +- .../service/orchestrationv2/erecon/module.go | 7 +- .../orchestrationv2/erecon/service_test.go | 15 +-- .../service/orchestrationv2/idem/module.go | 7 +- .../service/orchestrationv2/oobs/service.go | 6 +- .../orchestrationv2/opagg/fixtures_test.go | 2 +- .../service/orchestrationv2/opagg/module.go | 7 +- .../service/orchestrationv2/ostate/module.go | 7 +- .../service/orchestrationv2/pquery/service.go | 6 +- .../service/orchestrationv2/prepo/service.go | 3 + .../service/orchestrationv2/prmap/module.go | 7 +- .../orchestrationv2/prmap/service_test.go | 4 +- .../orchestrationv2/prmap/state_mapping.go | 10 +- .../service/orchestrationv2/psvc/service.go | 7 +- .../orchestrationv2/psvc/service_e2e_test.go | 2 +- .../service/orchestrationv2/qsnap/module.go | 7 +- .../qsnap/resolve_errors_test.go | 17 +-- .../qsnap/resolve_shapes_test.go | 21 +--- .../service/orchestrationv2/reqval/module.go | 7 +- .../service/orchestrationv2/sexec/module.go | 9 +- .../service/orchestrationv2/sexec/routes.go | 58 ++------- .../service/orchestrationv2/sexec/service.go | 4 +- .../orchestrationv2/sexec/service_test.go | 19 ++- .../service/orchestrationv2/ssched/module.go | 9 +- .../xplan/compile_flow_test.go | 66 +++++++++- .../xplan/compile_policy_test.go | 6 +- .../orchestrationv2/xplan/expansion.go | 4 +- .../service/orchestrationv2/xplan/module.go | 7 +- .../service/orchestrationv2/xplan/route.go | 25 +--- .../xplan/service_boundaries.go | 18 ++- .../orchestrationv2/xplan/service_policy.go | 6 +- api/payments/orchestrator/main.go | 2 +- .../quotation/graph_path_finder/adjacency.go | 4 +- .../graph_path_finder/service_network_test.go | 8 +- .../graph_path_finder/service_test.go | 6 +- .../quote_computation_service/compute_test.go | 38 +++++- .../planner_path_finding.go | 7 +- .../planner_path_finding_test.go | 2 +- .../planner_steps.go | 23 +++- .../quote_binding_validation.go | 4 + .../quote_response_mapper_v2/service_test.go | 2 +- api/payments/storage/model/payment.go | 7 +- api/payments/storage/model/plan_template.go | 6 +- api/payments/storage/model/rails.go | 62 ++++++++++ api/payments/storage/model/rails_test.go | 28 +++++ api/payments/storage/model/route.go | 4 +- .../storage/mongo/store/plan_templates.go | 14 ++- api/payments/storage/mongo/store/routes.go | 49 +++++++- api/pkg/discovery/gatewayid_test.go | 4 +- api/pkg/discovery/rail_vocab.go | 43 ++++++- api/pkg/discovery/rail_vocab_test.go | 12 ++ api/pkg/messaging/internal/natsb/broker.go | 114 ++++++++++++++---- .../messaging/internal/natsb/broker_test.go | 52 +++++++- api/proto/common/gateway/v1/gateway.proto | 7 +- api/server/go.sum | 4 +- ci/dev/chain-gateway.dockerfile | 2 + ci/dev/mntx-gateway.dockerfile | 2 + ci/dev/tgsettle-gateway.dockerfile | 2 + ci/dev/tron-gateway.dockerfile | 2 + ci/scripts/chain_gateway/build-image.sh | 17 +++ ci/scripts/mntx/build-image.sh | 17 +++ ci/scripts/tgsettle/build-image.sh | 17 +++ ci/scripts/tron_gateway/build-image.sh | 17 +++ docker-compose.dev.yml | 4 + 76 files changed, 769 insertions(+), 230 deletions(-) create mode 100644 api/payments/storage/model/rails.go create mode 100644 api/payments/storage/model/rails_test.go diff --git a/.woodpecker/gateway_chain.yml b/.woodpecker/gateway_chain.yml index aa13ae7b..5ed26a66 100644 --- a/.woodpecker/gateway_chain.yml +++ b/.woodpecker/gateway_chain.yml @@ -14,6 +14,7 @@ when: path: include: - api/gateway/chain/** + - api/gateway/common/** - api/proto/** - api/pkg/** ignore_message: '[rebuild]' diff --git a/.woodpecker/gateway_mntx.yml b/.woodpecker/gateway_mntx.yml index bc7ba934..c680a1cf 100644 --- a/.woodpecker/gateway_mntx.yml +++ b/.woodpecker/gateway_mntx.yml @@ -13,6 +13,7 @@ when: path: include: - api/gateway/mntx/** + - api/gateway/common/** - api/proto/** - api/pkg/** ignore_message: '[rebuild]' diff --git a/.woodpecker/gateway_tgsettle.yml b/.woodpecker/gateway_tgsettle.yml index 63db1e1a..8a512f6f 100644 --- a/.woodpecker/gateway_tgsettle.yml +++ b/.woodpecker/gateway_tgsettle.yml @@ -11,6 +11,7 @@ when: path: include: - api/gateway/tgsettle/** + - api/gateway/common/** - api/proto/** - api/pkg/** ignore_message: '[rebuild]' diff --git a/.woodpecker/gateway_tron.yml b/.woodpecker/gateway_tron.yml index 576bc07d..12965950 100644 --- a/.woodpecker/gateway_tron.yml +++ b/.woodpecker/gateway_tron.yml @@ -14,6 +14,7 @@ when: path: include: - api/gateway/tron/** + - api/gateway/common/** - api/proto/** - api/pkg/** ignore_message: '[rebuild]' diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go index 1a799ff6..70f133d9 100644 --- a/api/gateway/mntx/internal/server/internal/serverimp.go +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -333,7 +333,7 @@ func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gat return &gatewayv1.GatewayInstanceDescriptor{ Id: id, - Rail: gatewayv1.Rail_RAIL_CARD_PAYOUT, + Rail: gatewayv1.Rail_RAIL_CARD, Network: network, Currencies: currencies, Capabilities: &gatewayv1.RailCapabilities{ diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index 27abd0ee..fba8ba80 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -151,7 +151,7 @@ func (s *Service) startDiscoveryAnnouncer() { return } announce := discovery.Announcement{ - Service: "CARD_PAYOUT_RAIL_GATEWAY", + Service: "CARD_RAIL_GATEWAY", Rail: discovery.RailCardPayout, Operations: discovery.CardPayoutRailGatewayOperations(), InvokeURI: s.invokeURI, @@ -164,7 +164,7 @@ func (s *Service) startDiscoveryAnnouncer() { announce.Currencies = currenciesFromDescriptor(s.gatewayDescriptor) } if strings.TrimSpace(announce.ID) == "" { - announce.ID = "card_payout_rail_gateway" + announce.ID = discovery.StablePaymentGatewayID(discovery.RailCardPayout) } s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce) s.announcer.Start() diff --git a/api/gateway/tgsettle/config.dev.yml b/api/gateway/tgsettle/config.dev.yml index 20e0d8dd..050b9d5d 100644 --- a/api/gateway/tgsettle/config.dev.yml +++ b/api/gateway/tgsettle/config.dev.yml @@ -36,7 +36,7 @@ messaging: buffer_size: 1024 gateway: - rail: "provider_settlement" + rail: "SETTLEMENT" target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID timeout_seconds: 345600 accepted_user_ids: [] diff --git a/api/gateway/tgsettle/config.yml b/api/gateway/tgsettle/config.yml index cde9320a..7e736332 100644 --- a/api/gateway/tgsettle/config.yml +++ b/api/gateway/tgsettle/config.yml @@ -36,7 +36,7 @@ messaging: buffer_size: 1024 gateway: - rail: "provider_settlement" + rail: "SETTLEMENT" target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID timeout_seconds: 345600 accepted_user_ids: [] diff --git a/api/gateway/tgsettle/internal/server/internal/serverimp.go b/api/gateway/tgsettle/internal/server/internal/serverimp.go index 686c6cdf..2cfe94f2 100644 --- a/api/gateway/tgsettle/internal/server/internal/serverimp.go +++ b/api/gateway/tgsettle/internal/server/internal/serverimp.go @@ -10,6 +10,7 @@ import ( gatewaymongo "github.com/tech/sendico/gateway/tgsettle/storage/mongo" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" mb "github.com/tech/sendico/pkg/messaging/broker" @@ -141,8 +142,12 @@ func (i *Imp) loadConfig() (*config, error) { if cfg.Metrics == nil { cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"} } + cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail) if cfg.Gateway.Rail == "" { return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail") } + if !discovery.IsKnownRail(cfg.Gateway.Rail) { + return nil, merrors.InvalidArgument("gateway rail must be a known token", "gateway.rail") + } return cfg, nil } diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index ba58185f..2a63a075 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -95,9 +95,12 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro broker: broker, cfg: cfg, msgCfg: cfg.MessagingSettings, - rail: strings.TrimSpace(cfg.Rail), + rail: discovery.NormalizeRail(cfg.Rail), invokeURI: strings.TrimSpace(cfg.InvokeURI), } + if svc.rail == "" { + svc.rail = strings.TrimSpace(cfg.Rail) + } svc.chatID = strings.TrimSpace(readEnv(cfg.TargetChatIDEnv)) svc.successReaction = strings.TrimSpace(cfg.SuccessReaction) if svc.successReaction == "" { @@ -526,11 +529,12 @@ func (s *Service) startAnnouncer() { if s == nil || s.producer == nil { return } - caps := discovery.CardPayoutRailGatewayOperations() + rail := discovery.RailProviderSettlement + caps := discovery.ProviderSettlementRailGatewayOperations() announce := discovery.Announcement{ - ID: discovery.StablePaymentGatewayID(discovery.NormalizeRail(s.rail)), + ID: discovery.StablePaymentGatewayID(rail), Service: string(mservice.PaymentGateway), - Rail: discovery.NormalizeRail(s.rail), + Rail: rail, Operations: caps, InvokeURI: s.invokeURI, } diff --git a/api/payments/orchestrator/internal/server/internal/discovery_clients.go b/api/payments/orchestrator/internal/server/internal/discovery_clients.go index 78e88e82..317ac9dd 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_clients.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_clients.go @@ -31,7 +31,7 @@ var ( feesServiceNames = []string{"BILLING_FEES", string(mservice.FeePlans)} ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} - mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)} + mntxServiceNames = []string{"CARD_RAIL_GATEWAY", string(mservice.MntxGateway)} ) type discoveryEndpoint struct { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go index 4bae38aa..ce2460ab 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" pm "github.com/tech/sendico/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) // Factory builds initial orchestration-v2 payment aggregates. @@ -102,8 +103,12 @@ func New(deps ...Dependencies) Factory { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } return &svc{ - logger: dep.Logger.Named("agg"), + logger: logger.Named("agg"), now: func() time.Time { return time.Now().UTC() }, newID: func() bson.ObjectID { return bson.NewObjectID() diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go index 068a3530..7045157e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func TestCreate_OK(t *testing.T) { @@ -17,7 +18,8 @@ func TestCreate_OK(t *testing.T) { paymentID := bson.NewObjectID() factory := &svc{ - now: func() time.Time { return now }, + logger: zap.NewNop(), + now: func() time.Time { return now }, newID: func() bson.ObjectID { return paymentID }, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go index 06944f35..5edb5dcd 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // Reconciler applies external async events to payment runtime state. @@ -130,12 +131,16 @@ func New(deps ...Dependencies) Reconciler { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } now := dep.Now if now == nil { now = defaultNow } return &svc{ - logger: dep.Logger.Named("erecon"), + logger: logger.Named("erecon"), now: now, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go index f9ba4c26..34b4c437 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go @@ -7,11 +7,12 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" ) func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) { now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) - reconciler := &svc{now: func() time.Time { return now }} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) in := &agg.Payment{ PaymentRef: "p1", @@ -74,7 +75,7 @@ func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) { func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) { now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) - reconciler := &svc{now: func() time.Time { return now }} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := reconciler.Reconcile(Input{ Payment: &agg.Payment{ @@ -110,7 +111,7 @@ func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) { func TestReconcile_GatewayFailureMapping(t *testing.T) { now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) - reconciler := &svc{now: func() time.Time { return now }} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) retryable := true out, err := reconciler.Reconcile(Input{ @@ -178,7 +179,7 @@ func TestReconcile_GatewayFailureMapping(t *testing.T) { func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) { now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) - reconciler := &svc{now: func() time.Time { return now }} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := reconciler.Reconcile(Input{ Payment: &agg.Payment{ @@ -212,7 +213,7 @@ func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) { } func TestReconcile_CardMatchByExternalRef(t *testing.T) { - reconciler := &svc{now: defaultNow} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow}) out, err := reconciler.Reconcile(Input{ Payment: &agg.Payment{ @@ -246,7 +247,7 @@ func TestReconcile_CardMatchByExternalRef(t *testing.T) { } func TestReconcile_MatchingErrors(t *testing.T) { - reconciler := &svc{now: defaultNow} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow}) _, err := reconciler.Reconcile(Input{ Payment: &agg.Payment{ @@ -293,7 +294,7 @@ func TestReconcile_MatchingErrors(t *testing.T) { } func TestReconcile_ValidationErrors(t *testing.T) { - reconciler := &svc{now: defaultNow} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow}) tests := []struct { name string diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go index fd924ebb..6add1c20 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) // Store is the minimal payment store contract required for idempotency handling. @@ -52,5 +53,9 @@ func New(deps ...Dependencies) Service { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("idem")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("idem")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go index 9a89e9b3..af1d5df5 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go @@ -25,7 +25,11 @@ func newService(deps Dependencies) (Observer, error) { store = newMemoryAuditStore() } - logger := deps.Logger.Named("oobs") + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("oobs") metrics := deps.Metrics if metrics == nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go index 25ea0e7a..8fd9bab7 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go @@ -101,7 +101,7 @@ func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuo Hops: []*paymenttypes.QuoteRouteHop{ {Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, {Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"}, - {Index: 3, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"}, + {Index: 3, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"}, }, Settlement: &paymenttypes.QuoteRouteSettlement{ Model: "fix_source", diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go index 2e05f5e8..1620f5a6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go @@ -3,6 +3,7 @@ package opagg import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // Aggregator compacts compatible quote items into recipient-level execution groups. @@ -45,5 +46,9 @@ func New(deps ...Dependencies) Aggregator { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("opagg")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("opagg")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go index 07fb7d9f..6da7736a 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go @@ -4,6 +4,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // StateMachine is the single source of truth for orchestration-v2 state transitions. @@ -26,5 +27,9 @@ func New(deps ...Dependencies) StateMachine { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("ostate")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("ostate")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go index 246e418d..63b2dd69 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go @@ -48,8 +48,12 @@ func newService(deps Dependencies) (Service, error) { if deps.Repository == nil { return nil, merrors.InvalidArgument("payment repository v2 is required") } + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } return &svc{ - logger: deps.Logger.Named("pquery"), + logger: logger.Named("pquery"), repo: deps.Repository, }, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go index 2711368a..50d2f4e0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go @@ -49,6 +49,9 @@ func newWithStoreLogger(store paymentStore, logger mlogger.Logger) (Repository, if store == nil { return nil, merrors.InvalidArgument("payment repository v2: store is required") } + if logger == nil { + logger = zap.NewNop() + } if err := store.EnsureIndexes(requiredIndexes()); err != nil { return nil, err } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go index 7a20c2fe..3bd975b6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" ) // Mapper transforms orchestration-v2 runtime aggregate snapshots into API responses. @@ -32,5 +33,9 @@ func New(deps ...Dependencies) Mapper { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("prmap")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("prmap")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go index 2442611a..fd7b8758 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -305,7 +305,7 @@ func newPaymentFixture() *agg.Payment { Firm: true, }, Route: &paymenttypes.QuoteRouteSpecification{ - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "provider-1", PayoutMethod: "CARD", Network: "VISA", @@ -326,7 +326,7 @@ func newPaymentFixture() *agg.Payment { }, { Index: 20, - Rail: "CARD_PAYOUT", + Rail: "CARD", Gateway: "gw-card", InstanceID: "card-1", Role: paymenttypes.QuoteRouteHopRoleDestination, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go index 96cb5ba3..fe5530ee 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go @@ -107,11 +107,13 @@ func inferRail(kind string, stepCode string) gatewayv1.Rail { case strings.Contains(all, "ledger"): return gatewayv1.Rail_RAIL_LEDGER case strings.Contains(all, "card_payout"), strings.Contains(all, "card"): - return gatewayv1.Rail_RAIL_CARD_PAYOUT - case strings.Contains(all, "provider_settlement"), strings.Contains(all, "provider"): - return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT + return gatewayv1.Rail_RAIL_CARD + case strings.Contains(all, "provider_settlement"), + strings.Contains(all, "settlement"), + strings.Contains(all, "provider"): + return gatewayv1.Rail_RAIL_SETTLEMENT case strings.Contains(all, "fiat_onramp"), strings.Contains(all, "onramp"): - return gatewayv1.Rail_RAIL_FIAT_ONRAMP + return gatewayv1.Rail_RAIL_ONRAMP case strings.Contains(all, "crypto"), strings.Contains(all, "chain"), strings.Contains(all, "tx"): return gatewayv1.Rail_RAIL_CRYPTO default: diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go index 8089611f..9ed79e86 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go @@ -19,6 +19,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) const ( @@ -57,7 +58,11 @@ func newService(deps Dependencies) (Service, error) { return nil, merrors.InvalidArgument("payment repository v2 is required") } - logger := deps.Logger.Named("psvc") + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("psvc") observer := deps.Observer if observer == nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 02b1efa5..00b4770f 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -600,7 +600,7 @@ func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification { func buildCardRoute() *paymenttypes.QuoteRouteSpecification { return &paymenttypes.QuoteRouteSpecification{ - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "gw-card", Network: "visa", } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index 3b9082f0..12cc4d6d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) // Store is the minimal quote store contract required by the resolver. @@ -45,12 +46,16 @@ func New(deps ...Dependencies) Resolver { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } now := dep.Now if now == nil { now = time.Now } return &svc{ - logger: dep.Logger.Named("qsnap"), + logger: logger.Named("qsnap"), now: now, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go index 1ce56463..5fda64db 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/payments/storage/model" quotestorage "github.com/tech/sendico/payments/storage/quote" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func TestResolve_NotFound(t *testing.T) { @@ -29,9 +30,7 @@ func TestResolve_NotFound(t *testing.T) { func TestResolve_Expired(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -58,9 +57,7 @@ func TestResolve_Expired(t *testing.T) { func TestResolve_NotExecutableState(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -88,9 +85,7 @@ func TestResolve_NotExecutableState(t *testing.T) { func TestResolve_NotExecutableExecutionNote(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -115,9 +110,7 @@ func TestResolve_NotExecutableExecutionNote(t *testing.T) { func TestResolve_ShapeMismatch(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go index 5341566b..8afa5cf6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/payments/storage/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func TestResolve_SingleShapeOK(t *testing.T) { @@ -30,9 +31,7 @@ func TestResolve_SingleShapeOK(t *testing.T) { ExpiresAt: now.Add(time.Minute), } - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -89,9 +88,7 @@ func TestResolve_ArrayShapeOK(t *testing.T) { ExpiresAt: now.Add(time.Minute), } - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -142,9 +139,7 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) { ExpiresAt: now.Add(time.Minute), } - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -177,9 +172,7 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) { func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -211,9 +204,7 @@ func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) { func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go index 2bef45c4..95b6cf2c 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go @@ -3,6 +3,7 @@ package reqval import ( "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) // Validator validates execute-payment inputs and returns a normalized context. @@ -50,5 +51,9 @@ func New(deps ...Dependencies) Validator { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("reqval")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("reqval")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go index f7efc8ba..8b21229f 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // Registry dispatches orchestration steps to rail/action-specific executors. @@ -44,7 +45,7 @@ type CryptoExecutor interface { ExecuteCrypto(ctx context.Context, req StepRequest) (*ExecuteOutput, error) } -// ProviderSettlementExecutor handles provider settlement SEND actions. +// ProviderSettlementExecutor handles settlement FX_CONVERT actions. type ProviderSettlementExecutor interface { ExecuteProviderSettlement(ctx context.Context, req StepRequest) (*ExecuteOutput, error) } @@ -70,8 +71,12 @@ type Dependencies struct { } func New(deps Dependencies) Registry { + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } return &svc{ - logger: deps.Logger.Named("sexec"), + logger: logger.Named("sexec"), deps: deps, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go index a05a0018..2f526a5d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go @@ -1,8 +1,6 @@ package sexec import ( - "strings" - "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/payments/storage/model" ) @@ -29,13 +27,20 @@ func classifyRoute(step xplan.Step) route { switch rail { case model.RailCrypto: return routeCrypto - case model.RailProviderSettlement: - return routeProviderSettlement case model.RailCardPayout: return routeCardPayout default: return routeUnknown } + case model.RailOperationFXConvert: + switch rail { + case model.RailProviderSettlement: + return routeProviderSettlement + case model.RailLedger: + return routeLedger + default: + return routeUnknown + } case model.RailOperationFee: if rail == model.RailCrypto { return routeCrypto @@ -57,8 +62,7 @@ func isLedgerAction(action model.RailOperation) bool { model.RailOperationExternalCredit, model.RailOperationMove, model.RailOperationBlock, - model.RailOperationRelease, - model.RailOperationFXConvert: + model.RailOperationRelease: return true default: return false @@ -66,47 +70,9 @@ func isLedgerAction(action model.RailOperation) bool { } func normalizeAction(action model.RailOperation) model.RailOperation { - switch strings.ToUpper(strings.TrimSpace(string(action))) { - case string(model.RailOperationDebit): - return model.RailOperationDebit - case string(model.RailOperationCredit): - return model.RailOperationCredit - case string(model.RailOperationExternalDebit): - return model.RailOperationExternalDebit - case string(model.RailOperationExternalCredit): - return model.RailOperationExternalCredit - case string(model.RailOperationMove): - return model.RailOperationMove - case string(model.RailOperationSend): - return model.RailOperationSend - case string(model.RailOperationFee): - return model.RailOperationFee - case string(model.RailOperationObserveConfirm): - return model.RailOperationObserveConfirm - case string(model.RailOperationFXConvert): - return model.RailOperationFXConvert - case string(model.RailOperationBlock): - return model.RailOperationBlock - case string(model.RailOperationRelease): - return model.RailOperationRelease - default: - return model.RailOperationUnspecified - } + return model.ParseRailOperation(string(action)) } func normalizeRail(rail model.Rail) model.Rail { - switch strings.ToUpper(strings.TrimSpace(string(rail))) { - 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 - } + return model.ParseRail(string(rail)) } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go index d70b7bf5..9a8130af 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go @@ -63,13 +63,13 @@ func (s *svc) Execute(ctx context.Context, in ExecuteInput) (out *ExecuteOutput, return out, err case routeProviderSettlement: if s.deps.ProviderSettlement == nil { - return nil, missingExecutorError("provider_settlement") + return nil, missingExecutorError("settlement") } out, err = s.deps.ProviderSettlement.ExecuteProviderSettlement(ctx, req) return out, err case routeCardPayout: if s.deps.CardPayout == nil { - return nil, missingExecutorError("card_payout") + return nil, missingExecutorError("card") } out, err = s.deps.CardPayout.ExecuteCardPayout(ctx, req) return out, err diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go index 0d370171..3f85f087 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go @@ -37,7 +37,7 @@ func TestExecute_DispatchLedger(t *testing.T) { } } -func TestExecute_DispatchSendRailsAndObserve(t *testing.T) { +func TestExecute_DispatchRailsAndObserve(t *testing.T) { crypto := &fakeCryptoExecutor{} provider := &fakeProviderSettlementExecutor{} card := &fakeCardPayoutExecutor{} @@ -67,9 +67,9 @@ func TestExecute_DispatchSendRailsAndObserve(t *testing.T) { }, }, { - name: "send provider settlement", + name: "fx convert provider settlement", step: xplan.Step{ - StepRef: "s2", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement, + StepRef: "s2", StepCode: "provider.fx_convert", Action: model.RailOperationFXConvert, Rail: model.RailProviderSettlement, }, wantCalls: func(t *testing.T) { t.Helper() @@ -144,6 +144,19 @@ func TestExecute_UnsupportedStep(t *testing.T) { } } +func TestExecute_UnsupportedProviderSettlementSend(t *testing.T) { + registry := New(Dependencies{}) + + _, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{StepRef: "s1", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement}, + StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "provider.send", Attempt: 1}, + }) + if !errors.Is(err, ErrUnsupportedStep) { + t.Fatalf("expected ErrUnsupportedStep, got %v", err) + } +} + func TestExecute_MissingExecutor(t *testing.T) { registry := New(Dependencies{}) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go index 7197e56b..f7c3d1d0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // Runtime selects runnable orchestration steps and reconciles step runtime states. @@ -73,9 +74,13 @@ func New(deps ...Dependencies) Runtime { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } stateMachine := dep.StateMachine if stateMachine == nil { - stateMachine = ostate.New(ostate.Dependencies{Logger: dep.Logger.Named("ssched.ostate")}) + stateMachine = ostate.New(ostate.Dependencies{Logger: logger.Named("ssched.ostate")}) } now := dep.Now if now == nil { @@ -84,7 +89,7 @@ func New(deps ...Dependencies) Runtime { } } return &svc{ - logger: dep.Logger.Named("ssched"), + logger: logger.Named("ssched"), stateMachine: stateMachine, now: now, } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go index 385e8a3e..985229a5 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go @@ -25,7 +25,7 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) { }, { Index: 20, - Rail: "CARD_PAYOUT", + Rail: "CARD", Gateway: "gw-card", InstanceID: "card-1", Role: paymenttypes.QuoteRouteHopRoleDestination, @@ -101,7 +101,7 @@ func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T) Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 20, Rail: "CARD", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, @@ -144,6 +144,64 @@ func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) { assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden) } +func TestCompile_ExternalViaSettlement_UsesFXConvertOnSettlementHop(t *testing.T) { + compiler := New() + + graph, err := compiler.Compile(Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + { + Index: 10, + Rail: "CRYPTO", + Gateway: "gw-crypto", + InstanceID: "crypto-1", + Role: paymenttypes.QuoteRouteHopRoleSource, + }, + { + Index: 20, + Rail: "SETTLEMENT", + Gateway: "gw-settlement", + InstanceID: "settlement-1", + Role: paymenttypes.QuoteRouteHopRoleTransit, + }, + { + Index: 30, + Rail: "CARD", + Gateway: "gw-card", + InstanceID: "card-1", + Role: paymenttypes.QuoteRouteHopRoleDestination, + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Compile returned error: %v", err) + } + + convertCount := 0 + sendCount := 0 + for _, step := range graph.Steps { + if step.Rail != model.RailProviderSettlement { + continue + } + if step.Action == model.RailOperationFXConvert { + convertCount++ + } + if step.Action == model.RailOperationSend { + sendCount++ + } + } + if convertCount == 0 { + t.Fatalf("expected at least one settlement FX_CONVERT step") + } + if sendCount != 0 { + t.Fatalf("expected no settlement SEND steps, got %d", sendCount) + } +} + func TestCompile_InternalToInternal_UsesMove(t *testing.T) { compiler := New() @@ -176,7 +234,7 @@ func TestCompile_GuardsArePrepended(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ @@ -213,7 +271,7 @@ func TestCompile_SingleExternalFallback(t *testing.T) { QuoteSnapshot: &model.PaymentQuoteSnapshot{ Route: &paymenttypes.QuoteRouteSpecification{ RouteRef: "route-summary", - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "gw-card", Network: "visa", }, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go index 1d90930a..8b3a54d2 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go @@ -21,7 +21,7 @@ func TestCompile_PolicyOverrideByRailPair(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, @@ -78,7 +78,7 @@ func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, @@ -185,7 +185,7 @@ func TestCompile_ValidationErrors(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 2, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go index 3efcc9a0..67fdd603 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go @@ -81,7 +81,7 @@ func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy { func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility { switch action { - case model.RailOperationSend, model.RailOperationObserveConfirm: + case model.RailOperationSend, model.RailOperationFXConvert, model.RailOperationObserveConfirm: if role == paymenttypes.QuoteRouteHopRoleDestination { return model.ReportVisibilityUser } @@ -106,6 +106,8 @@ func defaultUserLabel( return "Card payout submitted" } return "Transfer submitted" + case model.RailOperationFXConvert: + return "FX conversion submitted" case model.RailOperationObserveConfirm: if kind == model.PaymentKindPayout && rail == model.RailCardPayout { return "Card payout confirmed" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go index f1295002..8e8686cb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go @@ -4,6 +4,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.uber.org/zap" ) // Compiler builds execution runtime step graph from resolved quote snapshots. @@ -116,7 +117,11 @@ func New(deps ...Dependencies) Compiler { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } return &svc{ - logger: dep.Logger.Named("xplan"), + logger: logger.Named("xplan"), } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go index 81bb0346..c12b29ac 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go @@ -58,6 +58,9 @@ func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string) } func railToken(rail model.Rail) string { + if rail == model.RailCardPayout { + return "card_payout" + } return strings.ToLower(strings.TrimSpace(string(rail))) } @@ -158,25 +161,5 @@ func normalizeHopRole( } func normalizeRail(raw string) model.Rail { - token := strings.ToUpper(strings.TrimSpace(raw)) - token = strings.ReplaceAll(token, "-", "_") - token = strings.ReplaceAll(token, " ", "_") - for strings.Contains(token, "__") { - token = strings.ReplaceAll(token, "__", "_") - } - - switch token { - case "CRYPTO": - return model.RailCrypto - case "PROVIDER_SETTLEMENT", "PROVIDER": - return model.RailProviderSettlement - case "LEDGER": - return model.RailLedger - case "CARD_PAYOUT", "CARD": - return model.RailCardPayout - case "FIAT_ONRAMP", "FIAT_ON_RAMP": - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } + return model.ParseRail(raw) } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go index 1318404b..314a0dd3 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -142,15 +142,16 @@ func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditio } func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { - visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role) + action, op := railActionForHop(hop) + visibility := defaultVisibilityForAction(action, hop.role) userLabel := "" if visibility == model.ReportVisibilityUser { - userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind) + userLabel = defaultUserLabel(action, hop.rail, hop.role, intent.Kind) } return Step{ - StepCode: singleHopCode(hop, "send"), - Kind: StepKindRailSend, - Action: model.RailOperationSend, + StepCode: singleHopCode(hop, op), + Kind: kindForAction(action), + Action: action, Rail: hop.rail, Gateway: hop.gateway, InstanceID: hop.instanceID, @@ -161,6 +162,13 @@ func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { } } +func railActionForHop(hop normalizedHop) (model.RailOperation, string) { + if hop.rail == model.RailProviderSettlement { + return model.RailOperationFXConvert, "fx_convert" + } + return model.RailOperationSend, "send" +} + func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step { visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role) userLabel := "" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go index f0ba594c..a9da65f4 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go @@ -199,7 +199,7 @@ func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalize } switch action { - case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee: + case model.RailOperationSend, model.RailOperationFXConvert, model.RailOperationObserveConfirm, model.RailOperationFee: return to.rail case model.RailOperationBlock, model.RailOperationRelease, @@ -220,7 +220,7 @@ func resolveStepContext( from normalizedHop, to normalizedHop, ) (uint32, paymenttypes.QuoteRouteHopRole, string, string) { - if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) { + if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationFXConvert || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) { return to.index, to.role, to.gateway, to.instanceID } if rail == from.rail { @@ -234,7 +234,7 @@ func resolveStepContext( func kindForAction(action model.RailOperation) StepKind { switch action { - case model.RailOperationSend: + case model.RailOperationSend, model.RailOperationFXConvert: return StepKindRailSend case model.RailOperationObserveConfirm: return StepKindRailObserve diff --git a/api/payments/orchestrator/main.go b/api/payments/orchestrator/main.go index 0f188098..655b1b7d 100644 --- a/api/payments/orchestrator/main.go +++ b/api/payments/orchestrator/main.go @@ -41,7 +41,7 @@ step_scheduler_runtime Pick runnable steps, manage dependency checks, retries/attempts, and mark blocked/skipped. step_executor_registry -Rail/action executors (ledger, crypto, provider_settlement, card_payout, observe_confirm) behind interfaces. +Rail/action executors (ledger, crypto, settlement, card, observe_confirm) behind interfaces. external_event_reconciler Consume async gateway/ledger/card events, map to step updates, append external refs, advance aggregate state. diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go index a3dc12c5..278be422 100644 --- a/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go @@ -64,8 +64,8 @@ func networkPriority(edgeNetwork, requested string) int { } func normalizeRail(value model.Rail) model.Rail { - normalized := model.Rail(strings.ToUpper(strings.TrimSpace(string(value)))) - if normalized == "" { + normalized := model.ParseRail(string(value)) + if normalized == model.RailUnspecified { return model.RailUnspecified } return normalized diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go index cd917aa3..fa71e088 100644 --- a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go @@ -23,7 +23,7 @@ func TestFind_NetworkFiltersEdges(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) if got, want := path.Edges[0].Network, "TRON"; got != want { t.Fatalf("unexpected first edge network: got=%q want=%q", got, want) } @@ -47,7 +47,7 @@ func TestFind_PrefersExactNetworkOverWildcard(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "PROVIDER_SETTLEMENT", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "SETTLEMENT", "CARD"}) } func TestFind_DeterministicTieBreak(t *testing.T) { @@ -68,7 +68,7 @@ func TestFind_DeterministicTieBreak(t *testing.T) { } // Both routes have equal length; lexical tie-break chooses LEDGER branch. - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) } func TestFind_IgnoresInvalidEdges(t *testing.T) { @@ -88,5 +88,5 @@ func TestFind_IgnoresInvalidEdges(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) } diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go index 711cb92a..001fd148 100644 --- a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go @@ -62,7 +62,7 @@ func TestFind_FindsIndirectPath(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) if got, want := len(path.Edges), 2; got != want { t.Fatalf("unexpected edge count: got=%d want=%d", got, want) } @@ -84,7 +84,7 @@ func TestFind_PrefersShortestPath(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "CARD"}) } func TestFind_HandlesCycles(t *testing.T) { @@ -103,7 +103,7 @@ func TestFind_HandlesCycles(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) } func TestFind_ReturnsErrorWhenPathUnavailable(t *testing.T) { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go index 045f2f7b..a4db0da1 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go @@ -131,6 +131,42 @@ func TestBuildPlan_RequiresFXAddsMiddleStep(t *testing.T) { } } +func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing.T) { + svc := New(nil) + orgID := bson.NewObjectID() + intent := sampleCryptoToCardQuoteIntent() + intent.RequiresFX = true + intent.SettlementCurrency = "RUB" + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + PreviewOnly: false, + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(planModel.Items) != 1 { + t.Fatalf("expected one plan item") + } + steps := planModel.Items[0].Steps + if got, want := len(steps), 4; got != want { + t.Fatalf("unexpected step count: got=%d want=%d", got, want) + } + last := steps[len(steps)-1] + if last == nil || last.Amount == nil { + t.Fatalf("expected destination step amount") + } + if got, want := strings.TrimSpace(last.Amount.GetCurrency()), "RUB"; got != want { + t.Fatalf("unexpected destination step currency: got=%q want=%q", got, want) + } + if got, want := last.Operation, model.RailOperationSend; got != want { + t.Fatalf("unexpected destination operation: got=%q want=%q", got, want) + } +} + func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) { svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{ items: []*model.GatewayInstanceDescriptor{ @@ -406,7 +442,7 @@ func TestCompute_FailsWhenCoreReturnsDifferentRoute(t *testing.T) { quote: &ComputedQuote{ DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"}, Route: "ationv2.RouteSpecification{ - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "other-provider", PayoutMethod: "CARD", }, diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go index 11797093..5d93f9d8 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go @@ -16,6 +16,9 @@ func (s *QuoteComputationService) resolveRouteRails( destinationRail model.Rail, network string, ) ([]model.Rail, error) { + sourceRail = model.ParseRail(string(sourceRail)) + destinationRail = model.ParseRail(string(destinationRail)) + s.logger.Debug("Resolving route rails", zap.String("source_rail", string(sourceRail)), zap.String("dest_rail", string(destinationRail)), @@ -179,8 +182,8 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_ continue } - from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail)))) - to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail)))) + from := model.ParseRail(string(route.FromRail)) + to := model.ParseRail(string(route.ToRail)) if from == model.RailUnspecified || to == model.RailUnspecified { continue diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go index 16ae2c9d..0acc2868 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go @@ -74,7 +74,7 @@ func TestBuildPlan_UsesRouteGraphPath(t *testing.T) { if got, want := string(item.Steps[1].Rail), string(model.RailProviderSettlement); got != want { t.Fatalf("unexpected transit rail: got=%q want=%q", got, want) } - if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "PROVIDER_SETTLEMENT" { + if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "SETTLEMENT" { t.Fatalf("unexpected route transit hop rail: %q", got) } } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go index f052318a..9b7b490e 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/tech/sendico/payments/storage/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) func buildComputationSteps( @@ -19,6 +20,7 @@ func buildComputationSteps( attrs := intent.Attributes amount := protoMoneyFromModel(intent.Amount) + destinationAmount := destinationStepAmount(intent, amount) sourceRail := sourceRailForIntent(intent) destinationRail := destinationRailForIntent(intent) rails := normalizeRouteRails(sourceRail, destinationRail, routeRails) @@ -101,7 +103,7 @@ func buildComputationSteps( GatewayID: destinationGatewayID, InstanceID: destinationInstanceID, DependsOn: []string{lastStepID}, - Amount: cloneProtoMoney(amount), + Amount: destinationAmount, Optional: false, IncludeInAggregate: true, }) @@ -196,3 +198,22 @@ func destinationOperationForRail(rail model.Rail) model.RailOperation { return model.RailOperationExternalCredit } } + +func destinationStepAmount(intent model.PaymentIntent, sourceAmount *moneyv1.Money) *moneyv1.Money { + amount := cloneProtoMoney(sourceAmount) + if amount == nil { + return nil + } + if !intent.RequiresFX { + return amount + } + + settlementCurrency := strings.ToUpper(strings.TrimSpace(intent.SettlementCurrency)) + if settlementCurrency == "" && intent.FX != nil && intent.FX.Pair != nil { + settlementCurrency = strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote)) + } + if settlementCurrency != "" { + amount.Currency = settlementCurrency + } + return amount +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go index b6d80ffa..e0bfa87d 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go @@ -3,6 +3,7 @@ package quote_computation_service import ( "strings" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) @@ -59,6 +60,9 @@ func normalizeSettlementParts(src *quotationv2.RouteSettlement) (chain, token, c } func normalizeRail(value string) string { + if rail := model.ParseRail(value); rail != model.RailUnspecified { + return string(rail) + } return strings.ToUpper(strings.TrimSpace(value)) } diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go index 40c96e56..c81282b7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -40,7 +40,7 @@ func TestMap_ExecutableQuote(t *testing.T) { Currency: "USD", }, Route: "ationv2.RouteSpecification{ - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "monetix", PayoutMethod: "CARD", Settlement: "ationv2.RouteSettlement{ diff --git a/api/payments/storage/model/payment.go b/api/payments/storage/model/payment.go index 0837e488..6b8d95f1 100644 --- a/api/payments/storage/model/payment.go +++ b/api/payments/storage/model/payment.go @@ -75,10 +75,11 @@ type Rail string const ( RailUnspecified Rail = "UNSPECIFIED" RailCrypto Rail = "CRYPTO" - RailProviderSettlement Rail = "PROVIDER_SETTLEMENT" + RailProviderSettlement Rail = "SETTLEMENT" RailLedger Rail = "LEDGER" - RailCardPayout Rail = "CARD_PAYOUT" - RailFiatOnRamp Rail = "FIAT_ONRAMP" + RailCardPayout Rail = "CARD" + RailFiatOnRamp Rail = "ONRAMP" + RailFiatOffRamp Rail = "OFFRAMP" ) // RailOperation identifies an explicit action within a payment plan. diff --git a/api/payments/storage/model/plan_template.go b/api/payments/storage/model/plan_template.go index b33d7c35..b106ebd4 100644 --- a/api/payments/storage/model/plan_template.go +++ b/api/payments/storage/model/plan_template.go @@ -42,8 +42,8 @@ func (t *PaymentPlanTemplate) Normalize() { if t == nil { return } - t.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.FromRail)))) - t.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.ToRail)))) + t.FromRail = normalizeRail(t.FromRail) + t.ToRail = normalizeRail(t.ToRail) t.Network = strings.ToUpper(strings.TrimSpace(t.Network)) if len(t.Steps) == 0 { return @@ -51,7 +51,7 @@ func (t *PaymentPlanTemplate) Normalize() { for i := range t.Steps { step := &t.Steps[i] step.StepID = strings.TrimSpace(step.StepID) - step.Rail = Rail(strings.ToUpper(strings.TrimSpace(string(step.Rail)))) + step.Rail = normalizeRail(step.Rail) step.Operation = strings.ToLower(strings.TrimSpace(step.Operation)) step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility) step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) diff --git a/api/payments/storage/model/rails.go b/api/payments/storage/model/rails.go new file mode 100644 index 00000000..5619a482 --- /dev/null +++ b/api/payments/storage/model/rails.go @@ -0,0 +1,62 @@ +package model + +import "strings" + +var supportedRails = map[Rail]struct{}{ + RailCrypto: {}, + RailProviderSettlement: {}, + RailLedger: {}, + RailCardPayout: {}, + RailFiatOnRamp: {}, + RailFiatOffRamp: {}, +} + +// ParseRail canonicalizes string values into a Rail token. +func ParseRail(value string) Rail { + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" { + return RailUnspecified + } + clean = strings.ReplaceAll(clean, "-", "_") + clean = strings.ReplaceAll(clean, " ", "_") + for strings.Contains(clean, "__") { + clean = strings.ReplaceAll(clean, "__", "_") + } + + switch clean { + case string(RailCrypto), "RAIL_CRYPTO": + return RailCrypto + case string(RailProviderSettlement), "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT": + return RailProviderSettlement + case string(RailLedger), "RAIL_LEDGER": + return RailLedger + case string(RailCardPayout), "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT": + return RailCardPayout + case string(RailFiatOnRamp), "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP": + return RailFiatOnRamp + case string(RailFiatOffRamp), "FIAT_OFFRAMP", "RAIL_OFFRAMP", "RAIL_FIAT_OFFRAMP": + return RailFiatOffRamp + default: + return RailUnspecified + } +} + +// IsSupportedRail reports whether rail is recognized by payment planning. +func IsSupportedRail(rail Rail) bool { + _, ok := supportedRails[ParseRail(string(rail))] + return ok +} + +func normalizeRail(value Rail) Rail { + parsed := ParseRail(string(value)) + if parsed != RailUnspecified { + return parsed + } + + clean := strings.ToUpper(strings.TrimSpace(string(value))) + if clean == "" { + return RailUnspecified + } + + return Rail(clean) +} diff --git a/api/payments/storage/model/rails_test.go b/api/payments/storage/model/rails_test.go new file mode 100644 index 00000000..5412451e --- /dev/null +++ b/api/payments/storage/model/rails_test.go @@ -0,0 +1,28 @@ +package model + +import "testing" + +func TestParseRail(t *testing.T) { + cases := []struct { + name string + input string + want Rail + }{ + {name: "crypto", input: "crypto", want: RailCrypto}, + {name: "settlement canonical", input: "SETTLEMENT", want: RailProviderSettlement}, + {name: "settlement legacy", input: "provider_settlement", want: RailProviderSettlement}, + {name: "card canonical", input: "card", want: RailCardPayout}, + {name: "card legacy", input: "card_payout", want: RailCardPayout}, + {name: "onramp", input: "fiat_onramp", want: RailFiatOnRamp}, + {name: "offramp", input: "fiat_offramp", want: RailFiatOffRamp}, + {name: "unknown", input: "telegram", want: RailUnspecified}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ParseRail(tc.input); got != tc.want { + t.Fatalf("ParseRail(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} diff --git a/api/payments/storage/model/route.go b/api/payments/storage/model/route.go index 2144072c..63b59e9c 100644 --- a/api/payments/storage/model/route.go +++ b/api/payments/storage/model/route.go @@ -28,8 +28,8 @@ func (r *PaymentRoute) Normalize() { if r == nil { return } - r.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.FromRail)))) - r.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.ToRail)))) + r.FromRail = normalizeRail(r.FromRail) + r.ToRail = normalizeRail(r.ToRail) r.Network = strings.ToUpper(strings.TrimSpace(r.Network)) } diff --git a/api/payments/storage/mongo/store/plan_templates.go b/api/payments/storage/mongo/store/plan_templates.go index cbfb55c1..8b5f7682 100644 --- a/api/payments/storage/mongo/store/plan_templates.go +++ b/api/payments/storage/mongo/store/plan_templates.go @@ -123,6 +123,7 @@ func (p *PlanTemplates) GetByID(ctx context.Context, id bson.ObjectID) (*model.P } return nil, err } + entity.Normalize() return entity, nil } @@ -133,11 +134,15 @@ func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTempl query := repository.Query() - if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" { - query = query.Filter(repository.Field("fromRail"), from) + if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 { + query = query.Filter(repository.Field("fromRail"), from[0]) + } else if len(from) > 1 { + query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...) } - if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" { - query = query.Filter(repository.Field("toRail"), to) + if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 { + query = query.Filter(repository.Field("toRail"), to[0]) + } else if len(to) > 1 { + query = query.In(repository.Field("toRail"), stringSliceToAny(to)...) } if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" { query = query.Filter(repository.Field("network"), network) @@ -152,6 +157,7 @@ func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTempl if err := cur.Decode(item); err != nil { return err } + item.Normalize() templates = append(templates, item) return nil } diff --git a/api/payments/storage/mongo/store/routes.go b/api/payments/storage/mongo/store/routes.go index 49fba147..fe2a8060 100644 --- a/api/payments/storage/mongo/store/routes.go +++ b/api/payments/storage/mongo/store/routes.go @@ -120,6 +120,7 @@ func (r *Routes) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentR } return nil, err } + entity.Normalize() return entity, nil } @@ -130,11 +131,15 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m query := repository.Query() - if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" { - query = query.Filter(repository.Field("fromRail"), from) + if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 { + query = query.Filter(repository.Field("fromRail"), from[0]) + } else if len(from) > 1 { + query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...) } - if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" { - query = query.Filter(repository.Field("toRail"), to) + if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 { + query = query.Filter(repository.Field("toRail"), to[0]) + } else if len(to) > 1 { + query = query.In(repository.Field("toRail"), stringSliceToAny(to)...) } if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" { query = query.Filter(repository.Field("network"), network) @@ -149,6 +154,7 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m if err := cur.Decode(item); err != nil { return err } + item.Normalize() routes = append(routes, item) return nil } @@ -163,3 +169,38 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m } var _ storage.RoutesStore = (*Routes)(nil) + +func normalizedRailFilterValues(rail model.Rail) []string { + clean := strings.ToUpper(strings.TrimSpace(string(rail))) + if clean == "" { + return nil + } + + if parsed := model.ParseRail(string(rail)); parsed != model.RailUnspecified { + switch parsed { + case model.RailCrypto: + return []string{string(model.RailCrypto), "RAIL_CRYPTO"} + case model.RailProviderSettlement: + return []string{string(model.RailProviderSettlement), "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT"} + case model.RailLedger: + return []string{string(model.RailLedger), "RAIL_LEDGER"} + case model.RailCardPayout: + return []string{string(model.RailCardPayout), "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT"} + case model.RailFiatOnRamp: + return []string{string(model.RailFiatOnRamp), "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP"} + case model.RailFiatOffRamp: + return []string{string(model.RailFiatOffRamp), "FIAT_OFFRAMP", "RAIL_OFFRAMP", "RAIL_FIAT_OFFRAMP"} + default: + return []string{string(parsed)} + } + } + return []string{clean} +} + +func stringSliceToAny(values []string) []any { + out := make([]any, 0, len(values)) + for _, value := range values { + out = append(out, value) + } + return out +} diff --git a/api/pkg/discovery/gatewayid_test.go b/api/pkg/discovery/gatewayid_test.go index fd30e453..f0c8162d 100644 --- a/api/pkg/discovery/gatewayid_test.go +++ b/api/pkg/discovery/gatewayid_test.go @@ -10,7 +10,7 @@ func TestStableGatewayID(t *testing.T) { want string }{ {name: "prefix and key", prefix: "crypto_rail_gateway", key: " TRON ", want: "crypto_rail_gateway_tron"}, - {name: "prefix trailing underscore", prefix: "payment_gateway_", key: " PROVIDER_SETTLEMENT ", want: "payment_gateway_provider_settlement"}, + {name: "prefix trailing underscore", prefix: "payment_gateway_", key: " SETTLEMENT ", want: "payment_gateway_settlement"}, {name: "missing key", prefix: "payment_gateway", key: " ", want: "payment_gateway_unknown"}, {name: "missing prefix", prefix: " ", key: "TRON", want: "tron"}, } @@ -35,7 +35,7 @@ func TestStableCryptoRailGatewayID(t *testing.T) { } func TestStablePaymentGatewayID(t *testing.T) { - if got, want := StablePaymentGatewayID(" PROVIDER_SETTLEMENT "), "payment_gateway_provider_settlement"; got != want { + if got, want := StablePaymentGatewayID(" SETTLEMENT "), "payment_gateway_settlement"; got != want { t.Fatalf("unexpected stable id: got=%q want=%q", got, want) } if got, want := StablePaymentGatewayID(""), "payment_gateway_unknown"; got != want { diff --git a/api/pkg/discovery/rail_vocab.go b/api/pkg/discovery/rail_vocab.go index 61163cd7..c0249802 100644 --- a/api/pkg/discovery/rail_vocab.go +++ b/api/pkg/discovery/rail_vocab.go @@ -4,10 +4,10 @@ import "strings" const ( RailCrypto = "CRYPTO" - RailProviderSettlement = "PROVIDER_SETTLEMENT" + RailProviderSettlement = "SETTLEMENT" RailLedger = "LEDGER" - RailCardPayout = "CARD_PAYOUT" - RailFiatOnRamp = "FIAT_ONRAMP" + RailCardPayout = "CARD" + RailFiatOnRamp = "ONRAMP" ) const ( @@ -48,7 +48,30 @@ var knownRailOperations = map[string]struct{}{ // NormalizeRail canonicalizes a rail token. func NormalizeRail(value string) string { - return strings.ToUpper(strings.TrimSpace(value)) + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" { + return "" + } + clean = strings.ReplaceAll(clean, "-", "_") + clean = strings.ReplaceAll(clean, " ", "_") + for strings.Contains(clean, "__") { + clean = strings.ReplaceAll(clean, "__", "_") + } + + switch clean { + case RailCrypto, "RAIL_CRYPTO": + return RailCrypto + case RailProviderSettlement, "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT": + return RailProviderSettlement + case RailLedger, "RAIL_LEDGER": + return RailLedger + case RailCardPayout, "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT": + return RailCardPayout + case RailFiatOnRamp, "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP": + return RailFiatOnRamp + default: + return clean + } } // IsKnownRail reports whether the value is a recognized payment rail. @@ -60,8 +83,8 @@ func IsKnownRail(value string) bool { // NormalizeRailOperation canonicalizes a rail operation token. func NormalizeRailOperation(value string) string { clean := strings.ToUpper(strings.TrimSpace(value)) - if strings.HasPrefix(clean, "RAIL_OPERATION_") { - clean = strings.TrimPrefix(clean, "RAIL_OPERATION_") + if after, ok := strings.CutPrefix(clean, "RAIL_OPERATION_"); ok { + clean = after } return clean } @@ -140,3 +163,11 @@ func CardPayoutRailGatewayOperations() []string { RailOperationObserveConfirm, } } + +// ProviderSettlementRailGatewayOperations returns canonical operations for settlement gateways. +func ProviderSettlementRailGatewayOperations() []string { + return []string{ + RailOperationFXConvert, + RailOperationObserveConfirm, + } +} diff --git a/api/pkg/discovery/rail_vocab_test.go b/api/pkg/discovery/rail_vocab_test.go index 8c5e7d26..7bb0cf4f 100644 --- a/api/pkg/discovery/rail_vocab_test.go +++ b/api/pkg/discovery/rail_vocab_test.go @@ -47,3 +47,15 @@ func TestIsKnownRail(t *testing.T) { t.Fatalf("did not expect telegram rail to be known") } } + +func TestNormalizeRailAliases(t *testing.T) { + if got := NormalizeRail("provider_settlement"); got != RailProviderSettlement { + t.Fatalf("provider_settlement alias mismatch: got=%q want=%q", got, RailProviderSettlement) + } + if got := NormalizeRail("card_payout"); got != RailCardPayout { + t.Fatalf("card_payout alias mismatch: got=%q want=%q", got, RailCardPayout) + } + if got := NormalizeRail("RAIL_SETTLEMENT"); got != RailProviderSettlement { + t.Fatalf("RAIL_SETTLEMENT alias mismatch: got=%q want=%q", got, RailProviderSettlement) + } +} diff --git a/api/pkg/messaging/internal/natsb/broker.go b/api/pkg/messaging/internal/natsb/broker.go index daa7a7d2..b0d88fb5 100644 --- a/api/pkg/messaging/internal/natsb/broker.go +++ b/api/pkg/messaging/internal/natsb/broker.go @@ -34,40 +34,108 @@ type envConfig struct { } const defaultConsumerBufferSize = 1024 +const redactedNATSPassword = "xxxxx" -func sanitizeNATSURL(rawURL string) string { +func buildSafePublishableNATSURL(rawURL string) string { if rawURL == "" { return rawURL } parts := strings.Split(rawURL, ",") - sanitized := make([]string, 0, len(parts)) + safe := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed == "" { continue } - if !strings.Contains(trimmed, "://") { - sanitized = append(sanitized, trimmed) + built, ok := buildSafePublishableNATSEntry(trimmed) + if !ok { + safe = append(safe, trimmed) continue } - - parsed, err := url.Parse(trimmed) - if err != nil { - sanitized = append(sanitized, trimmed) - continue - } - if parsed.User == nil { - sanitized = append(sanitized, trimmed) - continue - } - sanitized = append(sanitized, parsed.Redacted()) + safe = append(safe, built) } - if len(sanitized) == 0 { + if len(safe) == 0 { return strings.TrimSpace(rawURL) } - return strings.Join(sanitized, ",") + return strings.Join(safe, ",") +} + +func buildSafePublishableNATSEntry(raw string) (string, bool) { + parsed, err := url.Parse(raw) + if err == nil && parsed.Host != "" { + safe := &url.URL{ + Scheme: parsed.Scheme, + Host: parsed.Host, + Path: parsed.Path, + RawPath: parsed.RawPath, + RawQuery: parsed.RawQuery, + Fragment: parsed.Fragment, + } + if safe.Scheme == "" { + safe.Scheme = "nats" + } + if parsed.User != nil { + username := parsed.User.Username() + if username == "" { + username = redactedNATSPassword + } + safe.User = url.UserPassword(username, redactedNATSPassword) + } + return safe.String(), true + } + + return buildSafePublishableFromAuthority(raw) +} + +func buildSafePublishableFromAuthority(raw string) (string, bool) { + scheme := "nats" + authorityAndSuffix := raw + if schemeIndex := strings.Index(raw, "://"); schemeIndex >= 0 { + if candidate := strings.TrimSpace(raw[:schemeIndex]); candidate != "" { + scheme = candidate + } + authorityAndSuffix = raw[schemeIndex+3:] + } + + authorityEnd := strings.IndexAny(authorityAndSuffix, "/?#") + if authorityEnd < 0 { + authorityEnd = len(authorityAndSuffix) + } + + authority := authorityAndSuffix[:authorityEnd] + suffix := authorityAndSuffix[authorityEnd:] + atIndex := strings.LastIndex(authority, "@") + hostPort := authority + username := "" + if atIndex >= 0 { + userInfo := authority[:atIndex] + hostPort = authority[atIndex+1:] + if hostPort == "" { + return "", false + } + username = userInfo + if colonIndex := strings.Index(userInfo, ":"); colonIndex >= 0 { + username = userInfo[:colonIndex] + } + if username == "" { + username = redactedNATSPassword + } + } + + if hostPort == "" || strings.ContainsAny(hostPort, " \t\r\n") { + return "", false + } + + safe := &url.URL{ + Scheme: scheme, + Host: hostPort, + } + if username != "" { + safe.User = url.UserPassword(username, redactedNATSPassword) + } + return safe.String() + suffix, true } // loadEnv gathers and validates connection details from environment variables @@ -144,7 +212,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e } natsURL = u.String() } - sanitizedNATSURL := sanitizeNATSURL(natsURL) + publishableNATSURL := buildSafePublishableNATSURL(natsURL) opts := []nats.Option{ nats.Name(settings.NATSName), @@ -156,7 +224,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e zap.String("broker", settings.NATSName), } if conn != nil { - fields = append(fields, zap.String("connected_url", sanitizeNATSURL(conn.ConnectedUrl()))) + fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(conn.ConnectedUrl()))) } if err != nil { fields = append(fields, zap.Error(err)) @@ -168,7 +236,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e zap.String("broker", settings.NATSName), } if conn != nil { - fields = append(fields, zap.String("connected_url", sanitizeNATSURL(conn.ConnectedUrl()))) + fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(conn.ConnectedUrl()))) } l.Info("Reconnected to NATS", fields...) }), @@ -178,7 +246,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e } if conn != nil { if url := conn.ConnectedUrl(); url != "" { - fields = append(fields, zap.String("connected_url", sanitizeNATSURL(url))) + fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(url))) } if err := conn.LastError(); err != nil { fields = append(fields, zap.Error(err)) @@ -208,7 +276,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e } if res.nc, err = nats.Connect(natsURL, opts...); err != nil { - l.Error("Failed to connect to NATS", zap.String("url", sanitizedNATSURL), zap.Error(err)) + l.Error("Failed to connect to NATS", zap.String("url", publishableNATSURL), zap.Error(err)) return nil, err } if res.js, err = res.nc.JetStream(); err != nil { @@ -216,7 +284,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e } logger.Info("Connected to NATS", zap.String("broker", settings.NATSName), - zap.String("url", sanitizedNATSURL)) + zap.String("url", publishableNATSURL)) return res, nil } diff --git a/api/pkg/messaging/internal/natsb/broker_test.go b/api/pkg/messaging/internal/natsb/broker_test.go index a1212363..4152a962 100644 --- a/api/pkg/messaging/internal/natsb/broker_test.go +++ b/api/pkg/messaging/internal/natsb/broker_test.go @@ -5,14 +5,14 @@ import ( "testing" ) -func TestSanitizeNATSURL(t *testing.T) { +func TestBuildSafePublishableNATSURL(t *testing.T) { t.Parallel() t.Run("redacts single URL credentials", func(t *testing.T) { t.Parallel() raw := "nats://alice:supersecret@localhost:4222" - sanitized := sanitizeNATSURL(raw) + sanitized := buildSafePublishableNATSURL(raw) if strings.Contains(sanitized, "supersecret") { t.Fatalf("expected password to be redacted, got %q", sanitized) @@ -22,11 +22,25 @@ func TestSanitizeNATSURL(t *testing.T) { } }) + t.Run("redacts credentials in gateway URL format", func(t *testing.T) { + t.Parallel() + + raw := "nats://dev_nats:nats_password_123@dev-nats:4222" + sanitized := buildSafePublishableNATSURL(raw) + + if strings.Contains(sanitized, "nats_password_123") { + t.Fatalf("expected password to be redacted, got %q", sanitized) + } + if !strings.Contains(sanitized, "dev_nats:xxxxx@dev-nats:4222") { + t.Fatalf("expected sanitized URL with redacted password, got %q", sanitized) + } + }) + t.Run("keeps URL without credentials unchanged", func(t *testing.T) { t.Parallel() raw := "nats://localhost:4222" - sanitized := sanitizeNATSURL(raw) + sanitized := buildSafePublishableNATSURL(raw) if sanitized != raw { t.Fatalf("expected URL without credentials to remain unchanged, got %q", sanitized) } @@ -36,7 +50,7 @@ func TestSanitizeNATSURL(t *testing.T) { t.Parallel() raw := " nats://alice:one@localhost:4222, nats://bob:two@localhost:4223 " - sanitized := sanitizeNATSURL(raw) + sanitized := buildSafePublishableNATSURL(raw) if strings.Contains(sanitized, "one") || strings.Contains(sanitized, "two") { t.Fatalf("expected passwords to be redacted, got %q", sanitized) @@ -50,9 +64,37 @@ func TestSanitizeNATSURL(t *testing.T) { t.Parallel() raw := "not a url" - sanitized := sanitizeNATSURL(raw) + sanitized := buildSafePublishableNATSURL(raw) if sanitized != raw { t.Fatalf("expected invalid URL to remain unchanged, got %q", sanitized) } }) + + t.Run("redacts malformed URL credentials via fallback", func(t *testing.T) { + t.Parallel() + + raw := "nats://alice:pa%ss@localhost:4222" + sanitized := buildSafePublishableNATSURL(raw) + + if strings.Contains(sanitized, "pa%ss") { + t.Fatalf("expected malformed password to be redacted, got %q", sanitized) + } + if !strings.Contains(sanitized, "alice:xxxxx@localhost:4222") { + t.Fatalf("expected fallback redaction to preserve host and username, got %q", sanitized) + } + }) + + t.Run("redacts URL without scheme when user info is present", func(t *testing.T) { + t.Parallel() + + raw := "alice:topsecret@localhost:4222" + sanitized := buildSafePublishableNATSURL(raw) + + if strings.Contains(sanitized, "topsecret") { + t.Fatalf("expected password to be redacted, got %q", sanitized) + } + if !strings.Contains(sanitized, "alice:xxxxx@localhost:4222") { + t.Fatalf("expected sanitized authority with redacted password, got %q", sanitized) + } + }) } diff --git a/api/proto/common/gateway/v1/gateway.proto b/api/proto/common/gateway/v1/gateway.proto index 5bec241d..20f1a0af 100644 --- a/api/proto/common/gateway/v1/gateway.proto +++ b/api/proto/common/gateway/v1/gateway.proto @@ -26,10 +26,11 @@ enum Operation { enum Rail { RAIL_UNSPECIFIED = 0; RAIL_CRYPTO = 1; - RAIL_PROVIDER_SETTLEMENT = 2; + RAIL_SETTLEMENT = 2; RAIL_LEDGER = 3; - RAIL_CARD_PAYOUT = 4; - RAIL_FIAT_ONRAMP = 5; + RAIL_CARD = 4; + RAIL_ONRAMP = 5; + RAIL_OFFRAMP = 6; } // Operations supported in a payment plan. diff --git a/api/server/go.sum b/api/server/go.sum index af103f5f..34698344 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -361,8 +361,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU= +google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= diff --git a/ci/dev/chain-gateway.dockerfile b/ci/dev/chain-gateway.dockerfile index 1217dd31..8d0e7eca 100644 --- a/ci/dev/chain-gateway.dockerfile +++ b/ci/dev/chain-gateway.dockerfile @@ -11,6 +11,7 @@ WORKDIR /src COPY api/proto ./api/proto COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh @@ -25,6 +26,7 @@ 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 --from=builder /src/api/gateway/common ./api/gateway/common # Copy dev-specific entrypoint script COPY ci/dev/entrypoints/chain-gateway.sh /app/entrypoint.sh diff --git a/ci/dev/mntx-gateway.dockerfile b/ci/dev/mntx-gateway.dockerfile index 3d497d5a..b2f89fc2 100644 --- a/ci/dev/mntx-gateway.dockerfile +++ b/ci/dev/mntx-gateway.dockerfile @@ -11,6 +11,7 @@ WORKDIR /src COPY api/proto ./api/proto COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh @@ -25,6 +26,7 @@ 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 --from=builder /src/api/gateway/common ./api/gateway/common # Source code will be mounted at runtime WORKDIR /src/api/gateway/mntx diff --git a/ci/dev/tgsettle-gateway.dockerfile b/ci/dev/tgsettle-gateway.dockerfile index b8ff3e32..f791d21a 100644 --- a/ci/dev/tgsettle-gateway.dockerfile +++ b/ci/dev/tgsettle-gateway.dockerfile @@ -11,6 +11,7 @@ WORKDIR /src COPY api/proto ./api/proto COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh @@ -25,6 +26,7 @@ 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 --from=builder /src/api/gateway/common ./api/gateway/common # Source code will be mounted at runtime WORKDIR /src/api/gateway/tgsettle diff --git a/ci/dev/tron-gateway.dockerfile b/ci/dev/tron-gateway.dockerfile index 8a203edc..2ecebf29 100644 --- a/ci/dev/tron-gateway.dockerfile +++ b/ci/dev/tron-gateway.dockerfile @@ -11,6 +11,7 @@ WORKDIR /src COPY api/proto ./api/proto COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh @@ -25,6 +26,7 @@ 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 --from=builder /src/api/gateway/common ./api/gateway/common # Copy dev-specific entrypoint script COPY ci/dev/entrypoints/tron-gateway.sh /app/entrypoint.sh diff --git a/ci/scripts/chain_gateway/build-image.sh b/ci/scripts/chain_gateway/build-image.sh index ed90ca64..d2419989 100755 --- a/ci/scripts/chain_gateway/build-image.sh +++ b/ci/scripts/chain_gateway/build-image.sh @@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then BUILD_CONTEXT="/workspace" fi +# Gateway modules use a local replace (../common); ensure build context contains shared code. +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then + if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then + echo "[chain-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2 + BUILD_CONTEXT="${REPO_ROOT}" + fi +fi + +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then + echo "[chain-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2 + exit 67 +fi +if [ ! -f "${BUILD_CONTEXT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then + echo "[chain-gateway-build] dockerfile not found in build context: ${CHAIN_GATEWAY_DOCKERFILE}" >&2 + exit 68 +fi + /kaniko/executor \ --context "${BUILD_CONTEXT}" \ --dockerfile "${CHAIN_GATEWAY_DOCKERFILE}" \ diff --git a/ci/scripts/mntx/build-image.sh b/ci/scripts/mntx/build-image.sh index cacf96b3..81dd471a 100644 --- a/ci/scripts/mntx/build-image.sh +++ b/ci/scripts/mntx/build-image.sh @@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then BUILD_CONTEXT="/workspace" fi +# Gateway modules use a local replace (../common); ensure build context contains shared code. +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then + if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then + echo "[mntx-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2 + BUILD_CONTEXT="${REPO_ROOT}" + fi +fi + +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then + echo "[mntx-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2 + exit 67 +fi +if [ ! -f "${BUILD_CONTEXT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then + echo "[mntx-gateway-build] dockerfile not found in build context: ${MNTX_GATEWAY_DOCKERFILE}" >&2 + exit 68 +fi + /kaniko/executor \ --context "${BUILD_CONTEXT}" \ --dockerfile "${MNTX_GATEWAY_DOCKERFILE}" \ diff --git a/ci/scripts/tgsettle/build-image.sh b/ci/scripts/tgsettle/build-image.sh index cef8f1a6..a592afa1 100755 --- a/ci/scripts/tgsettle/build-image.sh +++ b/ci/scripts/tgsettle/build-image.sh @@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then BUILD_CONTEXT="/workspace" fi +# Gateway modules use a local replace (../common); ensure build context contains shared code. +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then + if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then + echo "[tgsettle-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2 + BUILD_CONTEXT="${REPO_ROOT}" + fi +fi + +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then + echo "[tgsettle-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2 + exit 67 +fi +if [ ! -f "${BUILD_CONTEXT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then + echo "[tgsettle-gateway-build] dockerfile not found in build context: ${TGSETTLE_GATEWAY_DOCKERFILE}" >&2 + exit 68 +fi + /kaniko/executor \ --context "${BUILD_CONTEXT}" \ --dockerfile "${TGSETTLE_GATEWAY_DOCKERFILE}" \ diff --git a/ci/scripts/tron_gateway/build-image.sh b/ci/scripts/tron_gateway/build-image.sh index 490ce280..e1744f61 100755 --- a/ci/scripts/tron_gateway/build-image.sh +++ b/ci/scripts/tron_gateway/build-image.sh @@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then BUILD_CONTEXT="/workspace" fi +# Gateway modules use a local replace (../common); ensure build context contains shared code. +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${TRON_GATEWAY_DOCKERFILE}" ]; then + if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${TRON_GATEWAY_DOCKERFILE}" ]; then + echo "[tron-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2 + BUILD_CONTEXT="${REPO_ROOT}" + fi +fi + +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then + echo "[tron-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2 + exit 67 +fi +if [ ! -f "${BUILD_CONTEXT}/${TRON_GATEWAY_DOCKERFILE}" ]; then + echo "[tron-gateway-build] dockerfile not found in build context: ${TRON_GATEWAY_DOCKERFILE}" >&2 + exit 68 +fi + /kaniko/executor \ --context "${BUILD_CONTEXT}" \ --dockerfile "${TRON_GATEWAY_DOCKERFILE}" \ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 95943698..306753f3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -616,6 +616,7 @@ services: dev-chain-gateway-vault-agent: { condition: service_healthy } volumes: - ./api/gateway/chain:/src/api/gateway/chain + - ./api/gateway/common:/src/api/gateway/common - ./api/gateway/chain/config.dev.yml:/app/config.yml:ro - dev-chain-gateway-vault-run:/run/vault:ro ports: @@ -693,6 +694,7 @@ services: dev-tron-gateway-vault-agent: { condition: service_healthy } volumes: - ./api/gateway/tron:/src/api/gateway/tron + - ./api/gateway/common:/src/api/gateway/common - ./api/gateway/tron/config.dev.yml:/app/config.yml:ro - dev-tron-gateway-vault-run:/run/vault:ro ports: @@ -738,6 +740,7 @@ services: dev-vault: { condition: service_healthy } volumes: - ./api/gateway/mntx:/src/api/gateway/mntx + - ./api/gateway/common:/src/api/gateway/common - ./api/gateway/mntx/config.dev.yml:/app/config.yml:ro ports: - "50075:50075" @@ -781,6 +784,7 @@ services: dev-vault: { condition: service_healthy } volumes: - ./api/gateway/tgsettle:/src/api/gateway/tgsettle + - ./api/gateway/common:/src/api/gateway/common - ./api/gateway/tgsettle/config.dev.yml:/app/config.yml:ro ports: - "50080:50080" -- 2.49.1 From 2e08ec9b9b6da27771e8d4fb0cba26cb61f3d1cd Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 24 Feb 2026 16:39:08 +0100 Subject: [PATCH 03/23] fee treatment added --- .../service/gateway/confirmation_flow.go | 2 +- .../internal/service/gateway/service.go | 24 ------ .../service/orchestrationv2/prepo/document.go | 5 +- .../service/orchestrator/service_v2.go | 3 +- .../interface/api/srequest/payment_enums.go | 9 ++ .../interface/api/srequest/payment_intent.go | 1 + .../internal/server/paymentapiimp/mapper.go | 18 ++++ .../mapper_fee_treatment_test.go | 82 +++++++++++++++++++ .../lib/data/dto/payment/intent/payment.dart | 4 + .../lib/data/mapper/payment/enums.dart | 1 + .../payment/{fee_line.dart => fees/line.dart} | 0 .../data/mapper/payment/fees/treatment.dart | 26 ++++++ .../data/mapper/payment/intent/payment.dart | 3 + .../lib/models/payment/fees/treatment.dart | 5 ++ .../pshared/lib/models/payment/intent.dart | 3 + .../payment/quotation/intent_builder.dart | 4 +- .../payment/multiple_intent_builder.dart | 2 + 17 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go rename frontend/pshared/lib/data/mapper/payment/{fee_line.dart => fees/line.dart} (100%) create mode 100644 frontend/pshared/lib/data/mapper/payment/fees/treatment.dart create mode 100644 frontend/pshared/lib/models/payment/fees/treatment.dart diff --git a/api/gateway/tgsettle/internal/service/gateway/confirmation_flow.go b/api/gateway/tgsettle/internal/service/gateway/confirmation_flow.go index 150a4868..5750a20e 100644 --- a/api/gateway/tgsettle/internal/service/gateway/confirmation_flow.go +++ b/api/gateway/tgsettle/internal/service/gateway/confirmation_flow.go @@ -313,7 +313,7 @@ func (s *Service) publishPendingConfirmationResult(pending *storagemodel.Pending return nil } -func (s *Service) sendTelegramText(ctx context.Context, request *model.TelegramTextRequest) error { +func (s *Service) sendTelegramText(_ context.Context, request *model.TelegramTextRequest) error { if request == nil { return merrors.InvalidArgument("telegram text request is nil", "request") } diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index 2a63a075..3d5392f1 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -756,27 +756,3 @@ func readEnv(env string) string { } var _ grpcapp.Service = (*Service)(nil) - -func statusFromConfirmationResult(r *model.ConfirmationResult) storagemodel.PaymentStatus { - if r == nil { - return storagemodel.PaymentStatusWaiting - } - - switch r.Status { - - case model.ConfirmationStatusConfirmed: - return storagemodel.PaymentStatusProcessing - - case model.ConfirmationStatusClarified: - return storagemodel.PaymentStatusWaiting - - case model.ConfirmationStatusRejected: - return storagemodel.PaymentStatusFailed - - case model.ConfirmationStatusTimeout: - return storagemodel.PaymentStatusFailed - - default: - return storagemodel.PaymentStatusFailed - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go index d669758b..c06f6956 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go @@ -8,11 +8,10 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/db/storable" pm "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" "go.mongodb.org/mongo-driver/v2/bson" ) -const paymentsV2Collection = "payments_v2" - type paymentDocument struct { storable.Base `bson:",inline"` pm.OrganizationBoundBase `bson:",inline"` @@ -28,7 +27,7 @@ type paymentDocument struct { } func (*paymentDocument) Collection() string { - return paymentsV2Collection + return mservice.Payments } func toDocument(payment *agg.Payment) (*paymentDocument, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go index ed6281a4..3df78c32 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "go.mongodb.org/mongo-driver/v2/mongo" "go.uber.org/zap" @@ -81,7 +82,7 @@ func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) pr return nil } paymentRepo, err := prepo.NewMongo( - db.Collection("payments_v2"), + db.Collection(mservice.Payments), prepo.Dependencies{Logger: logger.Named("orchestration_v2_prepo")}, ) if err != nil { diff --git a/api/server/interface/api/srequest/payment_enums.go b/api/server/interface/api/srequest/payment_enums.go index 267bc449..ab3fe6af 100644 --- a/api/server/interface/api/srequest/payment_enums.go +++ b/api/server/interface/api/srequest/payment_enums.go @@ -20,6 +20,15 @@ const ( SettlementModeFixReceived SettlementMode = "fix_received" ) +// FeeTreatment controls where fee impact is applied by quotation. +type FeeTreatment string + +const ( + FeeTreatmentUnspecified FeeTreatment = "unspecified" + FeeTreatmentAddToSource FeeTreatment = "add_to_source" + FeeTreatmentDeductFromDestination FeeTreatment = "deduct_from_destination" +) + // FXSide mirrors the common FX side enum. type FXSide string diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index 13c24d97..272dd311 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -14,6 +14,7 @@ type PaymentIntent struct { Amount *paymenttypes.Money `json:"amount,omitempty"` FX *FXIntent `json:"fx,omitempty"` SettlementMode SettlementMode `json:"settlement_mode,omitempty"` + FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"` SettlementCurrency string `json:"settlement_currency,omitempty"` Attributes map[string]string `json:"attributes,omitempty"` Customer *Customer `json:"customer,omitempty"` diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index a9be4fff..2620c71e 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -28,6 +28,10 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e if err != nil { return nil, err } + feeTreatment, err := mapFeeTreatment(intent.FeeTreatment) + if err != nil { + return nil, err + } settlementCurrency := strings.TrimSpace(intent.SettlementCurrency) if settlementCurrency == "" { settlementCurrency = resolveSettlementCurrency(intent) @@ -47,6 +51,7 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e Destination: destination, Amount: mapMoney(intent.Amount), SettlementMode: settlementMode, + FeeTreatment: feeTreatment, SettlementCurrency: settlementCurrency, } if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" { @@ -250,6 +255,19 @@ func mapSettlementMode(mode srequest.SettlementMode) (paymentv1.SettlementMode, } } +func mapFeeTreatment(treatment srequest.FeeTreatment) (quotationv2.FeeTreatment, error) { + switch strings.TrimSpace(string(treatment)) { + case "", string(srequest.FeeTreatmentUnspecified): + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, nil + case string(srequest.FeeTreatmentAddToSource): + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, nil + case string(srequest.FeeTreatmentDeductFromDestination): + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, nil + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("unsupported fee treatment: " + string(treatment)) + } +} + func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) { switch strings.TrimSpace(string(chain)) { case "", string(srequest.ChainNetworkUnspecified): diff --git a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go new file mode 100644 index 00000000..3df16d71 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go @@ -0,0 +1,82 @@ +package paymentapiimp + +import ( + "testing" + + paymenttypes "github.com/tech/sendico/pkg/payments/types" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "github.com/tech/sendico/server/interface/api/srequest" +) + +func TestMapQuoteIntent_PropagatesFeeTreatment(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixReceived, + FeeTreatment: srequest.FeeTreatmentDeductFromDestination, + } + + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("mapQuoteIntent returned error: %v", err) + } + if got == nil { + t.Fatalf("expected mapped quote intent") + } + if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION { + t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String()) + } +} + +func TestMapQuoteIntent_InvalidFeeTreatmentFails(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatment("wrong_value"), + } + + if _, err := mapQuoteIntent(intent); err == nil { + t.Fatalf("expected error for invalid fee treatment") + } +} diff --git a/frontend/pshared/lib/data/dto/payment/intent/payment.dart b/frontend/pshared/lib/data/dto/payment/intent/payment.dart index ebb57fdc..997b5f9d 100644 --- a/frontend/pshared/lib/data/dto/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/intent/payment.dart @@ -23,6 +23,9 @@ class PaymentIntentDTO { @JsonKey(name: 'settlement_currency') final String? settlementCurrency; + @JsonKey(name: "fee_treatment") + final String? feeTreatment; + final Map? attributes; final CustomerDTO? customer; @@ -36,6 +39,7 @@ class PaymentIntentDTO { this.settlementCurrency, this.attributes, this.customer, + this.feeTreatment, }); factory PaymentIntentDTO.fromJson(Map json) => _$PaymentIntentDTOFromJson(json); diff --git a/frontend/pshared/lib/data/mapper/payment/enums.dart b/frontend/pshared/lib/data/mapper/payment/enums.dart index 65581395..6be3e33d 100644 --- a/frontend/pshared/lib/data/mapper/payment/enums.dart +++ b/frontend/pshared/lib/data/mapper/payment/enums.dart @@ -5,6 +5,7 @@ import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; + PaymentKind paymentKindFromValue(String? value) { switch (value) { case 'payout': diff --git a/frontend/pshared/lib/data/mapper/payment/fee_line.dart b/frontend/pshared/lib/data/mapper/payment/fees/line.dart similarity index 100% rename from frontend/pshared/lib/data/mapper/payment/fee_line.dart rename to frontend/pshared/lib/data/mapper/payment/fees/line.dart diff --git a/frontend/pshared/lib/data/mapper/payment/fees/treatment.dart b/frontend/pshared/lib/data/mapper/payment/fees/treatment.dart new file mode 100644 index 00000000..395930d3 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/fees/treatment.dart @@ -0,0 +1,26 @@ +import 'package:pshared/models/payment/fees/treatment.dart'; + + +FeeTreatment feeTreatmentFromValue(String? value) { + switch (value) { + case 'add_to_source': + return FeeTreatment.addToSource; + case 'deduct_from_destination': + return FeeTreatment.deductFromDestination; + case 'unspecified': + return FeeTreatment.unspecified; + default: + throw ArgumentError('Unknown FeeTreatment value: $value'); + } +} + +String feeTreatmentToValue(FeeTreatment value) { + switch (value) { + case FeeTreatment.addToSource: + return 'add_to_source'; + case FeeTreatment.deductFromDestination: + return 'deduct_from_destination'; + case FeeTreatment.unspecified: + return 'unspecified'; + } +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart index bab5acbd..2170e7f9 100644 --- a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart @@ -1,4 +1,5 @@ import 'package:pshared/data/dto/payment/intent/payment.dart'; +import 'package:pshared/data/mapper/payment/fees/treatment.dart'; import 'package:pshared/data/mapper/payment/payment.dart'; import 'package:pshared/data/mapper/payment/enums.dart'; import 'package:pshared/data/mapper/payment/intent/customer.dart'; @@ -18,6 +19,7 @@ extension PaymentIntentMapper on PaymentIntent { settlementCurrency: settlementCurrency, attributes: attributes, customer: customer?.toDTO(), + feeTreatment: feeTreatmentToValue(feeTreatment), ); } @@ -32,5 +34,6 @@ extension PaymentIntentDTOMapper on PaymentIntentDTO { settlementCurrency: settlementCurrency, attributes: attributes, customer: customer?.toDomain(), + feeTreatment: feeTreatmentFromValue(feeTreatment), ); } diff --git a/frontend/pshared/lib/models/payment/fees/treatment.dart b/frontend/pshared/lib/models/payment/fees/treatment.dart new file mode 100644 index 00000000..9ae7c9b7 --- /dev/null +++ b/frontend/pshared/lib/models/payment/fees/treatment.dart @@ -0,0 +1,5 @@ +enum FeeTreatment { + addToSource, + deductFromDestination, + unspecified, +} diff --git a/frontend/pshared/lib/models/payment/intent.dart b/frontend/pshared/lib/models/payment/intent.dart index aeef6177..d611c477 100644 --- a/frontend/pshared/lib/models/payment/intent.dart +++ b/frontend/pshared/lib/models/payment/intent.dart @@ -1,3 +1,4 @@ +import 'package:pshared/models/payment/fees/treatment.dart'; import 'package:pshared/models/payment/fx/intent.dart'; import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/customer.dart'; @@ -12,6 +13,7 @@ class PaymentIntent { final PaymentMethodData? destination; final Money? amount; final FxIntent? fx; + final FeeTreatment feeTreatment; final SettlementMode settlementMode; final String? settlementCurrency; final Map? attributes; @@ -27,5 +29,6 @@ class PaymentIntent { this.settlementCurrency, this.attributes, this.customer, + required this.feeTreatment, }); } diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index 70b71e10..8d4fef4a 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -2,6 +2,7 @@ import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/chain_network.dart'; import 'package:pshared/models/payment/customer.dart'; +import 'package:pshared/models/payment/fees/treatment.dart'; import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart'; @@ -63,7 +64,8 @@ class QuotationIntentBuilder { ) ), fx: fxIntent, - settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, + feeTreatment: payment.payerCoversFee ? FeeTreatment.addToSource : FeeTreatment.deductFromDestination, + settlementMode: SettlementMode.fixSource, settlementCurrency: FxIntentHelper.resolveSettlementCurrency( amount: amount, fx: fxIntent, diff --git a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart index ea615a07..c08bc471 100644 --- a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart +++ b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart @@ -1,6 +1,7 @@ import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/chain_network.dart'; +import 'package:pshared/models/payment/fees/treatment.dart'; import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/card.dart'; @@ -52,6 +53,7 @@ class MultipleIntentBuilder { expYear: row.expYear, ), amount: amount, + feeTreatment: FeeTreatment.addToSource, settlementMode: SettlementMode.fixReceived, settlementCurrency: FxIntentHelper.resolveSettlementCurrency( amount: amount, -- 2.49.1 From 4c5677202a331e60da3a27fd5352f1aa888927b6 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 24 Feb 2026 18:02:20 +0100 Subject: [PATCH 04/23] mece request / payment economics --- .../orchestrationv2/prmap/intent_mapping.go | 16 +-- .../orchestrationv2/prmap/quote_mapping.go | 3 +- .../orchestrationv2/prmap/service_test.go | 7 +- .../internal/service/quotation/helpers.go | 30 +++-- .../quotation/helpers_economics_test.go | 72 +++++++++++ .../service/quotation/internal_helpers.go | 20 +++ .../quotation_service_v2/converters.go | 2 +- .../quotation/quotation_service_v2/helpers.go | 10 +- .../quotation_service_v2/single_processor.go | 2 +- .../service/quotation/quotation_v2_wiring.go | 16 +-- .../computed_quote_enricher.go | 2 +- .../quote_computation_service/economics.go | 10 +- .../quote_computation_service/planner.go | 10 +- .../service/quotation/quote_engine.go | 9 +- .../quote_request_validator.go | 38 +----- .../quote_request_validator_test.go | 70 ++++++++++- .../quote_response_mapper_v2/service.go | 29 ++--- .../quote_response_mapper_v2/service_test.go | 26 ++++ .../transfer_intent_hydrator/hydrator.go | 38 ++---- .../transfer_intent_hydrator_test.go | 84 +++++++++++-- api/pkg/payments/economics/knobs.go | 101 +++++++++++++++ .../interface/api/srequest/payment_intent.go | 4 +- .../srequest/payment_intent_validate_test.go | 85 +++++++++++++ .../api/srequest/payment_value_objects.go | 16 +-- .../internal/server/paymentapiimp/mapper.go | 16 ++- .../mapper_fee_treatment_test.go | 119 ++++++++++++++++++ .../lib/data/dto/payment/intent/payment.dart | 8 +- .../data/mapper/payment/intent/payment.dart | 3 - .../pshared/lib/models/payment/intent.dart | 3 - .../payment/quotation/intent_builder.dart | 26 ++-- .../test/payment/request_dto_format_test.dart | 3 +- .../payment/multiple_intent_builder.dart | 49 ++++---- 32 files changed, 704 insertions(+), 223 deletions(-) create mode 100644 api/payments/quotation/internal/service/quotation/helpers_economics_test.go create mode 100644 api/pkg/payments/economics/knobs.go create mode 100644 api/server/interface/api/srequest/payment_intent_validate_test.go diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go index ad21f400..a25722fb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/payments/storage/model" pkgmodel "github.com/tech/sendico/pkg/model" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -28,12 +29,16 @@ func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error } settlementMode := settlementModeToProto(src.SettlementMode) + feeTreatment := payecon.DefaultFeeTreatment() + if len(src.Attributes) > 0 { + feeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(src.Attributes["fee_treatment"]) + } return "ationv2.QuoteIntent{ Source: source, Destination: destination, Amount: moneyToProto(src.Amount), SettlementMode: settlementMode, - FeeTreatment: feeTreatmentForSettlementMode(settlementMode), + FeeTreatment: feeTreatment, SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)), Comment: strings.TrimSpace(src.Attributes["comment"]), }, nil @@ -186,15 +191,6 @@ func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode { } } -func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE - } -} - func uintToString(value uint32) string { if value == 0 { return "" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go index ed5aa573..89775283 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go @@ -5,6 +5,7 @@ import ( "time" "github.com/tech/sendico/payments/storage/model" + payecon "github.com/tech/sendico/pkg/payments/economics" 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" @@ -42,7 +43,7 @@ func mapQuoteSnapshot( ExecutionConditions: executionConditionsToProto(src.ExecutionConditions), PayerTotalDebitAmount: moneyToProto(src.TotalCost), ResolvedSettlementMode: resolvedSettlementMode, - ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), + ResolvedFeeTreatment: payecon.DefaultFeeTreatment(), IntentRef: strings.TrimSpace(intentRef), } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go index fd7b8758..dcba8a81 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -53,7 +53,7 @@ func TestMap_Success(t *testing.T) { if got, want := intent.GetSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { t.Fatalf("settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) } - if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { t.Fatalf("fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) } if got, want := intent.GetComment(), "invoice-7"; got != want { @@ -92,7 +92,7 @@ func TestMap_Success(t *testing.T) { if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { t.Fatalf("resolved_settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) } - if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { + if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { t.Fatalf("resolved_fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) } if got, want := quote.GetIntentRef(), payment.IntentSnapshot.Ref; got != want { @@ -241,7 +241,8 @@ func newPaymentFixture() *agg.Payment { SettlementMode: model.SettlementModeFixSource, SettlementCurrency: "USD", Attributes: map[string]string{ - "comment": "invoice-7", + "comment": "invoice-7", + "fee_treatment": "deduct_from_destination", }, }, QuoteSnapshot: &model.PaymentQuoteSnapshot{ diff --git a/api/payments/quotation/internal/service/quotation/helpers.go b/api/payments/quotation/internal/service/quotation/helpers.go index 68b7b4bc..64d6beb4 100644 --- a/api/payments/quotation/internal/service/quotation/helpers.go +++ b/api/payments/quotation/internal/service/quotation/helpers.go @@ -15,7 +15,7 @@ import ( 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" - paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) type moneyGetter interface { @@ -156,7 +156,14 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s } } -func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode paymentv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) { +func computeAggregates( + pay, + settlement, + fee *moneyv1.Money, + network *chainv1.EstimateTransferFeeResponse, + fxQuote *oraclev1.Quote, + feeTreatment quotationv2.FeeTreatment, +) (*moneyv1.Money, *moneyv1.Money) { if pay == nil { return nil, nil } @@ -197,21 +204,20 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est } } - switch mode { - case paymentv1.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. + switch feeTreatment { + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: applyChargeToSettlement(fee) + default: + // Default to payer-covers-fee when fee_treatment is omitted. + applyChargeToDebit(fee) } if network != nil && network.GetNetworkFee() != nil { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - applyChargeToDebit(network.GetNetworkFee()) - default: + switch feeTreatment { + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: applyChargeToSettlement(network.GetNetworkFee()) + default: + applyChargeToDebit(network.GetNetworkFee()) } } diff --git a/api/payments/quotation/internal/service/quotation/helpers_economics_test.go b/api/payments/quotation/internal/service/quotation/helpers_economics_test.go new file mode 100644 index 00000000..6eb0de66 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/helpers_economics_test.go @@ -0,0 +1,72 @@ +package quotation + +import ( + "testing" + + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func TestComputeAggregates_AddToSource(t *testing.T) { + debit, settlement := computeAggregates( + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "10", Currency: "USD"}, + nil, + nil, + quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + ) + if debit == nil || settlement == nil { + t.Fatalf("expected aggregate amounts") + } + if got, want := debit.GetAmount(), "110"; got != want { + t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) + } + if got, want := settlement.GetAmount(), "100"; got != want { + t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + } +} + +func TestComputeAggregates_DeductFromDestination(t *testing.T) { + debit, settlement := computeAggregates( + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "10", Currency: "USD"}, + nil, + nil, + quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + ) + if debit == nil || settlement == nil { + t.Fatalf("expected aggregate amounts") + } + if got, want := debit.GetAmount(), "100"; got != want { + t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) + } + if got, want := settlement.GetAmount(), "90"; got != want { + t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + } +} + +func TestComputeAggregates_NetworkFeeFollowsFeeTreatment(t *testing.T) { + networkFee := &chainv1.EstimateTransferFeeResponse{ + NetworkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, + } + debit, settlement := computeAggregates( + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "100", Currency: "USD"}, + &moneyv1.Money{Amount: "10", Currency: "USD"}, + networkFee, + nil, + quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + ) + if debit == nil || settlement == nil { + t.Fatalf("expected aggregate amounts") + } + if got, want := debit.GetAmount(), "100"; got != want { + t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) + } + if got, want := settlement.GetAmount(), "88"; got != want { + t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + } +} diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go index 8357d43b..161949d6 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -5,8 +5,10 @@ import ( "strings" "time" + payecon "github.com/tech/sendico/pkg/payments/economics" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) @@ -89,3 +91,21 @@ func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent { Side: fxv1.Side_SELL_BASE_BUY_QUOTE, } } + +func resolvedFeeTreatmentForQuote(intent *sharedv1.PaymentIntent) quotationv2.FeeTreatment { + if intent == nil { + return payecon.DefaultFeeTreatment() + } + attrs := intent.GetAttributes() + if len(attrs) == 0 { + return payecon.DefaultFeeTreatment() + } + + keys := []string{"fee_treatment", "feeTreatment"} + for _, key := range keys { + if value := strings.TrimSpace(attrs[key]); value != "" { + return payecon.ResolveFeeTreatmentFromStringOrDefault(value) + } + } + return payecon.DefaultFeeTreatment() +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go index 0f986098..22c9b3bb 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go @@ -86,7 +86,7 @@ func canonicalFromSnapshot( Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions), FXQuote: protoFXQuoteFromModel(snapshot.FXQuote), ResolvedSettlementMode: resolvedSettlementMode, - ResolvedFeeTreatment: resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode), + ResolvedFeeTreatment: defaultResolvedFeeTreatment(), ExpiresAt: expiresAt, PricedAt: pricedAt, } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go index def6a16a..4bebc500 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go @@ -5,6 +5,7 @@ import ( "time" "github.com/tech/sendico/payments/storage/model" + payecon "github.com/tech/sendico/pkg/payments/economics" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -57,11 +58,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle } } -func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE - } +func defaultResolvedFeeTreatment() quotationv2.FeeTreatment { + return payecon.DefaultFeeTreatment() } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go index 3e6cfa99..f2be9c87 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go @@ -177,7 +177,7 @@ func (p *singleIntentProcessorV2) Process( canonical.ResolvedSettlementMode = resolvedSettlementModeFromModel(planItem.Intent.SettlementMode) } if canonical.ResolvedFeeTreatment == 0 { - canonical.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(canonical.ResolvedSettlementMode) + canonical.ResolvedFeeTreatment = defaultResolvedFeeTreatment() } mapped, mapErr := p.mapper.Map(quote_response_mapper_v2.MapInput{ diff --git a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go index 1eb8981b..eb28aaa2 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go +++ b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/storage/model" quotestorage "github.com/tech/sendico/payments/storage/quote" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -129,6 +130,10 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1. if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE } + resolvedFeeTreatment := payecon.DefaultFeeTreatment() + if attrs := in.Intent.Attributes; len(attrs) > 0 { + resolvedFeeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(attrs["fee_treatment"]) + } return "e_computation_service.ComputedQuote{ DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()), CreditAmount: cloneProtoMoney(src.GetExpectedSettlementAmount()), @@ -139,16 +144,7 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1. Route: cloneRouteSpecification(in.Route), ExecutionConditions: cloneExecutionConditions(in.ExecutionConditions), ResolvedSettlementMode: resolvedSettlementMode, - ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), - } -} - -func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + ResolvedFeeTreatment: resolvedFeeTreatment, } } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go index dbc09a60..e53eb50f 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go @@ -37,7 +37,7 @@ func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *Co src.ResolvedFeeTreatment = item.ResolvedFeeTreatment } if src.ResolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - src.ResolvedFeeTreatment = resolvedFeeTreatmentForSettlementMode(src.ResolvedSettlementMode) + src.ResolvedFeeTreatment = defaultResolvedFeeTreatment() } return src } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go index b7dc37c7..1bf49467 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go @@ -2,6 +2,7 @@ package quote_computation_service import ( "github.com/tech/sendico/payments/storage/model" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) @@ -17,11 +18,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle } } -func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE - } +func defaultResolvedFeeTreatment() quotationv2.FeeTreatment { + return payecon.DefaultFeeTreatment() } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index b49c45f4..b478ba70 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -10,6 +10,7 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + payecon "github.com/tech/sendico/pkg/payments/economics" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" @@ -256,7 +257,7 @@ func (s *QuoteComputationService) buildPlanItem( ExecutionConditions: cloneExecutionConditions(conditions), } resolvedSettlementMode := resolvedSettlementModeFromModel(modelIntent.SettlementMode) - resolvedFeeTreatment := resolvedFeeTreatmentForSettlementMode(resolvedSettlementMode) + resolvedFeeTreatment := resolvedFeeTreatmentFromHydratedIntent(intent) intentRef := strings.TrimSpace(modelIntent.Ref) if intentRef == "" { @@ -288,6 +289,13 @@ func (s *QuoteComputationService) buildPlanItem( }, nil } +func resolvedFeeTreatmentFromHydratedIntent(intent *transfer_intent_hydrator.QuoteIntent) quotationv2.FeeTreatment { + if intent == nil { + return defaultResolvedFeeTreatment() + } + return payecon.ResolveFeeTreatmentFromStringOrDefault(string(intent.FeeTreatment)) +} + func deriveItemIdempotencyKey(base string, total, index int) string { base = strings.TrimSpace(base) if base == "" { diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index 20d55a54..4d15ba93 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -119,7 +119,14 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo s.logger.Debug("Network fee estimated", zap.String("org_ref", orgRef)) } - debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode()) + debitAmount, settlementAmount := computeAggregates( + payAmount, + settlementAmountBeforeFees, + feeTotal, + networkFee, + fxQuote, + resolvedFeeTreatmentForQuote(intent), + ) quote := &sharedv1.PaymentQuote{ DebitAmount: debitAmount, diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go index 155786d1..18ba268f 100644 --- a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/tech/sendico/pkg/merrors" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -151,46 +152,15 @@ func validateSettlementAndFeeTreatment( feeTreatment quotationv2.FeeTreatment, field string, ) error { - if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { - if feeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - return merrors.InvalidArgument(field + ".settlement_mode is required when fee_treatment is set") - } - // Both omitted is allowed and will be normalized by the hydrator. - return nil - } - - expected, ok := expectedFeeTreatmentForMode(mode) - if !ok { + if !payecon.IsValidSettlementMode(mode) { return merrors.InvalidArgument(field + ".settlement_mode is invalid") } - - if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - return nil - } - if feeTreatment != expected { - return merrors.InvalidArgument( - fmt.Sprintf( - "%s.fee_treatment conflicts with settlement_mode %s (expected %s)", - field, - mode.String(), - expected.String(), - ), - ) + if !payecon.IsValidFeeTreatment(feeTreatment) { + return merrors.InvalidArgument(field + ".fee_treatment is invalid") } return nil } -func expectedFeeTreatmentForMode(mode paymentv1.SettlementMode) (quotationv2.FeeTreatment, bool) { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, true - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, true - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, false - } -} - func hasEndpointValue(endpoint *endpointv1.PaymentEndpoint) bool { if endpoint == nil { return false diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go index 3d855b89..98a8ead9 100644 --- a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go @@ -116,7 +116,7 @@ func TestValidateQuotePayment_Rules(t *testing.T) { checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intent.source is required") }, }, { - name: "fee treatment requires settlement mode", + name: "invalid settlement mode", req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", @@ -125,17 +125,17 @@ func TestValidateQuotePayment_Rules(t *testing.T) { Destination: endpointWithMethodRef("pm-dst"), Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, - SettlementMode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + SettlementMode: paymentv1.SettlementMode(99), }, PreviewOnly: false, InitiatorRef: "actor-1", }, checkErr: func(err error) bool { - return err != nil && strings.Contains(err.Error(), "intent.settlement_mode is required when fee_treatment is set") + return err != nil && strings.Contains(err.Error(), "intent.settlement_mode is invalid") }, }, { - name: "conflicting fee treatment is rejected", + name: "invalid fee treatment", req: "ationv2.QuotePaymentRequest{ Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, IdempotencyKey: "idem-1", @@ -144,13 +144,13 @@ func TestValidateQuotePayment_Rules(t *testing.T) { Destination: endpointWithMethodRef("pm-dst"), Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, - FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + FeeTreatment: quotationv2.FeeTreatment(99), }, PreviewOnly: false, InitiatorRef: "actor-1", }, checkErr: func(err error) bool { - return err != nil && strings.Contains(err.Error(), "intent.fee_treatment conflicts with settlement_mode") + return err != nil && strings.Contains(err.Error(), "intent.fee_treatment is invalid") }, }, } @@ -272,6 +272,64 @@ func TestValidateQuotePayments_Rules(t *testing.T) { } } +func TestValidateQuotePayment_EconomicsKnobsAreIndependent(t *testing.T) { + validator := New() + orgHex := bson.NewObjectID().Hex() + + combinations := []struct { + name string + mode paymentv1.SettlementMode + fee quotationv2.FeeTreatment + }{ + { + name: "fix_source with add_to_source", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "fix_source with deduct_from_destination", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + { + name: "fix_received with add_to_source", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "fix_received with deduct_from_destination", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + { + name: "fee specified while settlement mode omitted", + mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + } + + for _, tc := range combinations { + t.Run(tc.name, func(t *testing.T) { + req := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intent: "ationv2.QuoteIntent{ + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + SettlementMode: tc.mode, + FeeTreatment: tc.fee, + }, + InitiatorRef: "actor-1", + } + + if _, err := validator.ValidateQuotePayment(req); err != nil { + t.Fatalf("expected economics combination to be accepted, got %v", err) + } + }) + } +} + func validQuoteIntent() *quotationv2.QuoteIntent { return "ationv2.QuoteIntent{ Source: endpointWithMethodRef("pm-src"), diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go index a310232e..a6321910 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go @@ -4,6 +4,7 @@ import ( "strings" "time" + payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -24,7 +25,7 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) { } expiresAt := normalizeQuoteExpiresAt(in.Quote.ExpiresAt, in.Quote.FXQuote) settlementMode := normalizeResolvedSettlementMode(in.Quote.ResolvedSettlementMode) - feeTreatment := normalizeResolvedFeeTreatment(settlementMode, in.Quote.ResolvedFeeTreatment) + feeTreatment := normalizeResolvedFeeTreatment(in.Quote.ResolvedFeeTreatment) result := "ationv2.PaymentQuote{ Storable: mapStorable(in.Meta), @@ -94,26 +95,12 @@ func normalizeResolvedSettlementMode(mode paymentv1.SettlementMode) paymentv1.Se } } -func normalizeResolvedFeeTreatment( - mode paymentv1.SettlementMode, - feeTreatment quotationv2.FeeTreatment, -) quotationv2.FeeTreatment { - expected := expectedFeeTreatmentForMode(mode) - switch feeTreatment { - case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, - quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: - if feeTreatment == expected { - return feeTreatment - } +func normalizeResolvedFeeTreatment(feeTreatment quotationv2.FeeTreatment) quotationv2.FeeTreatment { + if !payecon.IsValidFeeTreatment(feeTreatment) { + return payecon.DefaultFeeTreatment() } - return expected -} - -func expectedFeeTreatmentForMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION - default: - return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + return payecon.DefaultFeeTreatment() } + return feeTreatment } diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go index c81282b7..2a62a7a2 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -171,6 +171,32 @@ func TestMap_DefaultsResolvedEconomicsWhenUnset(t *testing.T) { } } +func TestMap_PreservesIndependentResolvedFeeTreatment(t *testing.T) { + mapper := New() + out, err := mapper.Map(MapInput{ + Quote: CanonicalQuote{ + TransferPrincipalAmount: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + Status: QuoteStatus{ + State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == nil || out.Quote == nil { + t.Fatalf("expected mapped quote") + } + if got, want := out.Quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := out.Quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected resolved_fee_treatment: got=%s want=%s", got.String(), want.String()) + } +} + func TestMap_BlockedQuote(t *testing.T) { mapper := New() out, err := mapper.Map(MapInput{ diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go index 17e744b4..97091e12 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/tech/sendico/pkg/merrors" + payecon "github.com/tech/sendico/pkg/payments/economics" paymenttypes "github.com/tech/sendico/pkg/payments/types" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" @@ -177,31 +178,17 @@ func resolveEconomics( mode paymentv1.SettlementMode, feeTreatment quotationv2.FeeTreatment, ) (QuoteSettlementMode, QuoteFeeTreatment, error) { - if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED && - feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - return QuoteSettlementModeFixSource, QuoteFeeTreatmentAddToSource, nil - } - if mode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { - return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is required when fee_treatment is set") - } - - resolvedMode := settlementModeFromProto(mode) - if resolvedMode == QuoteSettlementModeUnspecified { + if !payecon.IsValidSettlementMode(mode) { return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.settlement_mode is invalid") } - expectedFeeTreatment := feeTreatmentForMode(resolvedMode) - if feeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { - return resolvedMode, expectedFeeTreatment, nil - } - - resolvedFeeTreatment := feeTreatmentFromProto(feeTreatment) - if resolvedFeeTreatment == QuoteFeeTreatmentUnspecified { + if !payecon.IsValidFeeTreatment(feeTreatment) { return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment is invalid") } - if resolvedFeeTreatment != expectedFeeTreatment { - return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, merrors.InvalidArgument("intent.fee_treatment conflicts with settlement_mode") + resolvedModeProto, resolvedFeeProto, err := payecon.ResolveSettlementAndFee(mode, feeTreatment) + if err != nil { + return QuoteSettlementModeUnspecified, QuoteFeeTreatmentUnspecified, err } - return resolvedMode, resolvedFeeTreatment, nil + return settlementModeFromProto(resolvedModeProto), feeTreatmentFromProto(resolvedFeeProto), nil } func settlementModeFromProto(mode paymentv1.SettlementMode) QuoteSettlementMode { @@ -225,14 +212,3 @@ func feeTreatmentFromProto(value quotationv2.FeeTreatment) QuoteFeeTreatment { return QuoteFeeTreatmentUnspecified } } - -func feeTreatmentForMode(mode QuoteSettlementMode) QuoteFeeTreatment { - switch mode { - case QuoteSettlementModeFixSource: - return QuoteFeeTreatmentAddToSource - case QuoteSettlementModeFixReceived: - return QuoteFeeTreatmentDeductFromDestination - default: - return QuoteFeeTreatmentUnspecified - } -} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go index b10e1057..28c9ce26 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go @@ -429,23 +429,91 @@ func TestHydrateMany_IndexesError(t *testing.T) { } } -func TestHydrateOne_RejectsConflictingEconomics(t *testing.T) { +func TestHydrateOne_AcceptsIndependentEconomicsKnobs(t *testing.T) { h := New(nil) intent := "ationv2.QuoteIntent{ - Source: endpointWithMethodRef(bson.NewObjectID().Hex()), - Destination: endpointWithMethodRef(bson.NewObjectID().Hex()), + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src"}), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + }), + }, + }, + }, Amount: newMoney("1", "USD"), - SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, - FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, } - _, err := h.HydrateOne(context.Background(), HydrateOneInput{ + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ OrganizationRef: bson.NewObjectID().Hex(), InitiatorRef: bson.NewObjectID().Hex(), Intent: intent, }) - if err == nil || !strings.Contains(err.Error(), "fee_treatment conflicts with settlement_mode") { - t.Fatalf("expected settlement/fee conflict error, got %v", err) + if err != nil { + t.Fatalf("expected independent economics knobs to be accepted, got %v", err) + } + if got.SettlementMode != QuoteSettlementModeFixReceived { + t.Fatalf("unexpected settlement mode: got=%s", got.SettlementMode) + } + if got.FeeTreatment != QuoteFeeTreatmentAddToSource { + t.Fatalf("unexpected fee treatment: got=%s", got.FeeTreatment) + } +} + +func TestHydrateOne_DefaultsSettlementModeWhenOnlyFeeTreatmentProvided(t *testing.T) { + h := New(nil) + intent := "ationv2.QuoteIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src"}), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + }), + }, + }, + }, + Amount: newMoney("1", "USD"), + SettlementMode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + FeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + } + + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.SettlementMode != QuoteSettlementModeFixSource { + t.Fatalf("unexpected default settlement mode: got=%s", got.SettlementMode) + } + if got.FeeTreatment != QuoteFeeTreatmentDeductFromDestination { + t.Fatalf("unexpected resolved fee treatment: got=%s", got.FeeTreatment) } } diff --git a/api/pkg/payments/economics/knobs.go b/api/pkg/payments/economics/knobs.go new file mode 100644 index 00000000..a74271d2 --- /dev/null +++ b/api/pkg/payments/economics/knobs.go @@ -0,0 +1,101 @@ +package economics + +import ( + "strings" + + "github.com/tech/sendico/pkg/merrors" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +const ( + SettlementModeValueFixSource = "fix_source" + SettlementModeValueFixReceived = "fix_received" + + FeeTreatmentValueAddToSource = "add_to_source" + FeeTreatmentValueDeductFromDestination = "deduct_from_destination" +) + +func DefaultSettlementMode() paymentv1.SettlementMode { + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE +} + +func DefaultFeeTreatment() quotationv2.FeeTreatment { + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE +} + +func IsValidSettlementMode(mode paymentv1.SettlementMode) bool { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return true + default: + return false + } +} + +func IsValidFeeTreatment(value quotationv2.FeeTreatment) bool { + switch value { + case quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, + quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: + return true + default: + return false + } +} + +func ResolveSettlementAndFee( + mode paymentv1.SettlementMode, + feeTreatment quotationv2.FeeTreatment, +) (paymentv1.SettlementMode, quotationv2.FeeTreatment, error) { + if !IsValidSettlementMode(mode) { + return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("settlement_mode is invalid") + } + if !IsValidFeeTreatment(feeTreatment) { + return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("fee_treatment is invalid") + } + + resolvedMode := mode + if resolvedMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { + resolvedMode = DefaultSettlementMode() + } + resolvedFee := feeTreatment + if resolvedFee == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + resolvedFee = DefaultFeeTreatment() + } + return resolvedMode, resolvedFee, nil +} + +func FeeTreatmentFromString(value string) (quotationv2.FeeTreatment, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "unspecified", "fee_treatment_unspecified": + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, true + case FeeTreatmentValueAddToSource, "fee_treatment_add_to_source": + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, true + case FeeTreatmentValueDeductFromDestination, "fee_treatment_deduct_from_destination": + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, true + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, false + } +} + +func ResolveFeeTreatmentFromStringOrDefault(value string) quotationv2.FeeTreatment { + parsed, ok := FeeTreatmentFromString(value) + if !ok || parsed == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + return DefaultFeeTreatment() + } + return parsed +} + +func FeeTreatmentValue(value quotationv2.FeeTreatment) string { + switch value { + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: + return FeeTreatmentValueDeductFromDestination + case quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE: + return FeeTreatmentValueAddToSource + default: + return "" + } +} diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index 272dd311..1f0b9c96 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -56,9 +56,7 @@ func (p *PaymentIntent) Validate() error { } if strings.TrimSpace(p.SettlementCurrency) != "" { - if err := ValidateCurrency(p.SettlementCurrency, &AssetResolverStub{}); err != nil { - return err - } + return merrors.InvalidArgument("settlement_currency must not be provided; it is derived from fx intent or amount currency", "intent.settlement_currency") } return nil diff --git a/api/server/interface/api/srequest/payment_intent_validate_test.go b/api/server/interface/api/srequest/payment_intent_validate_test.go new file mode 100644 index 00000000..0cf4ec3c --- /dev/null +++ b/api/server/interface/api/srequest/payment_intent_validate_test.go @@ -0,0 +1,85 @@ +package srequest + +import ( + "testing" + + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func TestPaymentIntentValidate_RejectsSettlementCurrency(t *testing.T) { + intent := mustValidBaseIntent(t) + intent.SettlementCurrency = "RUB" + + if err := intent.Validate(); err == nil { + t.Fatalf("expected validation error for settlement_currency") + } +} + +func TestPaymentIntentValidate_RejectsFXWithoutPair(t *testing.T) { + intent := mustValidBaseIntent(t) + intent.FX = &FXIntent{ + Side: FXSideSellBaseBuyQuote, + } + + if err := intent.Validate(); err == nil { + t.Fatalf("expected validation error for missing fx pair") + } +} + +func TestPaymentIntentValidate_RejectsInvalidFXSide(t *testing.T) { + intent := mustValidBaseIntent(t) + intent.FX = &FXIntent{ + Pair: &CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: FXSide("wrong"), + } + + if err := intent.Validate(); err == nil { + t.Fatalf("expected validation error for invalid fx side") + } +} + +func TestPaymentIntentValidate_AcceptsValidFX(t *testing.T) { + intent := mustValidBaseIntent(t) + intent.FX = &FXIntent{ + Pair: &CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: FXSideSellBaseBuyQuote, + } + + if err := intent.Validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func mustValidBaseIntent(t *testing.T) *PaymentIntent { + t.Helper() + + source, err := NewManagedWalletEndpointDTO(ManagedWalletEndpoint{ManagedWalletRef: "mw-src"}, nil) + if err != nil { + t.Fatalf("build source endpoint: %v", err) + } + destination, err := NewCardEndpointDTO(CardEndpoint{ + Pan: "2200700142860161", + FirstName: "Jane", + LastName: "Doe", + ExpMonth: 2, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("build destination endpoint: %v", err) + } + + return &PaymentIntent{ + Kind: PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: SettlementModeFixSource, + FeeTreatment: FeeTreatmentAddToSource, + } +} diff --git a/api/server/interface/api/srequest/payment_value_objects.go b/api/server/interface/api/srequest/payment_value_objects.go index 35b7cc85..03f062b7 100644 --- a/api/server/interface/api/srequest/payment_value_objects.go +++ b/api/server/interface/api/srequest/payment_value_objects.go @@ -105,15 +105,17 @@ type FXIntent struct { } func (fx *FXIntent) Validate() error { - if fx.Pair != nil { - if err := fx.Pair.Validate(); err != nil { - return err - } + if fx.Pair == nil { + return merrors.InvalidArgument("fx pair is required", "intent.fx.pair") + } + if err := fx.Pair.Validate(); err != nil { + return err } - var zeroSide FXSide - if fx.Side == zeroSide { - return merrors.InvalidArgument("fx side is required", "intent.fx.side") + switch strings.TrimSpace(string(fx.Side)) { + case string(FXSideBuyBaseSellQuote), string(FXSideSellBaseBuyQuote): + default: + return merrors.InvalidArgument("fx side is invalid", "intent.fx.side") } if fx.TTLms < 0 { diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 2620c71e..093aaa13 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/pkg/merrors" pkgmodel "github.com/tech/sendico/pkg/model" + payecon "github.com/tech/sendico/pkg/payments/economics" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" @@ -32,9 +33,16 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e if err != nil { return nil, err } - settlementCurrency := strings.TrimSpace(intent.SettlementCurrency) + resolvedSettlementMode, resolvedFeeTreatment, err := payecon.ResolveSettlementAndFee(settlementMode, feeTreatment) + if err != nil { + return nil, err + } + if strings.TrimSpace(intent.SettlementCurrency) != "" { + return nil, merrors.InvalidArgument("settlement_currency must not be provided; it is derived from fx intent or amount currency") + } + settlementCurrency := resolveSettlementCurrency(intent) if settlementCurrency == "" { - settlementCurrency = resolveSettlementCurrency(intent) + return nil, merrors.InvalidArgument("unable to derive settlement currency from intent") } source, err := mapQuoteEndpoint(intent.Source, "intent.source") @@ -50,8 +58,8 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e Source: source, Destination: destination, Amount: mapMoney(intent.Amount), - SettlementMode: settlementMode, - FeeTreatment: feeTreatment, + SettlementMode: resolvedSettlementMode, + FeeTreatment: resolvedFeeTreatment, SettlementCurrency: settlementCurrency, } if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" { diff --git a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go index 3df16d71..a450545f 100644 --- a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go +++ b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go @@ -4,6 +4,7 @@ import ( "testing" paymenttypes "github.com/tech/sendico/pkg/payments/types" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "github.com/tech/sendico/server/interface/api/srequest" ) @@ -80,3 +81,121 @@ func TestMapQuoteIntent_InvalidFeeTreatmentFails(t *testing.T) { t.Fatalf("expected error for invalid fee treatment") } } + +func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixReceived, + FeeTreatment: srequest.FeeTreatmentAddToSource, + } + + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("mapQuoteIntent returned error: %v", err) + } + if got.GetSettlementMode() != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED { + t.Fatalf("unexpected settlement mode: got=%s", got.GetSettlementMode().String()) + } + if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE { + t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String()) + } +} + +func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatmentAddToSource, + SettlementCurrency: "RUB", + } + + if _, err := mapQuoteIntent(intent); err == nil { + t.Fatalf("expected error for explicit settlement_currency") + } +} + +func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatmentAddToSource, + FX: &srequest.FXIntent{ + Pair: &srequest.CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: srequest.FXSideSellBaseBuyQuote, + }, + } + + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.GetSettlementCurrency() != "RUB" { + t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) + } +} diff --git a/frontend/pshared/lib/data/dto/payment/intent/payment.dart b/frontend/pshared/lib/data/dto/payment/intent/payment.dart index 997b5f9d..7327ddef 100644 --- a/frontend/pshared/lib/data/dto/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/intent/payment.dart @@ -7,7 +7,6 @@ import 'package:pshared/data/dto/money.dart'; part 'payment.g.dart'; - @JsonSerializable() class PaymentIntentDTO { final String? kind; @@ -20,9 +19,6 @@ class PaymentIntentDTO { @JsonKey(name: 'settlement_mode') final String? settlementMode; - @JsonKey(name: 'settlement_currency') - final String? settlementCurrency; - @JsonKey(name: "fee_treatment") final String? feeTreatment; @@ -36,12 +32,12 @@ class PaymentIntentDTO { this.amount, this.fx, this.settlementMode, - this.settlementCurrency, this.attributes, this.customer, this.feeTreatment, }); - factory PaymentIntentDTO.fromJson(Map json) => _$PaymentIntentDTOFromJson(json); + factory PaymentIntentDTO.fromJson(Map json) => + _$PaymentIntentDTOFromJson(json); Map toJson() => _$PaymentIntentDTOToJson(this); } diff --git a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart index 2170e7f9..f0a8d00b 100644 --- a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart @@ -7,7 +7,6 @@ import 'package:pshared/data/mapper/payment/intent/fx.dart'; import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/models/payment/intent.dart'; - extension PaymentIntentMapper on PaymentIntent { PaymentIntentDTO toDTO() => PaymentIntentDTO( kind: paymentKindToValue(kind), @@ -16,7 +15,6 @@ extension PaymentIntentMapper on PaymentIntent { amount: amount?.toDTO(), fx: fx?.toDTO(), settlementMode: settlementModeToValue(settlementMode), - settlementCurrency: settlementCurrency, attributes: attributes, customer: customer?.toDTO(), feeTreatment: feeTreatmentToValue(feeTreatment), @@ -31,7 +29,6 @@ extension PaymentIntentDTOMapper on PaymentIntentDTO { amount: amount?.toDomain(), fx: fx?.toDomain(), settlementMode: settlementModeFromValue(settlementMode), - settlementCurrency: settlementCurrency, attributes: attributes, customer: customer?.toDomain(), feeTreatment: feeTreatmentFromValue(feeTreatment), diff --git a/frontend/pshared/lib/models/payment/intent.dart b/frontend/pshared/lib/models/payment/intent.dart index d611c477..4a722606 100644 --- a/frontend/pshared/lib/models/payment/intent.dart +++ b/frontend/pshared/lib/models/payment/intent.dart @@ -6,7 +6,6 @@ import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; - class PaymentIntent { final PaymentKind kind; final PaymentMethodData? source; @@ -15,7 +14,6 @@ class PaymentIntent { final FxIntent? fx; final FeeTreatment feeTreatment; final SettlementMode settlementMode; - final String? settlementCurrency; final Map? attributes; final Customer? customer; @@ -26,7 +24,6 @@ class PaymentIntent { this.amount, this.fx, this.settlementMode = SettlementMode.unspecified, - this.settlementCurrency, this.attributes, this.customer, required this.feeTreatment, diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index 8d4fef4a..ca26a8b0 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -21,7 +21,6 @@ import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/payment/fx_helpers.dart'; - class QuotationIntentBuilder { PaymentIntent? build({ required PaymentAmountProvider payment, @@ -45,8 +44,10 @@ class QuotationIntentBuilder { // TODO: adapt to possible other sources currency: sourceCurrency, ); - final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod && - (paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency; + final isCryptoToCrypto = + paymentData is CryptoAddressPaymentMethod && + (paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == + amount.currency; final fxIntent = FxIntentHelper.buildSellBaseBuyQuote( baseCurrency: sourceCurrency, quoteCurrency: 'RUB', // TODO: exentd target currencies @@ -61,15 +62,13 @@ class QuotationIntentBuilder { asset: PaymentAsset( tokenSymbol: selectedWallet.tokenSymbol ?? '', chain: selectedWallet.network ?? ChainNetwork.unspecified, - ) + ), ), fx: fxIntent, - feeTreatment: payment.payerCoversFee ? FeeTreatment.addToSource : FeeTreatment.deductFromDestination, + feeTreatment: payment.payerCoversFee + ? FeeTreatment.addToSource + : FeeTreatment.deductFromDestination, settlementMode: SettlementMode.fixSource, - settlementCurrency: FxIntentHelper.resolveSettlementCurrency( - amount: amount, - fx: fxIntent, - ), customer: customer, ); } @@ -92,8 +91,9 @@ class QuotationIntentBuilder { : name.trim().split(RegExp(r'\s+')); final firstName = parts.isNotEmpty ? parts.first : null; final lastName = parts.length >= 2 ? parts.last : null; - final middleName = - parts.length > 2 ? parts.sublist(1, parts.length - 1).join(' ') : null; + final middleName = parts.length > 2 + ? parts.sublist(1, parts.length - 1).join(' ') + : null; return Customer( id: id, @@ -120,7 +120,9 @@ class QuotationIntentBuilder { return iban.accountHolder.trim(); } - final bank = method?.bankAccountData ?? (data is RussianBankAccountPaymentMethod ? data : null); + final bank = + method?.bankAccountData ?? + (data is RussianBankAccountPaymentMethod ? data : null); if (bank != null && bank.recipientName.trim().isNotEmpty) { return bank.recipientName.trim(); } diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 8dae1812..17123984 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -54,7 +54,6 @@ void main() { ), amount: MoneyDTO(amount: '10', currency: 'USD'), settlementMode: 'fix_received', - settlementCurrency: 'USD', ), ); @@ -68,7 +67,7 @@ void main() { final intent = json['intent'] as Map; expect(intent['kind'], equals('payout')); expect(intent['settlement_mode'], equals('fix_received')); - expect(intent['settlement_currency'], equals('USD')); + expect(intent.containsKey('settlement_currency'), isFalse); final source = intent['source'] as Map; final destination = intent['destination'] as Map; diff --git a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart index c08bc471..8efb30bf 100644 --- a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart +++ b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart @@ -13,7 +13,6 @@ import 'package:pshared/utils/payment/fx_helpers.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; - class MultipleIntentBuilder { static const String _currency = 'RUB'; @@ -36,33 +35,27 @@ class MultipleIntentBuilder { ); return rows - .map( - (row) { - final amount = Money(amount: row.amount, currency: _currency); - return PaymentIntent( - kind: PaymentKind.payout, - source: ManagedWalletPaymentMethod( - managedWalletRef: sourceWallet.id, - asset: sourceAsset, - ), - destination: CardPaymentMethod( - pan: row.pan, - firstName: row.firstName, - lastName: row.lastName, - expMonth: row.expMonth, - expYear: row.expYear, - ), - amount: amount, - feeTreatment: FeeTreatment.addToSource, - settlementMode: SettlementMode.fixReceived, - settlementCurrency: FxIntentHelper.resolveSettlementCurrency( - amount: amount, - fx: fxIntent, - ), - fx: fxIntent, - ); - }, - ) + .map((row) { + final amount = Money(amount: row.amount, currency: _currency); + return PaymentIntent( + kind: PaymentKind.payout, + source: ManagedWalletPaymentMethod( + managedWalletRef: sourceWallet.id, + asset: sourceAsset, + ), + destination: CardPaymentMethod( + pan: row.pan, + firstName: row.firstName, + lastName: row.lastName, + expMonth: row.expMonth, + expYear: row.expYear, + ), + amount: amount, + feeTreatment: FeeTreatment.addToSource, + settlementMode: SettlementMode.fixReceived, + fx: fxIntent, + ); + }) .toList(growable: false); } } -- 2.49.1 From a998b5907229e00f4977216a2d6bd897e2cd6550 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 24 Feb 2026 18:33:12 +0100 Subject: [PATCH 05/23] complete MECE request --- api/gateway/chain/go.mod | 2 +- api/gateway/chain/go.sum | 4 +- api/gateway/tron/go.mod | 2 +- api/gateway/tron/go.sum | 4 +- .../orchestrationv2/prmap/intent_mapping.go | 17 +- .../orchestrationv2/prmap/service_test.go | 4 +- .../internal/service/quotation/convert.go | 144 ++++++++++++++- .../service/quotation/convert_mece_test.go | 97 ++++++++++ .../quotation/helpers_economics_test.go | 172 ++++++++++++------ .../service/quotation/internal_helpers.go | 5 +- .../quotation/internal_helpers_test.go | 116 ++++++++++++ .../service/quotation/quotation_v2_wiring.go | 17 +- .../quote_computation_service/compute_test.go | 106 +++++++++++ .../computed_quote_enricher_test.go | 71 ++++++++ .../quote_computation_service/helpers.go | 1 + .../intent_adapters.go | 12 ++ .../service/quotation/quote_engine.go | 12 +- .../quote_request_validator_test.go | 5 + .../transfer_intent_hydrator/hydrator.go | 4 +- .../transfer_intent_hydrator_test.go | 142 +++++++++++++++ api/payments/storage/model/payment.go | 11 ++ .../interface/api/srequest/payment_intent.go | 25 +-- .../srequest/payment_intent_validate_test.go | 7 +- .../internal/server/paymentapiimp/mapper.go | 3 - .../mapper_fee_treatment_test.go | 23 ++- .../lib/data/dto/payment/fx_quote.dart | 6 +- .../lib/data/mapper/payment/fx_quote.dart | 51 +++--- .../data/mapper/payment/payment_response.dart | 2 +- .../{payment_quote.dart => quote.dart} | 2 +- .../lib/data/mapper/payment/quote/quotes.dart | 2 +- .../pshared/lib/models/payment/fx/quote.dart | 3 +- .../provider/payment/multiple/quotation.dart | 6 +- .../lib/service/payment/quotation.dart | 2 +- .../test/payment/request_dto_format_test.dart | 33 +++- 34 files changed, 957 insertions(+), 156 deletions(-) create mode 100644 api/payments/quotation/internal/service/quotation/convert_mece_test.go create mode 100644 api/payments/quotation/internal/service/quotation/internal_helpers_test.go create mode 100644 api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher_test.go rename frontend/pshared/lib/data/mapper/payment/{payment_quote.dart => quote.dart} (95%) diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index e3848599..571c7048 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -25,7 +25,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 47cbf45d..fdd2e6f0 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b h1:RVnS+OZmBJbbNeqejAksq3Mxc73y0IEzyTUHPPWZuj8= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 h1:kLumy+keYsmuByIG8/G7Iay1vGCd1/WBq8a3vvPJWTM= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index 859ab524..fa2176fb 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -27,7 +27,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index 7a144168..7fc500da 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b h1:RVnS+OZmBJbbNeqejAksq3Mxc73y0IEzyTUHPPWZuj8= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 h1:kLumy+keYsmuByIG8/G7Iay1vGCd1/WBq8a3vvPJWTM= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go index a25722fb..a40ec8aa 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go @@ -6,7 +6,6 @@ import ( "github.com/tech/sendico/payments/storage/model" pkgmodel "github.com/tech/sendico/pkg/model" - payecon "github.com/tech/sendico/pkg/payments/economics" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -29,10 +28,7 @@ func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error } settlementMode := settlementModeToProto(src.SettlementMode) - feeTreatment := payecon.DefaultFeeTreatment() - if len(src.Attributes) > 0 { - feeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(src.Attributes["fee_treatment"]) - } + feeTreatment := feeTreatmentToProto(src.FeeTreatment) return "ationv2.QuoteIntent{ Source: source, Destination: destination, @@ -191,6 +187,17 @@ func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode { } } +func feeTreatmentToProto(value model.FeeTreatment) quotationv2.FeeTreatment { + switch value { + case model.FeeTreatmentDeductFromDestination: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + case model.FeeTreatmentAddToSource: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + } +} + func uintToString(value uint32) string { if value == 0 { return "" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go index dcba8a81..0b25dfd9 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -239,10 +239,10 @@ func newPaymentFixture() *agg.Payment { Currency: "USDT", }, SettlementMode: model.SettlementModeFixSource, + FeeTreatment: model.FeeTreatmentDeductFromDestination, SettlementCurrency: "USD", Attributes: map[string]string{ - "comment": "invoice-7", - "fee_treatment": "deduct_from_destination", + "comment": "invoice-7", }, }, QuoteSnapshot: &model.PaymentQuoteSnapshot{ diff --git a/api/payments/quotation/internal/service/quotation/convert.go b/api/payments/quotation/internal/service/quotation/convert.go index 024bca64..866a47b7 100644 --- a/api/payments/quotation/internal/service/quotation/convert.go +++ b/api/payments/quotation/internal/service/quotation/convert.go @@ -5,12 +5,14 @@ import ( "github.com/tech/sendico/payments/storage/model" chainasset "github.com/tech/sendico/pkg/chain" + payecon "github.com/tech/sendico/pkg/payments/economics" paymenttypes "github.com/tech/sendico/pkg/payments/types" 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" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) @@ -18,17 +20,28 @@ func intentFromProto(src *sharedv1.PaymentIntent) model.PaymentIntent { if src == nil { return model.PaymentIntent{} } + attrs := cloneMetadata(src.GetAttributes()) + feeTreatment := feeTreatmentFromAttributes(attrs) + delete(attrs, "fee_treatment") + delete(attrs, "feeTreatment") + delete(attrs, "settlement_mode") + delete(attrs, "settlementMode") + + settlementCurrency := derivedSettlementCurrencyFromProtoIntent(src) + requiresFX := derivedRequiresFXFromProtoIntent(src, settlementCurrency) + 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(), + RequiresFX: requiresFX, FeePolicy: feePolicyFromProto(src.GetFeePolicy()), SettlementMode: settlementModeFromProto(src.GetSettlementMode()), - SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()), - Attributes: cloneMetadata(src.GetAttributes()), + FeeTreatment: feeTreatment, + SettlementCurrency: settlementCurrency, + Attributes: attrs, Customer: customerFromProto(src.GetCustomer()), } if src.GetFx() != nil { @@ -103,17 +116,33 @@ func fxIntentFromProto(src *sharedv1.FXIntent) *model.FXIntent { } func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent { + attrs := cloneMetadata(src.Attributes) + if attrs == nil { + attrs = map[string]string{} + } + if feeTreatment := strings.TrimSpace(string(src.FeeTreatment)); feeTreatment != "" && feeTreatment != string(model.FeeTreatmentUnspecified) { + attrs["fee_treatment"] = feeTreatment + } + if len(attrs) == 0 { + attrs = nil + } + + settlementCurrency := strings.TrimSpace(src.SettlementCurrency) + if settlementCurrency == "" { + settlementCurrency = derivedSettlementCurrencyFromModelIntent(src) + } + intent := &sharedv1.PaymentIntent{ Ref: src.Ref, Kind: protoKindFromModel(src.Kind), Source: protoEndpointFromModel(src.Source), Destination: protoEndpointFromModel(src.Destination), Amount: protoMoney(src.Amount), - RequiresFx: src.RequiresFX, + RequiresFx: derivedRequiresFXFromModelIntent(src, settlementCurrency), FeePolicy: feePolicyToProto(src.FeePolicy), SettlementMode: settlementModeToProto(src.SettlementMode), - SettlementCurrency: strings.TrimSpace(src.SettlementCurrency), - Attributes: cloneMetadata(src.Attributes), + SettlementCurrency: settlementCurrency, + Attributes: attrs, Customer: protoCustomerFromModel(src.Customer), } if src.FX != nil { @@ -122,6 +151,109 @@ func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent { return intent } +func feeTreatmentFromAttributes(attrs map[string]string) model.FeeTreatment { + if len(attrs) == 0 { + return model.FeeTreatmentAddToSource + } + keys := []string{"fee_treatment", "feeTreatment"} + for _, key := range keys { + if value := strings.TrimSpace(attrs[key]); value != "" { + switch payecon.ResolveFeeTreatmentFromStringOrDefault(value) { + case quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION: + return model.FeeTreatmentDeductFromDestination + default: + return model.FeeTreatmentAddToSource + } + } + } + return model.FeeTreatmentAddToSource +} + +func derivedSettlementCurrencyFromProtoIntent(src *sharedv1.PaymentIntent) string { + if src == nil { + return "" + } + if fx := src.GetFx(); fx != nil && fx.GetPair() != nil { + if currency := settlementCurrencyFromPair(fx.GetPair(), fx.GetSide()); currency != "" { + return currency + } + } + if amount := src.GetAmount(); amount != nil { + if currency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency())); currency != "" { + return currency + } + } + return strings.ToUpper(strings.TrimSpace(src.GetSettlementCurrency())) +} + +func derivedSettlementCurrencyFromModelIntent(src model.PaymentIntent) string { + if src.FX != nil && src.FX.Pair != nil { + pair := &fxv1.CurrencyPair{ + Base: strings.TrimSpace(src.FX.Pair.Base), + Quote: strings.TrimSpace(src.FX.Pair.Quote), + } + if currency := settlementCurrencyFromPair(pair, fxSideToProto(src.FX.Side)); currency != "" { + return currency + } + } + if src.Amount != nil { + if currency := strings.ToUpper(strings.TrimSpace(src.Amount.Currency)); currency != "" { + return currency + } + } + return "" +} + +func settlementCurrencyFromPair(pair *fxv1.CurrencyPair, side fxv1.Side) string { + if pair == nil { + return "" + } + base := strings.ToUpper(strings.TrimSpace(pair.GetBase())) + quote := strings.ToUpper(strings.TrimSpace(pair.GetQuote())) + switch side { + case fxv1.Side_BUY_BASE_SELL_QUOTE: + if base != "" { + return base + } + case fxv1.Side_SELL_BASE_BUY_QUOTE, fxv1.Side_SIDE_UNSPECIFIED: + if quote != "" { + return quote + } + } + if quote != "" { + return quote + } + return base +} + +func derivedRequiresFXFromProtoIntent(src *sharedv1.PaymentIntent, settlementCurrency string) bool { + if src == nil { + return false + } + if fx := src.GetFx(); fx != nil && fx.GetPair() != nil { + return true + } + amount := src.GetAmount() + if amount == nil { + return false + } + amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + settlementCurrency = strings.ToUpper(strings.TrimSpace(settlementCurrency)) + return amountCurrency != "" && settlementCurrency != "" && !strings.EqualFold(amountCurrency, settlementCurrency) +} + +func derivedRequiresFXFromModelIntent(src model.PaymentIntent, settlementCurrency string) bool { + if src.FX != nil && src.FX.Pair != nil { + return true + } + if src.Amount == nil { + return false + } + amountCurrency := strings.ToUpper(strings.TrimSpace(src.Amount.Currency)) + settlementCurrency = strings.ToUpper(strings.TrimSpace(settlementCurrency)) + return amountCurrency != "" && settlementCurrency != "" && !strings.EqualFold(amountCurrency, settlementCurrency) +} + func customerFromProto(src *sharedv1.Customer) *model.Customer { if src == nil { return nil diff --git a/api/payments/quotation/internal/service/quotation/convert_mece_test.go b/api/payments/quotation/internal/service/quotation/convert_mece_test.go new file mode 100644 index 00000000..717a97d5 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/convert_mece_test.go @@ -0,0 +1,97 @@ +package quotation + +import ( + "testing" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" +) + +func TestIntentFromProto_DerivesCanonicalFXFieldsAndFeeTreatment(t *testing.T) { + src := &sharedv1.PaymentIntent{ + Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"}, + SettlementCurrency: "USD", + RequiresFx: false, + Fx: &sharedv1.FXIntent{ + Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + }, + Attributes: map[string]string{ + "comment": "invoice-7", + "fee_treatment": "deduct_from_destination", + "settlement_mode": "fix_source", + }, + } + + got := intentFromProto(src) + if got.SettlementCurrency != "RUB" { + t.Fatalf("unexpected settlement currency: got=%q", got.SettlementCurrency) + } + if !got.RequiresFX { + t.Fatalf("expected requires_fx to be derived as true") + } + if got.FeeTreatment != model.FeeTreatmentDeductFromDestination { + t.Fatalf("unexpected fee treatment: got=%q", got.FeeTreatment) + } + if _, ok := got.Attributes["fee_treatment"]; ok { + t.Fatalf("fee_treatment must not be kept in generic attributes") + } + if _, ok := got.Attributes["settlement_mode"]; ok { + t.Fatalf("settlement_mode must not be kept in generic attributes") + } + if got.Attributes["comment"] != "invoice-7" { + t.Fatalf("comment attribute must be preserved") + } +} + +func TestProtoIntentFromModel_DerivesRequiresFXAndSettlementCurrency(t *testing.T) { + src := model.PaymentIntent{ + Amount: &paymenttypes.Money{ + Amount: "10", + Currency: "USDT", + }, + FX: &model.FXIntent{ + Pair: &paymenttypes.CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: paymenttypes.FXSideSellBaseBuyQuote, + }, + FeeTreatment: model.FeeTreatmentAddToSource, + Attributes: map[string]string{ + "comment": "invoice-7", + }, + } + + got := protoIntentFromModel(src) + if got.GetSettlementCurrency() != "RUB" { + t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) + } + if !got.GetRequiresFx() { + t.Fatalf("expected requires_fx=true to be derived from FX intent") + } + if got.GetAttributes()["fee_treatment"] != "add_to_source" { + t.Fatalf("expected fee_treatment compatibility attribute to be set") + } + if got.GetAttributes()["comment"] != "invoice-7" { + t.Fatalf("expected comment attribute to be preserved") + } +} + +func TestProtoIntentFromModel_DerivesRequiresFXFromCurrencyMismatchWithoutFX(t *testing.T) { + src := model.PaymentIntent{ + Amount: &paymenttypes.Money{ + Amount: "10", + Currency: "USDT", + }, + SettlementCurrency: "RUB", + } + + got := protoIntentFromModel(src) + if !got.GetRequiresFx() { + t.Fatalf("expected requires_fx=true for currency mismatch") + } +} diff --git a/api/payments/quotation/internal/service/quotation/helpers_economics_test.go b/api/payments/quotation/internal/service/quotation/helpers_economics_test.go index 6eb0de66..8898fa87 100644 --- a/api/payments/quotation/internal/service/quotation/helpers_economics_test.go +++ b/api/payments/quotation/internal/service/quotation/helpers_economics_test.go @@ -3,70 +3,136 @@ package quotation import ( "testing" + 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" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) -func TestComputeAggregates_AddToSource(t *testing.T) { +func TestComputeAggregates_FeeTreatmentMatrix(t *testing.T) { + fxQuote := &oraclev1.Quote{ + Pair: &fxv1.CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Price: &moneyv1.Decimal{ + Value: "76", + }, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + } + + cases := []struct { + name string + pay *moneyv1.Money + settlement *moneyv1.Money + fee *moneyv1.Money + networkFee *moneyv1.Money + fxQuote *oraclev1.Quote + feeTreatment quotationv2.FeeTreatment + wantDebit string + wantSettlement string + }{ + { + name: "unspecified fee treatment defaults to add_to_source", + pay: &moneyv1.Money{Amount: "100", Currency: "USD"}, + settlement: &moneyv1.Money{Amount: "100", Currency: "USD"}, + fee: &moneyv1.Money{Amount: "10", Currency: "USD"}, + networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, + wantDebit: "112", + wantSettlement: "100", + }, + { + name: "add_to_source charges payer for fee and network", + pay: &moneyv1.Money{Amount: "100", Currency: "USD"}, + settlement: &moneyv1.Money{Amount: "100", Currency: "USD"}, + fee: &moneyv1.Money{Amount: "10", Currency: "USD"}, + networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantDebit: "112", + wantSettlement: "100", + }, + { + name: "deduct_from_destination charges recipient for fee and network", + pay: &moneyv1.Money{Amount: "100", Currency: "USD"}, + settlement: &moneyv1.Money{Amount: "100", Currency: "USD"}, + fee: &moneyv1.Money{Amount: "10", Currency: "USD"}, + networkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantDebit: "100", + wantSettlement: "88", + }, + { + name: "add_to_source keeps settlement amount unchanged in FX path", + pay: &moneyv1.Money{Amount: "100", Currency: "USDT"}, + settlement: &moneyv1.Money{Amount: "7600", Currency: "RUB"}, + fee: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + networkFee: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + fxQuote: fxQuote, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantDebit: "102", + wantSettlement: "7600", + }, + { + name: "deduct_from_destination converts charges in FX path", + pay: &moneyv1.Money{Amount: "100", Currency: "USDT"}, + settlement: &moneyv1.Money{Amount: "7600", Currency: "RUB"}, + fee: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + networkFee: &moneyv1.Money{Amount: "1", Currency: "USDT"}, + fxQuote: fxQuote, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantDebit: "100", + wantSettlement: "7448", + }, + { + name: "no charges keeps aggregates intact", + pay: &moneyv1.Money{Amount: "100", Currency: "USD"}, + settlement: &moneyv1.Money{Amount: "100", Currency: "USD"}, + feeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantDebit: "100", + wantSettlement: "100", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var network *chainv1.EstimateTransferFeeResponse + if tc.networkFee != nil { + network = &chainv1.EstimateTransferFeeResponse{NetworkFee: tc.networkFee} + } + + debit, settlement := computeAggregates( + tc.pay, + tc.settlement, + tc.fee, + network, + tc.fxQuote, + tc.feeTreatment, + ) + if debit == nil || settlement == nil { + t.Fatalf("expected aggregate amounts") + } + if got, want := debit.GetAmount(), tc.wantDebit; got != want { + t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) + } + if got, want := settlement.GetAmount(), tc.wantSettlement; got != want { + t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + } + }) + } +} + +func TestComputeAggregates_NilPayReturnsNil(t *testing.T) { debit, settlement := computeAggregates( - &moneyv1.Money{Amount: "100", Currency: "USD"}, + nil, &moneyv1.Money{Amount: "100", Currency: "USD"}, &moneyv1.Money{Amount: "10", Currency: "USD"}, nil, nil, quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, ) - if debit == nil || settlement == nil { - t.Fatalf("expected aggregate amounts") - } - if got, want := debit.GetAmount(), "110"; got != want { - t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) - } - if got, want := settlement.GetAmount(), "100"; got != want { - t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) - } -} - -func TestComputeAggregates_DeductFromDestination(t *testing.T) { - debit, settlement := computeAggregates( - &moneyv1.Money{Amount: "100", Currency: "USD"}, - &moneyv1.Money{Amount: "100", Currency: "USD"}, - &moneyv1.Money{Amount: "10", Currency: "USD"}, - nil, - nil, - quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, - ) - if debit == nil || settlement == nil { - t.Fatalf("expected aggregate amounts") - } - if got, want := debit.GetAmount(), "100"; got != want { - t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) - } - if got, want := settlement.GetAmount(), "90"; got != want { - t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) - } -} - -func TestComputeAggregates_NetworkFeeFollowsFeeTreatment(t *testing.T) { - networkFee := &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Amount: "2", Currency: "USD"}, - } - debit, settlement := computeAggregates( - &moneyv1.Money{Amount: "100", Currency: "USD"}, - &moneyv1.Money{Amount: "100", Currency: "USD"}, - &moneyv1.Money{Amount: "10", Currency: "USD"}, - networkFee, - nil, - quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, - ) - if debit == nil || settlement == nil { - t.Fatalf("expected aggregate amounts") - } - if got, want := debit.GetAmount(), "100"; got != want { - t.Fatalf("unexpected debit amount: got=%s want=%s", got, want) - } - if got, want := settlement.GetAmount(), "88"; got != want { - t.Fatalf("unexpected settlement amount: got=%s want=%s", got, want) + if debit != nil || settlement != nil { + t.Fatalf("expected nil aggregates, got debit=%v settlement=%v", debit, settlement) } } diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go index 161949d6..0f6cbabc 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -59,10 +59,7 @@ func shouldRequestFX(intent *sharedv1.PaymentIntent) bool { if intent == nil { return false } - if fxIntentForQuote(intent) != nil { - return true - } - return intent.GetRequiresFx() + return fxIntentForQuote(intent) != nil } func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent { diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers_test.go b/api/payments/quotation/internal/service/quotation/internal_helpers_test.go new file mode 100644 index 00000000..90b030e7 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/internal_helpers_test.go @@ -0,0 +1,116 @@ +package quotation + +import ( + "testing" + + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" +) + +func TestResolvedFeeTreatmentForQuote(t *testing.T) { + cases := []struct { + name string + intent *sharedv1.PaymentIntent + want quotationv2.FeeTreatment + }{ + { + name: "nil intent defaults", + intent: nil, + want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "no attributes defaults", + intent: &sharedv1.PaymentIntent{}, + want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "snake key parsed", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "fee_treatment": "deduct_from_destination", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + { + name: "camel key parsed", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "feeTreatment": "fee_treatment_deduct_from_destination", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + { + name: "invalid value falls back to default", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "fee_treatment": "something_else", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "snake key takes precedence over camel key", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "fee_treatment": "add_to_source", + "feeTreatment": "deduct_from_destination", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + }, + { + name: "value is trimmed and case-insensitive", + intent: &sharedv1.PaymentIntent{ + Attributes: map[string]string{ + "fee_treatment": " FEE_TREATMENT_DEDUCT_FROM_DESTINATION ", + }, + }, + want: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := resolvedFeeTreatmentForQuote(tc.intent) + if got != tc.want { + t.Fatalf("unexpected fee treatment: got=%s want=%s", got.String(), tc.want.String()) + } + }) + } +} + +func TestShouldRequestFX_DoesNotDependOnRequiresFxFlag(t *testing.T) { + intent := &sharedv1.PaymentIntent{ + Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"}, + SettlementCurrency: "USDT", + RequiresFx: true, + } + if got := shouldRequestFX(intent); got { + t.Fatalf("expected shouldRequestFX=false when only requires_fx=true without FX data") + } +} + +func TestShouldRequestFX_UsesFXIntentOrCurrencyDifference(t *testing.T) { + withPair := &sharedv1.PaymentIntent{ + Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"}, + Fx: &sharedv1.FXIntent{ + Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + }, + } + if got := shouldRequestFX(withPair); !got { + t.Fatalf("expected shouldRequestFX=true for explicit fx intent") + } + + withDerived := &sharedv1.PaymentIntent{ + Amount: &moneyv1.Money{Amount: "10", Currency: "USDT"}, + SettlementCurrency: "RUB", + } + if got := shouldRequestFX(withDerived); !got { + t.Fatalf("expected shouldRequestFX=true for derived FX from currency mismatch") + } +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go index eb28aaa2..205f4163 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go +++ b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go @@ -130,9 +130,9 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1. if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE } - resolvedFeeTreatment := payecon.DefaultFeeTreatment() - if attrs := in.Intent.Attributes; len(attrs) > 0 { - resolvedFeeTreatment = payecon.ResolveFeeTreatmentFromStringOrDefault(attrs["fee_treatment"]) + resolvedFeeTreatment := feeTreatmentToProto(in.Intent.FeeTreatment) + if resolvedFeeTreatment == quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED { + resolvedFeeTreatment = payecon.DefaultFeeTreatment() } return "e_computation_service.ComputedQuote{ DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()), @@ -148,6 +148,17 @@ func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1. } } +func feeTreatmentToProto(value model.FeeTreatment) quotationv2.FeeTreatment { + switch value { + case model.FeeTreatmentDeductFromDestination: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + case model.FeeTreatmentAddToSource: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED + } +} + func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification { if src == nil { return nil diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go index a4db0da1..129f59a8 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go @@ -15,6 +15,7 @@ import ( feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -167,6 +168,68 @@ func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing } } +func TestBuildPlan_ResolvesIndependentEconomicsKnobs(t *testing.T) { + svc := New(nil) + orgID := bson.NewObjectID() + intent := sampleCardQuoteIntent() + intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived + intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 1 { + t.Fatalf("expected single plan item") + } + item := planModel.Items[0] + if item == nil { + t.Fatalf("expected plan item") + } + if got, want := item.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := item.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { + t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String()) + } +} + +func TestBuildPlan_DefaultsResolvedFeeTreatmentWhenUnspecified(t *testing.T) { + svc := New(nil) + orgID := bson.NewObjectID() + intent := sampleCardQuoteIntent() + intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived + intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentUnspecified + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 1 { + t.Fatalf("expected single plan item") + } + item := planModel.Items[0] + if item == nil { + t.Fatalf("expected plan item") + } + if got, want := item.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := item.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected default resolved fee treatment: got=%s want=%s", got.String(), want.String()) + } +} + func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) { svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{ items: []*model.GatewayInstanceDescriptor{ @@ -405,6 +468,49 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) { if got := core.lastQuoteIn.ExecutionConditions.GetPrefundingRequired(); !got { t.Fatalf("expected prefunding_required in build quote input for reserve mode") } + if got, want := result.Quote.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { + t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := result.Quote.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String()) + } +} + +func TestCompute_PropagatesIndependentResolvedEconomics(t *testing.T) { + core := &fakeCore{ + quote: &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"}, + }, + expiresAt: time.Unix(1000, 0), + } + svc := New(core) + + orgID := bson.NewObjectID() + intent := sampleCardQuoteIntent() + intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived + intent.FeeTreatment = transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination + + output, err := svc.Compute(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + PreviewOnly: true, + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if output == nil || len(output.Results) != 1 { + t.Fatalf("expected single result") + } + if output.Results[0].Quote == nil { + t.Fatalf("expected quote") + } + if got, want := output.Results[0].Quote.ResolvedSettlementMode, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved settlement mode: got=%s want=%s", got.String(), want.String()) + } + if got, want := output.Results[0].Quote.ResolvedFeeTreatment, quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { + t.Fatalf("unexpected resolved fee treatment: got=%s want=%s", got.String(), want.String()) + } } func TestCompute_PreviewMarksIndicativeReadiness(t *testing.T) { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher_test.go new file mode 100644 index 00000000..53bd610f --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher_test.go @@ -0,0 +1,71 @@ +package quote_computation_service + +import ( + "testing" + + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func TestEnsureComputedQuote_UsesItemResolvedEconomicsWhenQuoteUnset(t *testing.T) { + src := &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + } + item := &QuoteComputationPlanItem{ + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + } + + got := ensureComputedQuote(src, item) + if got == nil { + t.Fatalf("expected quote") + } + if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED { + t.Fatalf("unexpected resolved settlement mode: %s", got.ResolvedSettlementMode.String()) + } + if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION { + t.Fatalf("unexpected resolved fee treatment: %s", got.ResolvedFeeTreatment.String()) + } +} + +func TestEnsureComputedQuote_PreservesQuoteResolvedEconomics(t *testing.T) { + src := &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + } + item := &QuoteComputationPlanItem{ + ResolvedSettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + ResolvedFeeTreatment: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + } + + got := ensureComputedQuote(src, item) + if got == nil { + t.Fatalf("expected quote") + } + if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE { + t.Fatalf("unexpected resolved settlement mode: %s", got.ResolvedSettlementMode.String()) + } + if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE { + t.Fatalf("unexpected resolved fee treatment: %s", got.ResolvedFeeTreatment.String()) + } +} + +func TestEnsureComputedQuote_DefaultsResolvedEconomicsWhenUnset(t *testing.T) { + src := &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + } + item := &QuoteComputationPlanItem{} + + got := ensureComputedQuote(src, item) + if got == nil { + t.Fatalf("expected quote") + } + if got.ResolvedSettlementMode != paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE { + t.Fatalf("unexpected default settlement mode: %s", got.ResolvedSettlementMode.String()) + } + if got.ResolvedFeeTreatment != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE { + t.Fatalf("unexpected default fee treatment: %s", got.ResolvedFeeTreatment.String()) + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go index cd3293f6..318ea6cf 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go @@ -78,6 +78,7 @@ func clonePaymentIntent(src model.PaymentIntent) model.PaymentIntent { FX: nil, FeePolicy: src.FeePolicy, SettlementMode: src.SettlementMode, + FeeTreatment: src.FeeTreatment, SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)), Attributes: cloneStringMap(src.Attributes), Customer: src.Customer, diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go index dcdc7267..9c4cc6dd 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go @@ -25,6 +25,7 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model RequiresFX: src.RequiresFX, Attributes: cloneStringMap(src.Attributes), SettlementMode: modelSettlementMode(src.SettlementMode), + FeeTreatment: modelFeeTreatment(src.FeeTreatment), SettlementCurrency: settlementCurrency, } } @@ -106,3 +107,14 @@ func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) mode return model.SettlementModeFixSource } } + +func modelFeeTreatment(value transfer_intent_hydrator.QuoteFeeTreatment) model.FeeTreatment { + switch value { + case transfer_intent_hydrator.QuoteFeeTreatmentDeductFromDestination: + return model.FeeTreatmentDeductFromDestination + case transfer_intent_hydrator.QuoteFeeTreatmentAddToSource: + return model.FeeTreatmentAddToSource + default: + return model.FeeTreatmentAddToSource + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index 4d15ba93..08c15aa6 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -335,7 +335,7 @@ func feeTriggerForIntent(intent *sharedv1.PaymentIntent) feesv1.Trigger { if intent == nil { return feesv1.Trigger_TRIGGER_UNSPECIFIED } - trigger := triggerFromKind(intent.GetKind(), intent.GetRequiresFx()) + trigger := triggerFromKind(intent.GetKind(), shouldRequestFX(intent)) if trigger != feesv1.Trigger_TRIGGER_FX_CONVERSION && isManagedWalletEndpoint(intent.GetSource()) && isLedgerEndpoint(intent.GetDestination()) { return feesv1.Trigger_TRIGGER_CAPTURE } @@ -488,17 +488,19 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.Payme } func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteRequest) (*oraclev1.Quote, error) { + intent := req.GetIntent() + fxRequired := shouldRequestFX(intent) + if !s.deps.oracle.available() { - if req.GetIntent().GetRequiresFx() { + if fxRequired { 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() { + if fxRequired { return nil, merrors.InvalidArgument("fx intent missing") } return nil, nil @@ -547,7 +549,7 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteR return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error())) } if quote == nil { - if intent.GetRequiresFx() { + if fxRequired { return nil, merrors.Internal("orchestrator: fx quote missing") } return nil, nil diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go index 98a8ead9..485e4928 100644 --- a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go @@ -281,6 +281,11 @@ func TestValidateQuotePayment_EconomicsKnobsAreIndependent(t *testing.T) { mode paymentv1.SettlementMode fee quotationv2.FeeTreatment }{ + { + name: "both knobs omitted", + mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, + }, { name: "fix_source with add_to_source", mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go index 97091e12..e115a766 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -136,9 +136,7 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn SettlementCurrency: settlementCurrency, RequiresFX: requiresFX, Attributes: map[string]string{ - "initiator_ref": strings.TrimSpace(in.InitiatorRef), - "settlement_mode": string(settlementMode), - "fee_treatment": string(feeTreatment), + "initiator_ref": strings.TrimSpace(in.InitiatorRef), }, } if intent.Comment != "" { diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go index 28c9ce26..4b4a0d60 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go @@ -517,6 +517,148 @@ func TestHydrateOne_DefaultsSettlementModeWhenOnlyFeeTreatmentProvided(t *testin } } +func TestHydrateOne_ResolvesEconomicsMatrix(t *testing.T) { + h := New(nil) + cases := []struct { + name string + mode paymentv1.SettlementMode + fee quotationv2.FeeTreatment + wantMode QuoteSettlementMode + wantFee QuoteFeeTreatment + }{ + { + name: "defaults both when unspecified", + mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, + wantMode: QuoteSettlementModeFixSource, + wantFee: QuoteFeeTreatmentAddToSource, + }, + { + name: "preserves fix_source with add_to_source", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantMode: QuoteSettlementModeFixSource, + wantFee: QuoteFeeTreatmentAddToSource, + }, + { + name: "preserves fix_source with deduct_from_destination", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantMode: QuoteSettlementModeFixSource, + wantFee: QuoteFeeTreatmentDeductFromDestination, + }, + { + name: "preserves fix_received with add_to_source", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantMode: QuoteSettlementModeFixReceived, + wantFee: QuoteFeeTreatmentAddToSource, + }, + { + name: "preserves fix_received with deduct_from_destination", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantMode: QuoteSettlementModeFixReceived, + wantFee: QuoteFeeTreatmentDeductFromDestination, + }, + { + name: "defaults settlement when only fee provided", + mode: paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, + fee: quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, + wantMode: QuoteSettlementModeFixSource, + wantFee: QuoteFeeTreatmentDeductFromDestination, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: economicsCardIntent(t, tc.mode, tc.fee), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.SettlementMode != tc.wantMode { + t.Fatalf("unexpected settlement mode: got=%s want=%s", got.SettlementMode, tc.wantMode) + } + if got.FeeTreatment != tc.wantFee { + t.Fatalf("unexpected fee treatment: got=%s want=%s", got.FeeTreatment, tc.wantFee) + } + if _, ok := got.Attributes["settlement_mode"]; ok { + t.Fatalf("settlement_mode must not be persisted in attributes") + } + if _, ok := got.Attributes["fee_treatment"]; ok { + t.Fatalf("fee_treatment must not be persisted in attributes") + } + }) + } +} + +func TestResolveEconomics_InvalidInputs(t *testing.T) { + cases := []struct { + name string + mode paymentv1.SettlementMode + fee quotationv2.FeeTreatment + wantErrPart string + }{ + { + name: "invalid settlement mode rejected", + mode: paymentv1.SettlementMode(99), + fee: quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, + wantErrPart: "intent.settlement_mode is invalid", + }, + { + name: "invalid fee treatment rejected", + mode: paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, + fee: quotationv2.FeeTreatment(99), + wantErrPart: "intent.fee_treatment is invalid", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := resolveEconomics(tc.mode, tc.fee) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), tc.wantErrPart) { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func economicsCardIntent(t *testing.T, mode paymentv1.SettlementMode, fee quotationv2.FeeTreatment) *quotationv2.QuoteIntent { + t.Helper() + return "ationv2.QuoteIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src"}), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + }), + }, + }, + }, + Amount: newMoney("1", "USD"), + SettlementMode: mode, + FeeTreatment: fee, + } +} + type fakeMethodsClient struct { getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error) } diff --git a/api/payments/storage/model/payment.go b/api/payments/storage/model/payment.go index 6b8d95f1..ea917c1f 100644 --- a/api/payments/storage/model/payment.go +++ b/api/payments/storage/model/payment.go @@ -30,6 +30,15 @@ const ( SettlementModeFixReceived SettlementMode = "fix_received" ) +// FeeTreatment defines who covers fees in settlement math. +type FeeTreatment string + +const ( + FeeTreatmentUnspecified FeeTreatment = "unspecified" + FeeTreatmentAddToSource FeeTreatment = "add_to_source" + FeeTreatmentDeductFromDestination FeeTreatment = "deduct_from_destination" +) + // CommitPolicy controls when a step is committed during orchestration. type CommitPolicy string @@ -233,6 +242,7 @@ type PaymentIntent struct { FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"` FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` SettlementMode SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` + FeeTreatment FeeTreatment `bson:"feeTreatment,omitempty" json:"feeTreatment,omitempty"` SettlementCurrency string `bson:"settlementCurrency,omitempty" json:"settlementCurrency,omitempty"` Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"` @@ -413,6 +423,7 @@ func (p *Payment) Normalize() { p.Intent.Attributes[k] = strings.TrimSpace(v) } } + p.Intent.FeeTreatment = FeeTreatment(strings.TrimSpace(string(p.Intent.FeeTreatment))) p.Intent.SettlementCurrency = strings.TrimSpace(p.Intent.SettlementCurrency) if p.Intent.Customer != nil { p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID) diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index 1f0b9c96..12b4a52b 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -1,23 +1,20 @@ package srequest import ( - "strings" - "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) type PaymentIntent struct { - Kind PaymentKind `json:"kind,omitempty"` - Source *Endpoint `json:"source,omitempty"` - Destination *Endpoint `json:"destination,omitempty"` - Amount *paymenttypes.Money `json:"amount,omitempty"` - FX *FXIntent `json:"fx,omitempty"` - SettlementMode SettlementMode `json:"settlement_mode,omitempty"` - FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"` - SettlementCurrency string `json:"settlement_currency,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` - Customer *Customer `json:"customer,omitempty"` + Kind PaymentKind `json:"kind,omitempty"` + Source *Endpoint `json:"source,omitempty"` + Destination *Endpoint `json:"destination,omitempty"` + Amount *paymenttypes.Money `json:"amount,omitempty"` + FX *FXIntent `json:"fx,omitempty"` + SettlementMode SettlementMode `json:"settlement_mode,omitempty"` + FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Customer *Customer `json:"customer,omitempty"` } type AssetResolverStub struct{} @@ -55,9 +52,5 @@ func (p *PaymentIntent) Validate() error { } } - if strings.TrimSpace(p.SettlementCurrency) != "" { - return merrors.InvalidArgument("settlement_currency must not be provided; it is derived from fx intent or amount currency", "intent.settlement_currency") - } - return nil } diff --git a/api/server/interface/api/srequest/payment_intent_validate_test.go b/api/server/interface/api/srequest/payment_intent_validate_test.go index 0cf4ec3c..5220199c 100644 --- a/api/server/interface/api/srequest/payment_intent_validate_test.go +++ b/api/server/interface/api/srequest/payment_intent_validate_test.go @@ -6,12 +6,11 @@ import ( paymenttypes "github.com/tech/sendico/pkg/payments/types" ) -func TestPaymentIntentValidate_RejectsSettlementCurrency(t *testing.T) { +func TestPaymentIntentValidate_AcceptsBaseIntentWithoutFX(t *testing.T) { intent := mustValidBaseIntent(t) - intent.SettlementCurrency = "RUB" - if err := intent.Validate(); err == nil { - t.Fatalf("expected validation error for settlement_currency") + if err := intent.Validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) } } diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 093aaa13..cf66b04e 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -37,9 +37,6 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e if err != nil { return nil, err } - if strings.TrimSpace(intent.SettlementCurrency) != "" { - return nil, merrors.InvalidArgument("settlement_currency must not be provided; it is derived from fx intent or amount currency") - } settlementCurrency := resolveSettlementCurrency(intent) if settlementCurrency == "" { return nil, merrors.InvalidArgument("unable to derive settlement currency from intent") diff --git a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go index a450545f..89f8f349 100644 --- a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go +++ b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go @@ -122,7 +122,7 @@ func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T } } -func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) { +func TestMapQuoteIntent_DerivesSettlementCurrencyFromAmountWithoutFX(t *testing.T) { source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ ManagedWalletRef: "wallet-source-1", }, nil) @@ -142,17 +142,20 @@ func TestMapQuoteIntent_RejectsExplicitSettlementCurrency(t *testing.T) { } intent := &srequest.PaymentIntent{ - Kind: srequest.PaymentKindPayout, - Source: &source, - Destination: &destination, - Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, - SettlementMode: srequest.SettlementModeFixSource, - FeeTreatment: srequest.FeeTreatmentAddToSource, - SettlementCurrency: "RUB", + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatmentAddToSource, } - if _, err := mapQuoteIntent(intent); err == nil { - t.Fatalf("expected error for explicit settlement_currency") + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.GetSettlementCurrency() != "USDT" { + t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) } } diff --git a/frontend/pshared/lib/data/dto/payment/fx_quote.dart b/frontend/pshared/lib/data/dto/payment/fx_quote.dart index 6487252e..a5db91e2 100644 --- a/frontend/pshared/lib/data/dto/payment/fx_quote.dart +++ b/frontend/pshared/lib/data/dto/payment/fx_quote.dart @@ -4,7 +4,6 @@ import 'package:pshared/data/dto/money.dart'; part 'fx_quote.g.dart'; - @JsonSerializable() class FxQuoteDTO { final String? quoteRef; @@ -15,6 +14,7 @@ class FxQuoteDTO { final MoneyDTO? baseAmount; final MoneyDTO? quoteAmount; final int? expiresAtUnixMs; + final int? pricedAtUnixMs; final String? provider; final String? rateRef; @@ -30,11 +30,13 @@ class FxQuoteDTO { this.baseAmount, this.quoteAmount, this.expiresAtUnixMs, + this.pricedAtUnixMs, this.provider, this.rateRef, this.firm = false, }); - factory FxQuoteDTO.fromJson(Map json) => _$FxQuoteDTOFromJson(json); + factory FxQuoteDTO.fromJson(Map json) => + _$FxQuoteDTOFromJson(json); Map toJson() => _$FxQuoteDTOToJson(this); } diff --git a/frontend/pshared/lib/data/mapper/payment/fx_quote.dart b/frontend/pshared/lib/data/mapper/payment/fx_quote.dart index a09dec5c..078d5e50 100644 --- a/frontend/pshared/lib/data/mapper/payment/fx_quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/fx_quote.dart @@ -2,35 +2,36 @@ import 'package:pshared/data/dto/payment/fx_quote.dart'; import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/models/payment/fx/quote.dart'; - extension FxQuoteDTOMapper on FxQuoteDTO { FxQuote toDomain() => FxQuote( - quoteRef: quoteRef, - baseCurrency: baseCurrency, - quoteCurrency: quoteCurrency, - side: side, - price: price, - baseAmount: baseAmount?.toDomain(), - quoteAmount: quoteAmount?.toDomain(), - expiresAtUnixMs: expiresAtUnixMs, - provider: provider, - rateRef: rateRef, - firm: firm ?? false, - ); + quoteRef: quoteRef, + baseCurrency: baseCurrency, + quoteCurrency: quoteCurrency, + side: side, + price: price, + baseAmount: baseAmount?.toDomain(), + quoteAmount: quoteAmount?.toDomain(), + expiresAtUnixMs: expiresAtUnixMs, + pricedAtUnixMs: pricedAtUnixMs, + provider: provider, + rateRef: rateRef, + firm: firm ?? false, + ); } extension FxQuoteMapper on FxQuote { FxQuoteDTO toDTO() => FxQuoteDTO( - quoteRef: quoteRef, - baseCurrency: baseCurrency, - quoteCurrency: quoteCurrency, - side: side, - price: price, - baseAmount: baseAmount?.toDTO(), - quoteAmount: quoteAmount?.toDTO(), - expiresAtUnixMs: expiresAtUnixMs, - provider: provider, - rateRef: rateRef, - firm: firm, - ); + quoteRef: quoteRef, + baseCurrency: baseCurrency, + quoteCurrency: quoteCurrency, + side: side, + price: price, + baseAmount: baseAmount?.toDTO(), + quoteAmount: quoteAmount?.toDTO(), + expiresAtUnixMs: expiresAtUnixMs, + pricedAtUnixMs: pricedAtUnixMs, + provider: provider, + rateRef: rateRef, + firm: firm, + ); } diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index 88b3eb02..f8f5b5e9 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -1,5 +1,5 @@ import 'package:pshared/data/dto/payment/payment.dart'; -import 'package:pshared/data/mapper/payment/payment_quote.dart'; +import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/state.dart'; diff --git a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart b/frontend/pshared/lib/data/mapper/payment/quote.dart similarity index 95% rename from frontend/pshared/lib/data/mapper/payment/payment_quote.dart rename to frontend/pshared/lib/data/mapper/payment/quote.dart index 5763a59c..6a825d74 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote.dart @@ -1,5 +1,5 @@ import 'package:pshared/data/dto/payment/payment_quote.dart'; -import 'package:pshared/data/mapper/payment/fee_line.dart'; +import 'package:pshared/data/mapper/payment/fees/line.dart'; import 'package:pshared/data/mapper/payment/fx_quote.dart'; import 'package:pshared/data/mapper/money.dart'; import 'package:pshared/data/mapper/payment/network_fee.dart'; diff --git a/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart index 4d77f60c..6e0c84f2 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart @@ -1,5 +1,5 @@ import 'package:pshared/data/dto/payment/quotes.dart'; -import 'package:pshared/data/mapper/payment/payment_quote.dart'; +import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/data/mapper/payment/quote/aggregate.dart'; import 'package:pshared/models/payment/quote/quotes.dart'; diff --git a/frontend/pshared/lib/models/payment/fx/quote.dart b/frontend/pshared/lib/models/payment/fx/quote.dart index 44d356b9..1e62b9de 100644 --- a/frontend/pshared/lib/models/payment/fx/quote.dart +++ b/frontend/pshared/lib/models/payment/fx/quote.dart @@ -1,6 +1,5 @@ import 'package:pshared/models/money.dart'; - class FxQuote { final String? quoteRef; final String? baseCurrency; @@ -10,6 +9,7 @@ class FxQuote { final Money? baseAmount; final Money? quoteAmount; final int? expiresAtUnixMs; + final int? pricedAtUnixMs; final String? provider; final String? rateRef; final bool firm; @@ -23,6 +23,7 @@ class FxQuote { required this.baseAmount, required this.quoteAmount, required this.expiresAtUnixMs, + required this.pricedAtUnixMs, required this.provider, required this.rateRef, this.firm = false, diff --git a/frontend/pshared/lib/provider/payment/multiple/quotation.dart b/frontend/pshared/lib/provider/payment/multiple/quotation.dart index 45e3fb7a..6881d3e8 100644 --- a/frontend/pshared/lib/provider/payment/multiple/quotation.dart +++ b/frontend/pshared/lib/provider/payment/multiple/quotation.dart @@ -12,7 +12,6 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/multiple.dart'; import 'package:pshared/utils/exception.dart'; - class MultiQuotationProvider extends ChangeNotifier { static const Duration _autoRefreshLead = Duration(seconds: 5); @@ -87,10 +86,13 @@ class MultiQuotationProvider extends ChangeNotifier { _setResource(_quotation.copyWith(isLoading: true, error: null)); try { + final effectiveIdempotencyKey = previewOnly + ? '' + : (idempotencyKey ?? const Uuid().v4()); final response = await MultiplePaymentsService.getQuotation( organization.current.id, QuotePaymentsRequest( - idempotencyKey: idempotencyKey ?? const Uuid().v4(), + idempotencyKey: effectiveIdempotencyKey, metadata: metadata, intents: intents.map((intent) => intent.toDTO()).toList(), previewOnly: previewOnly, diff --git a/frontend/pshared/lib/service/payment/quotation.dart b/frontend/pshared/lib/service/payment/quotation.dart index 756a894c..52474b5d 100644 --- a/frontend/pshared/lib/service/payment/quotation.dart +++ b/frontend/pshared/lib/service/payment/quotation.dart @@ -4,7 +4,7 @@ import 'package:pshared/api/requests/payment/quote.dart'; import 'package:pshared/api/requests/payment/quotes.dart'; import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/api/responses/payment/quotes.dart'; -import 'package:pshared/data/mapper/payment/payment_quote.dart'; +import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/data/mapper/payment/quote/quotes.dart'; import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/quote/quotes.dart'; diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 17123984..1159a425 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -5,6 +5,7 @@ import 'package:test/test.dart'; import 'package:pshared/api/requests/payment/initiate.dart'; import 'package:pshared/api/requests/payment/initiate_payments.dart'; import 'package:pshared/api/requests/payment/quote.dart'; +import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/data/dto/money.dart'; import 'package:pshared/data/dto/payment/endpoint.dart'; import 'package:pshared/data/dto/payment/intent/payment.dart'; @@ -40,7 +41,7 @@ void main() { test('quote payment request uses expected backend field names', () { final request = QuotePaymentRequest( - idempotencyKey: 'idem-1', + idempotencyKey: '', previewOnly: true, intent: const PaymentIntentDTO( kind: 'payout', @@ -60,7 +61,7 @@ void main() { final json = jsonDecode(jsonEncode(request.toJson())) as Map; - expect(json['idempotencyKey'], equals('idem-1')); + expect(json['idempotencyKey'], equals('')); expect(json['previewOnly'], isTrue); expect(json['intent'], isA>()); @@ -75,6 +76,34 @@ void main() { expect(destination['type'], equals('cardToken')); }); + test('quote response parses backend fx quote pricedAtUnixMs', () { + final response = PaymentQuoteResponse.fromJson({ + 'accessToken': {'token': 'token', 'expiration': '2026-02-25T00:00:00Z'}, + 'idempotencyKey': 'idem-1', + 'quote': { + 'quoteRef': 'q-1', + 'debitAmount': {'amount': '10', 'currency': 'USDT'}, + 'expectedSettlementAmount': {'amount': '760', 'currency': 'RUB'}, + 'fxQuote': { + 'quoteRef': 'fx-1', + 'baseCurrency': 'USDT', + 'quoteCurrency': 'RUB', + 'side': 'sell_base_buy_quote', + 'price': '76', + 'baseAmount': {'amount': '10', 'currency': 'USDT'}, + 'quoteAmount': {'amount': '760', 'currency': 'RUB'}, + 'expiresAtUnixMs': 1771945907749, + 'pricedAtUnixMs': 1771945907000, + 'provider': 'binance', + 'rateRef': 'rate-1', + 'firm': false, + }, + }, + }); + + expect(response.quote.fxQuote?.pricedAtUnixMs, equals(1771945907000)); + }); + test('initiate payment by quote keeps expected fields', () { final request = InitiatePaymentRequest( idempotencyKey: 'idem-2', -- 2.49.1 From da11be526a6e0afc3b3cc4448af529d8557dbe64 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Tue, 24 Feb 2026 21:18:23 +0100 Subject: [PATCH 06/23] removed legacy from bff --- api/server/interface/api/sresponse/payment.go | 74 ++++++++++----- .../lib/data/dto/payment/network_fee.dart | 20 ---- .../lib/data/dto/payment/payment_quote.dart | 28 ++---- .../lib/data/dto/payment/quote_aggregate.dart | 24 ----- .../lib/data/dto/payment/quote_amounts.dart | 22 +++++ .../lib/data/dto/payment/quote_fees.dart | 16 ++++ .../pshared/lib/data/dto/payment/quotes.dart | 7 +- .../lib/data/mapper/payment/network_fee.dart | 18 ---- .../lib/data/mapper/payment/quote.dart | 22 ++--- .../data/mapper/payment/quote/aggregate.dart | 22 ----- .../data/mapper/payment/quote/amounts.dart | 19 ++++ .../lib/data/mapper/payment/quote/fees.dart | 13 +++ .../lib/data/mapper/payment/quote/quotes.dart | 7 +- .../lib/models/payment/fees/network.dart | 12 --- .../lib/models/payment/quote/aggregate.dart | 16 ---- .../lib/models/payment/quote/amounts.dart | 13 +++ .../lib/models/payment/quote/fees.dart | 7 ++ .../lib/models/payment/quote/quote.dart | 22 ++--- .../lib/models/payment/quote/quotes.dart | 8 +- .../provider/payment/multiple/quotation.dart | 6 +- .../provider/payment/quotation/quotation.dart | 43 +++++---- .../lib/utils/payment/quote_helpers.dart | 92 +++++++++++++++++++ .../test/payment/request_dto_format_test.dart | 20 +++- .../report/details/summary_card/widget.dart | 19 ++-- .../pweb/lib/providers/multiple_payouts.dart | 38 ++++---- .../pweb/lib/utils/report/payment_mapper.dart | 28 +++--- 26 files changed, 343 insertions(+), 273 deletions(-) delete mode 100644 frontend/pshared/lib/data/dto/payment/network_fee.dart delete mode 100644 frontend/pshared/lib/data/dto/payment/quote_aggregate.dart create mode 100644 frontend/pshared/lib/data/dto/payment/quote_amounts.dart create mode 100644 frontend/pshared/lib/data/dto/payment/quote_fees.dart delete mode 100644 frontend/pshared/lib/data/mapper/payment/network_fee.dart delete mode 100644 frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart create mode 100644 frontend/pshared/lib/data/mapper/payment/quote/amounts.dart create mode 100644 frontend/pshared/lib/data/mapper/payment/quote/fees.dart delete mode 100644 frontend/pshared/lib/models/payment/fees/network.dart delete mode 100644 frontend/pshared/lib/models/payment/quote/aggregate.dart create mode 100644 frontend/pshared/lib/models/payment/quote/amounts.dart create mode 100644 frontend/pshared/lib/models/payment/quote/fees.dart create mode 100644 frontend/pshared/lib/utils/payment/quote_helpers.dart diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 4d79b82f..31a35a8a 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -41,26 +41,26 @@ type FxQuote struct { } type PaymentQuote struct { - QuoteRef string `json:"quoteRef,omitempty"` - DebitAmount *paymenttypes.Money `json:"debitAmount,omitempty"` - DebitSettlementAmount *paymenttypes.Money `json:"debitSettlementAmount,omitempty"` - ExpectedSettlementAmount *paymenttypes.Money `json:"expectedSettlementAmount,omitempty"` - ExpectedFeeTotal *paymenttypes.Money `json:"expectedFeeTotal,omitempty"` - FeeLines []FeeLine `json:"feeLines,omitempty"` - FxQuote *FxQuote `json:"fxQuote,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` + Amounts *QuoteAmounts `json:"amounts,omitempty"` + Fees *QuoteFees `json:"fees,omitempty"` + FxQuote *FxQuote `json:"fxQuote,omitempty"` } -type PaymentQuoteAggregate struct { - DebitAmounts []*paymenttypes.Money `json:"debitAmounts,omitempty"` - ExpectedSettlementAmounts []*paymenttypes.Money `json:"expectedSettlementAmounts,omitempty"` - ExpectedFeeTotals []*paymenttypes.Money `json:"expectedFeeTotals,omitempty"` +type QuoteAmounts struct { + SourcePrincipal *paymenttypes.Money `json:"sourcePrincipal,omitempty"` + SourceDebitTotal *paymenttypes.Money `json:"sourceDebitTotal,omitempty"` + DestinationSettlement *paymenttypes.Money `json:"destinationSettlement,omitempty"` +} + +type QuoteFees struct { + Lines []FeeLine `json:"lines,omitempty"` } type PaymentQuotes struct { - IdempotencyKey string `json:"idempotencyKey"` - QuoteRef string `json:"quoteRef,omitempty"` - Aggregate *PaymentQuoteAggregate `json:"aggregate,omitempty"` - Quotes []PaymentQuote `json:"quotes,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` + Items []PaymentQuote `json:"items,omitempty"` } type Payment struct { @@ -196,12 +196,13 @@ func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote { if q == nil { return nil } + amounts := toQuoteAmounts(q) + fees := toQuoteFees(q.GetFeeLines()) return &PaymentQuote{ - QuoteRef: q.GetQuoteRef(), - DebitAmount: toMoney(q.GetPayerTotalDebitAmount()), - ExpectedSettlementAmount: toMoney(q.GetDestinationAmount()), - FeeLines: toFeeLines(q.GetFeeLines()), - FxQuote: toFxQuote(q.GetFxQuote()), + QuoteRef: q.GetQuoteRef(), + Amounts: amounts, + Fees: fees, + FxQuote: toFxQuote(q.GetFxQuote()), } } @@ -209,22 +210,45 @@ func toPaymentQuotes(resp *quotationv2.QuotePaymentsResponse) *PaymentQuotes { if resp == nil { return nil } - quotes := make([]PaymentQuote, 0, len(resp.GetQuotes())) + items := make([]PaymentQuote, 0, len(resp.GetQuotes())) for _, quote := range resp.GetQuotes() { if dto := toPaymentQuote(quote); dto != nil { - quotes = append(quotes, *dto) + items = append(items, *dto) } } - if len(quotes) == 0 { - quotes = nil + if len(items) == 0 { + items = nil } return &PaymentQuotes{ IdempotencyKey: resp.GetIdempotencyKey(), QuoteRef: resp.GetQuoteRef(), - Quotes: quotes, + Items: items, } } +func toQuoteAmounts(q *quotationv2.PaymentQuote) *QuoteAmounts { + if q == nil { + return nil + } + amounts := &QuoteAmounts{ + SourcePrincipal: toMoney(q.GetTransferPrincipalAmount()), + SourceDebitTotal: toMoney(q.GetPayerTotalDebitAmount()), + DestinationSettlement: toMoney(q.GetDestinationAmount()), + } + if amounts.SourcePrincipal == nil && amounts.SourceDebitTotal == nil && amounts.DestinationSettlement == nil { + return nil + } + return amounts +} + +func toQuoteFees(lines []*feesv1.DerivedPostingLine) *QuoteFees { + feeLines := toFeeLines(lines) + if len(feeLines) == 0 { + return nil + } + return &QuoteFees{Lines: feeLines} +} + func toPayments(items []*orchestrationv2.Payment) []Payment { if len(items) == 0 { return nil diff --git a/frontend/pshared/lib/data/dto/payment/network_fee.dart b/frontend/pshared/lib/data/dto/payment/network_fee.dart deleted file mode 100644 index b9b2f344..00000000 --- a/frontend/pshared/lib/data/dto/payment/network_fee.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import 'package:pshared/data/dto/money.dart'; - -part 'network_fee.g.dart'; - - -@JsonSerializable() -class NetworkFeeDTO { - final MoneyDTO? networkFee; - final String? estimationContext; - - const NetworkFeeDTO({ - this.networkFee, - this.estimationContext, - }); - - factory NetworkFeeDTO.fromJson(Map json) => _$NetworkFeeDTOFromJson(json); - Map toJson() => _$NetworkFeeDTOToJson(this); -} diff --git a/frontend/pshared/lib/data/dto/payment/payment_quote.dart b/frontend/pshared/lib/data/dto/payment/payment_quote.dart index b1e665d9..326b979a 100644 --- a/frontend/pshared/lib/data/dto/payment/payment_quote.dart +++ b/frontend/pshared/lib/data/dto/payment/payment_quote.dart @@ -1,35 +1,21 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:pshared/data/dto/payment/fee_line.dart'; import 'package:pshared/data/dto/payment/fx_quote.dart'; -import 'package:pshared/data/dto/money.dart'; -import 'package:pshared/data/dto/payment/network_fee.dart'; +import 'package:pshared/data/dto/payment/quote_amounts.dart'; +import 'package:pshared/data/dto/payment/quote_fees.dart'; part 'payment_quote.g.dart'; - @JsonSerializable() class PaymentQuoteDTO { final String? quoteRef; - final MoneyDTO? debitAmount; - final MoneyDTO? debitSettlementAmount; - final MoneyDTO? expectedSettlementAmount; - final MoneyDTO? expectedFeeTotal; - final List? feeLines; - final NetworkFeeDTO? networkFee; + final QuoteAmountsDTO? amounts; + final QuoteFeesDTO? fees; final FxQuoteDTO? fxQuote; - const PaymentQuoteDTO({ - this.quoteRef, - this.debitAmount, - this.debitSettlementAmount, - this.expectedSettlementAmount, - this.expectedFeeTotal, - this.feeLines, - this.networkFee, - this.fxQuote, - }); + const PaymentQuoteDTO({this.quoteRef, this.amounts, this.fees, this.fxQuote}); - factory PaymentQuoteDTO.fromJson(Map json) => _$PaymentQuoteDTOFromJson(json); + factory PaymentQuoteDTO.fromJson(Map json) => + _$PaymentQuoteDTOFromJson(json); Map toJson() => _$PaymentQuoteDTOToJson(this); } diff --git a/frontend/pshared/lib/data/dto/payment/quote_aggregate.dart b/frontend/pshared/lib/data/dto/payment/quote_aggregate.dart deleted file mode 100644 index 34dd779b..00000000 --- a/frontend/pshared/lib/data/dto/payment/quote_aggregate.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import 'package:pshared/data/dto/money.dart'; - -part 'quote_aggregate.g.dart'; - - -@JsonSerializable() -class PaymentQuoteAggregateDTO { - final List? debitAmounts; - final List? expectedSettlementAmounts; - final List? expectedFeeTotals; - final List? networkFeeTotals; - - const PaymentQuoteAggregateDTO({ - this.debitAmounts, - this.expectedSettlementAmounts, - this.expectedFeeTotals, - this.networkFeeTotals, - }); - - factory PaymentQuoteAggregateDTO.fromJson(Map json) => _$PaymentQuoteAggregateDTOFromJson(json); - Map toJson() => _$PaymentQuoteAggregateDTOToJson(this); -} diff --git a/frontend/pshared/lib/data/dto/payment/quote_amounts.dart b/frontend/pshared/lib/data/dto/payment/quote_amounts.dart new file mode 100644 index 00000000..4e3dcdfc --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/quote_amounts.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/money.dart'; + +part 'quote_amounts.g.dart'; + +@JsonSerializable() +class QuoteAmountsDTO { + final MoneyDTO? sourcePrincipal; + final MoneyDTO? sourceDebitTotal; + final MoneyDTO? destinationSettlement; + + const QuoteAmountsDTO({ + this.sourcePrincipal, + this.sourceDebitTotal, + this.destinationSettlement, + }); + + factory QuoteAmountsDTO.fromJson(Map json) => + _$QuoteAmountsDTOFromJson(json); + Map toJson() => _$QuoteAmountsDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/quote_fees.dart b/frontend/pshared/lib/data/dto/payment/quote_fees.dart new file mode 100644 index 00000000..658183ef --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/quote_fees.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/payment/fee_line.dart'; + +part 'quote_fees.g.dart'; + +@JsonSerializable() +class QuoteFeesDTO { + final List? lines; + + const QuoteFeesDTO({this.lines}); + + factory QuoteFeesDTO.fromJson(Map json) => + _$QuoteFeesDTOFromJson(json); + Map toJson() => _$QuoteFeesDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/quotes.dart b/frontend/pshared/lib/data/dto/payment/quotes.dart index 71935cb3..23442814 100644 --- a/frontend/pshared/lib/data/dto/payment/quotes.dart +++ b/frontend/pshared/lib/data/dto/payment/quotes.dart @@ -1,6 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:pshared/data/dto/payment/quote_aggregate.dart'; import 'package:pshared/data/dto/payment/payment_quote.dart'; part 'quotes.g.dart'; @@ -9,14 +8,12 @@ part 'quotes.g.dart'; class PaymentQuotesDTO { final String quoteRef; final String? idempotencyKey; - final PaymentQuoteAggregateDTO? aggregate; - final List? quotes; + final List? items; const PaymentQuotesDTO({ required this.quoteRef, this.idempotencyKey, - this.aggregate, - this.quotes, + this.items, }); factory PaymentQuotesDTO.fromJson(Map json) => diff --git a/frontend/pshared/lib/data/mapper/payment/network_fee.dart b/frontend/pshared/lib/data/mapper/payment/network_fee.dart deleted file mode 100644 index ac1d697e..00000000 --- a/frontend/pshared/lib/data/mapper/payment/network_fee.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:pshared/data/dto/payment/network_fee.dart'; -import 'package:pshared/data/mapper/money.dart'; -import 'package:pshared/models/payment/fees/network.dart'; - - -extension NetworkFeeDTOMapper on NetworkFeeDTO { - NetworkFee toDomain() => NetworkFee( - networkFee: networkFee?.toDomain(), - estimationContext: estimationContext, - ); -} - -extension NetworkFeeMapper on NetworkFee { - NetworkFeeDTO toDTO() => NetworkFeeDTO( - networkFee: networkFee?.toDTO(), - estimationContext: estimationContext, - ); -} diff --git a/frontend/pshared/lib/data/mapper/payment/quote.dart b/frontend/pshared/lib/data/mapper/payment/quote.dart index 6a825d74..e3decf77 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote.dart @@ -1,21 +1,15 @@ import 'package:pshared/data/dto/payment/payment_quote.dart'; -import 'package:pshared/data/mapper/payment/fees/line.dart'; import 'package:pshared/data/mapper/payment/fx_quote.dart'; -import 'package:pshared/data/mapper/money.dart'; -import 'package:pshared/data/mapper/payment/network_fee.dart'; +import 'package:pshared/data/mapper/payment/quote/amounts.dart'; +import 'package:pshared/data/mapper/payment/quote/fees.dart'; import 'package:pshared/models/payment/quote/quote.dart'; - extension PaymentQuoteDTOMapper on PaymentQuoteDTO { PaymentQuote toDomain({String? idempotencyKey}) => PaymentQuote( quoteRef: quoteRef, idempotencyKey: idempotencyKey, - debitAmount: debitAmount?.toDomain(), - debitSettlementAmount: debitSettlementAmount?.toDomain(), - expectedSettlementAmount: expectedSettlementAmount?.toDomain(), - expectedFeeTotal: expectedFeeTotal?.toDomain(), - feeLines: feeLines?.map((line) => line.toDomain()).toList(), - networkFee: networkFee?.toDomain(), + amounts: amounts?.toDomain(), + fees: fees?.toDomain(), fxQuote: fxQuote?.toDomain(), ); } @@ -23,12 +17,8 @@ extension PaymentQuoteDTOMapper on PaymentQuoteDTO { extension PaymentQuoteMapper on PaymentQuote { PaymentQuoteDTO toDTO() => PaymentQuoteDTO( quoteRef: quoteRef, - debitAmount: debitAmount?.toDTO(), - debitSettlementAmount: debitSettlementAmount?.toDTO(), - expectedSettlementAmount: expectedSettlementAmount?.toDTO(), - expectedFeeTotal: expectedFeeTotal?.toDTO(), - feeLines: feeLines?.map((line) => line.toDTO()).toList(), - networkFee: networkFee?.toDTO(), + amounts: amounts?.toDTO(), + fees: fees?.toDTO(), fxQuote: fxQuote?.toDTO(), ); } diff --git a/frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart b/frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart deleted file mode 100644 index ab340283..00000000 --- a/frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:pshared/data/dto/payment/quote_aggregate.dart'; -import 'package:pshared/data/mapper/money.dart'; -import 'package:pshared/models/payment/quote/aggregate.dart'; - - -extension PaymentQuoteAggregateDTOMapper on PaymentQuoteAggregateDTO { - PaymentQuoteAggregate toDomain() => PaymentQuoteAggregate( - debitAmounts: debitAmounts?.map((amount) => amount.toDomain()).toList(), - expectedSettlementAmounts: expectedSettlementAmounts?.map((amount) => amount.toDomain()).toList(), - expectedFeeTotals: expectedFeeTotals?.map((amount) => amount.toDomain()).toList(), - networkFeeTotals: networkFeeTotals?.map((amount) => amount.toDomain()).toList(), - ); -} - -extension PaymentQuoteAggregateMapper on PaymentQuoteAggregate { - PaymentQuoteAggregateDTO toDTO() => PaymentQuoteAggregateDTO( - debitAmounts: debitAmounts?.map((amount) => amount.toDTO()).toList(), - expectedSettlementAmounts: expectedSettlementAmounts?.map((amount) => amount.toDTO()).toList(), - expectedFeeTotals: expectedFeeTotals?.map((amount) => amount.toDTO()).toList(), - networkFeeTotals: networkFeeTotals?.map((amount) => amount.toDTO()).toList(), - ); -} diff --git a/frontend/pshared/lib/data/mapper/payment/quote/amounts.dart b/frontend/pshared/lib/data/mapper/payment/quote/amounts.dart new file mode 100644 index 00000000..aee03e48 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/quote/amounts.dart @@ -0,0 +1,19 @@ +import 'package:pshared/data/dto/payment/quote_amounts.dart'; +import 'package:pshared/data/mapper/money.dart'; +import 'package:pshared/models/payment/quote/amounts.dart'; + +extension QuoteAmountsDTOMapper on QuoteAmountsDTO { + QuoteAmounts toDomain() => QuoteAmounts( + sourcePrincipal: sourcePrincipal?.toDomain(), + sourceDebitTotal: sourceDebitTotal?.toDomain(), + destinationSettlement: destinationSettlement?.toDomain(), + ); +} + +extension QuoteAmountsMapper on QuoteAmounts { + QuoteAmountsDTO toDTO() => QuoteAmountsDTO( + sourcePrincipal: sourcePrincipal?.toDTO(), + sourceDebitTotal: sourceDebitTotal?.toDTO(), + destinationSettlement: destinationSettlement?.toDTO(), + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/quote/fees.dart b/frontend/pshared/lib/data/mapper/payment/quote/fees.dart new file mode 100644 index 00000000..833dba3b --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/quote/fees.dart @@ -0,0 +1,13 @@ +import 'package:pshared/data/dto/payment/quote_fees.dart'; +import 'package:pshared/data/mapper/payment/fees/line.dart'; +import 'package:pshared/models/payment/quote/fees.dart'; + +extension QuoteFeesDTOMapper on QuoteFeesDTO { + QuoteFees toDomain() => + QuoteFees(lines: lines?.map((line) => line.toDomain()).toList()); +} + +extension QuoteFeesMapper on QuoteFees { + QuoteFeesDTO toDTO() => + QuoteFeesDTO(lines: lines?.map((line) => line.toDTO()).toList()); +} diff --git a/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart index 6e0c84f2..6189b071 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart @@ -1,14 +1,12 @@ import 'package:pshared/data/dto/payment/quotes.dart'; import 'package:pshared/data/mapper/payment/quote.dart'; -import 'package:pshared/data/mapper/payment/quote/aggregate.dart'; import 'package:pshared/models/payment/quote/quotes.dart'; extension PaymentQuotesDTOMapper on PaymentQuotesDTO { PaymentQuotes toDomain({String? idempotencyKey}) => PaymentQuotes( quoteRef: quoteRef, idempotencyKey: idempotencyKey ?? this.idempotencyKey, - aggregate: aggregate?.toDomain(), - quotes: quotes?.map((quote) => quote.toDomain()).toList(), + items: items?.map((quote) => quote.toDomain()).toList(), ); } @@ -16,7 +14,6 @@ extension PaymentQuotesMapper on PaymentQuotes { PaymentQuotesDTO toDTO() => PaymentQuotesDTO( quoteRef: quoteRef, idempotencyKey: idempotencyKey, - aggregate: aggregate?.toDTO(), - quotes: quotes?.map((quote) => quote.toDTO()).toList(), + items: items?.map((quote) => quote.toDTO()).toList(), ); } diff --git a/frontend/pshared/lib/models/payment/fees/network.dart b/frontend/pshared/lib/models/payment/fees/network.dart deleted file mode 100644 index 98c77edd..00000000 --- a/frontend/pshared/lib/models/payment/fees/network.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:pshared/models/money.dart'; - - -class NetworkFee { - final Money? networkFee; - final String? estimationContext; - - const NetworkFee({ - required this.networkFee, - required this.estimationContext, - }); -} diff --git a/frontend/pshared/lib/models/payment/quote/aggregate.dart b/frontend/pshared/lib/models/payment/quote/aggregate.dart deleted file mode 100644 index 6c236e13..00000000 --- a/frontend/pshared/lib/models/payment/quote/aggregate.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:pshared/models/money.dart'; - - -class PaymentQuoteAggregate { - final List? debitAmounts; - final List? expectedSettlementAmounts; - final List? expectedFeeTotals; - final List? networkFeeTotals; - - const PaymentQuoteAggregate({ - required this.debitAmounts, - required this.expectedSettlementAmounts, - required this.expectedFeeTotals, - required this.networkFeeTotals, - }); -} diff --git a/frontend/pshared/lib/models/payment/quote/amounts.dart b/frontend/pshared/lib/models/payment/quote/amounts.dart new file mode 100644 index 00000000..e5fc9ebe --- /dev/null +++ b/frontend/pshared/lib/models/payment/quote/amounts.dart @@ -0,0 +1,13 @@ +import 'package:pshared/models/money.dart'; + +class QuoteAmounts { + final Money? sourcePrincipal; + final Money? sourceDebitTotal; + final Money? destinationSettlement; + + const QuoteAmounts({ + required this.sourcePrincipal, + required this.sourceDebitTotal, + required this.destinationSettlement, + }); +} diff --git a/frontend/pshared/lib/models/payment/quote/fees.dart b/frontend/pshared/lib/models/payment/quote/fees.dart new file mode 100644 index 00000000..80b2f76a --- /dev/null +++ b/frontend/pshared/lib/models/payment/quote/fees.dart @@ -0,0 +1,7 @@ +import 'package:pshared/models/payment/fees/line.dart'; + +class QuoteFees { + final List? lines; + + const QuoteFees({required this.lines}); +} diff --git a/frontend/pshared/lib/models/payment/quote/quote.dart b/frontend/pshared/lib/models/payment/quote/quote.dart index c6940043..ecf3e5bb 100644 --- a/frontend/pshared/lib/models/payment/quote/quote.dart +++ b/frontend/pshared/lib/models/payment/quote/quote.dart @@ -1,29 +1,19 @@ -import 'package:pshared/models/payment/fees/line.dart'; import 'package:pshared/models/payment/fx/quote.dart'; -import 'package:pshared/models/money.dart'; -import 'package:pshared/models/payment/fees/network.dart'; - +import 'package:pshared/models/payment/quote/amounts.dart'; +import 'package:pshared/models/payment/quote/fees.dart'; class PaymentQuote { final String? quoteRef; final String? idempotencyKey; - final Money? debitAmount; - final Money? debitSettlementAmount; - final Money? expectedSettlementAmount; - final Money? expectedFeeTotal; - final List? feeLines; - final NetworkFee? networkFee; + final QuoteAmounts? amounts; + final QuoteFees? fees; final FxQuote? fxQuote; const PaymentQuote({ required this.quoteRef, required this.idempotencyKey, - required this.debitAmount, - required this.debitSettlementAmount, - required this.expectedSettlementAmount, - required this.expectedFeeTotal, - required this.feeLines, - required this.networkFee, + required this.amounts, + required this.fees, required this.fxQuote, }); } diff --git a/frontend/pshared/lib/models/payment/quote/quotes.dart b/frontend/pshared/lib/models/payment/quote/quotes.dart index 15fca09c..6f12601f 100644 --- a/frontend/pshared/lib/models/payment/quote/quotes.dart +++ b/frontend/pshared/lib/models/payment/quote/quotes.dart @@ -1,17 +1,13 @@ import 'package:pshared/models/payment/quote/quote.dart'; -import 'package:pshared/models/payment/quote/aggregate.dart'; - class PaymentQuotes { final String quoteRef; final String? idempotencyKey; - final PaymentQuoteAggregate? aggregate; - final List? quotes; + final List? items; const PaymentQuotes({ required this.quoteRef, required this.idempotencyKey, - required this.aggregate, - required this.quotes, + required this.items, }); } diff --git a/frontend/pshared/lib/provider/payment/multiple/quotation.dart b/frontend/pshared/lib/provider/payment/multiple/quotation.dart index 6881d3e8..077cbff2 100644 --- a/frontend/pshared/lib/provider/payment/multiple/quotation.dart +++ b/frontend/pshared/lib/provider/payment/multiple/quotation.dart @@ -34,11 +34,11 @@ class MultiQuotationProvider extends ChangeNotifier { quotation != null && !_quotation.isLoading && _quotation.error == null; DateTime? get quoteExpiresAt { - final quotes = quotation?.quotes; - if (quotes == null || quotes.isEmpty) return null; + final items = quotation?.items; + if (items == null || items.isEmpty) return null; int? minExpiresAt; - for (final quote in quotes) { + for (final quote in items) { final expiresAtUnixMs = quote.fxQuote?.expiresAtUnixMs; if (expiresAtUnixMs == null) continue; minExpiresAt = minExpiresAt == null diff --git a/frontend/pshared/lib/provider/payment/quotation/quotation.dart b/frontend/pshared/lib/provider/payment/quotation/quotation.dart index e205ad6b..08bc9c06 100644 --- a/frontend/pshared/lib/provider/payment/quotation/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation/quotation.dart @@ -23,12 +23,16 @@ import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/resource.dart'; import 'package:pshared/provider/payment/quotation/intent_builder.dart'; import 'package:pshared/service/payment/quotation.dart'; +import 'package:pshared/utils/payment/quote_helpers.dart'; import 'package:pshared/utils/exception.dart'; - class QuotationProvider extends ChangeNotifier { static final _logger = Logger('provider.payment.quotation'); - Resource _quotation = Resource(data: null, isLoading: false, error: null); + Resource _quotation = Resource( + data: null, + isLoading: false, + error: null, + ); late OrganizationsProvider _organizations; bool _isLoaded = false; PaymentIntent? _lastIntent; @@ -37,7 +41,7 @@ class QuotationProvider extends ChangeNotifier { AutoRefreshMode _autoRefreshMode = AutoRefreshMode.on; void update( - OrganizationsProvider venue, + OrganizationsProvider venue, PaymentAmountProvider payment, WalletsController wallets, PaymentFlowProvider flow, @@ -62,7 +66,8 @@ class QuotationProvider extends ChangeNotifier { bool get isLoading => _quotation.isLoading; Exception? get error => _quotation.error; bool get canRefresh => _lastIntent != null; - bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; + bool get isReady => + _isLoaded && !_quotation.isLoading && _quotation.error == null; AutoRefreshMode get autoRefreshMode => _autoRefreshMode; DateTime? get quoteExpiresAt { @@ -71,10 +76,10 @@ class QuotationProvider extends ChangeNotifier { return DateTime.fromMillisecondsSinceEpoch(expiresAtUnixMs, isUtc: true); } - - Asset? get fee => _assetFromMoney(quotation?.expectedFeeTotal); - Asset? get total => _assetFromMoney(quotation?.debitAmount); - Asset? get recipientGets => _assetFromMoney(quotation?.expectedSettlementAmount); + Asset? get fee => _assetFromMoney(quoteFeeTotal(quotation)); + Asset? get total => _assetFromMoney(quotation?.amounts?.sourceDebitTotal); + Asset? get recipientGets => + _assetFromMoney(quotation?.amounts?.destinationSettlement); Asset? _assetFromMoney(Money? money) { if (money == null) return null; @@ -101,26 +106,32 @@ class QuotationProvider extends ChangeNotifier { } Future getQuotation(PaymentIntent intent) async { - if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); + if (!_organizations.isOrganizationSet) { + throw StateError('Organization is not set'); + } _lastIntent = intent; try { _setResource(_quotation.copyWith(isLoading: true, error: null)); final response = await QuotationService.getQuotation( - _organizations.current.id, + _organizations.current.id, QuotePaymentRequest( idempotencyKey: Uuid().v4(), intent: intent.toDTO(), ), ); _isLoaded = true; - _setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); + _setResource( + _quotation.copyWith(data: response, isLoading: false, error: null), + ); } catch (e, st) { _logger.warning('Failed to get quotation', e, st); - _setResource(_quotation.copyWith( - data: null, - error: toException(e), - isLoading: false, - )); + _setResource( + _quotation.copyWith( + data: null, + error: toException(e), + isLoading: false, + ), + ); } return _quotation.data; } diff --git a/frontend/pshared/lib/utils/payment/quote_helpers.dart b/frontend/pshared/lib/utils/payment/quote_helpers.dart new file mode 100644 index 00000000..99bbcbda --- /dev/null +++ b/frontend/pshared/lib/utils/payment/quote_helpers.dart @@ -0,0 +1,92 @@ +import 'package:pshared/models/money.dart'; +import 'package:pshared/models/payment/fees/line.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; +import 'package:pshared/utils/currency.dart'; +import 'package:pshared/utils/money.dart'; + +Money? quoteFeeTotal(PaymentQuote? quote) { + final preferredCurrency = + quote?.amounts?.sourcePrincipal?.currency ?? + quote?.amounts?.sourceDebitTotal?.currency; + return quoteFeeTotalFromLines( + quote?.fees?.lines, + preferredCurrency: preferredCurrency, + ); +} + +Money? quoteFeeTotalFromLines( + List? lines, { + String? preferredCurrency, +}) { + if (lines == null || lines.isEmpty) return null; + + final normalizedPreferred = _normalizeCurrency(preferredCurrency); + final totalsByCurrency = {}; + + for (final line in lines) { + final money = line.amount; + if (money == null) continue; + + final currency = _normalizeCurrency(money.currency); + if (currency == null) continue; + + final amount = parseMoneyAmount(money.amount, fallback: double.nan); + if (amount.isNaN) continue; + + final sign = _lineSign(line.side); + final signedAmount = sign * amount.abs(); + totalsByCurrency[currency] = + (totalsByCurrency[currency] ?? 0) + signedAmount; + } + + if (totalsByCurrency.isEmpty) return null; + + final selectedCurrency = + normalizedPreferred != null && + totalsByCurrency.containsKey(normalizedPreferred) + ? normalizedPreferred + : totalsByCurrency.keys.first; + final total = totalsByCurrency[selectedCurrency]; + if (total == null) return null; + + return Money(amount: amountToString(total), currency: selectedCurrency); +} + +List aggregateMoneyByCurrency(Iterable values) { + final totals = {}; + for (final value in values) { + if (value == null) continue; + + final currency = _normalizeCurrency(value.currency); + if (currency == null) continue; + + final amount = parseMoneyAmount(value.amount, fallback: double.nan); + if (amount.isNaN) continue; + + totals[currency] = (totals[currency] ?? 0) + amount; + } + + return totals.entries + .map( + (entry) => + Money(amount: amountToString(entry.value), currency: entry.key), + ) + .toList(); +} + +double _lineSign(String? side) { + final normalized = side?.trim().toLowerCase() ?? ''; + switch (normalized) { + case 'entry_side_credit': + case 'credit': + return -1; + default: + return 1; + } +} + +String? _normalizeCurrency(String? currency) { + final normalized = currency?.trim().toUpperCase(); + if (normalized == null || normalized.isEmpty) return null; + return normalized; +} diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 1159a425..7c4154b1 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -82,8 +82,22 @@ void main() { 'idempotencyKey': 'idem-1', 'quote': { 'quoteRef': 'q-1', - 'debitAmount': {'amount': '10', 'currency': 'USDT'}, - 'expectedSettlementAmount': {'amount': '760', 'currency': 'RUB'}, + 'amounts': { + 'sourcePrincipal': {'amount': '10', 'currency': 'USDT'}, + 'sourceDebitTotal': {'amount': '10.75', 'currency': 'USDT'}, + 'destinationSettlement': {'amount': '760', 'currency': 'RUB'}, + }, + 'fees': { + 'lines': [ + { + 'ledgerAccountRef': 'ledger:fees', + 'amount': {'amount': '0.75', 'currency': 'USDT'}, + 'lineType': 'posting_line_type_fee', + 'side': 'entry_side_debit', + 'meta': {'fee_target': 'wallet'}, + }, + ], + }, 'fxQuote': { 'quoteRef': 'fx-1', 'baseCurrency': 'USDT', @@ -102,6 +116,8 @@ void main() { }); expect(response.quote.fxQuote?.pricedAtUnixMs, equals(1771945907000)); + expect(response.quote.amounts?.sourceDebitTotal?.amount, equals('10.75')); + expect(response.quote.fees?.lines?.length, equals(1)); }); test('initiate payment by quote keeps expected fields', () { diff --git a/frontend/pweb/lib/pages/report/details/summary_card/widget.dart b/frontend/pweb/lib/pages/report/details/summary_card/widget.dart index cd7cf05c..80041e68 100644 --- a/frontend/pweb/lib/pages/report/details/summary_card/widget.dart +++ b/frontend/pweb/lib/pages/report/details/summary_card/widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/utils/payment/quote_helpers.dart'; import 'package:pweb/pages/report/details/summary_card/amount_headline.dart'; import 'package:pweb/pages/report/details/summary_card/copy_id.dart'; @@ -13,7 +14,6 @@ import 'package:pweb/utils/clipboard.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class PaymentSummaryCard extends StatelessWidget { final Payment payment; final VoidCallback? onDownloadAct; @@ -31,11 +31,11 @@ class PaymentSummaryCard extends StatelessWidget { final status = statusFromPayment(payment); final dateLabel = formatDateLabel(context, resolvePaymentDate(payment)); - final primaryAmount = payment.lastQuote?.debitAmount ?? - payment.lastQuote?.expectedSettlementAmount; - final toAmount = payment.lastQuote?.expectedSettlementAmount; - final fee = payment.lastQuote?.expectedFeeTotal ?? - payment.lastQuote?.networkFee?.networkFee; + final primaryAmount = + payment.lastQuote?.amounts?.sourceDebitTotal ?? + payment.lastQuote?.amounts?.destinationSettlement; + final toAmount = payment.lastQuote?.amounts?.destinationSettlement; + final fee = quoteFeeTotal(payment.lastQuote); final amountLabel = formatMoney(primaryAmount); final toAmountLabel = formatMoney(toAmount); @@ -108,11 +108,8 @@ class PaymentSummaryCard extends StatelessWidget { child: CopyableId( label: loc.paymentIdLabel, value: paymentRef, - onCopy: () => copyToClipboard( - context, - paymentRef, - loc.paymentIdCopied, - ), + onCopy: () => + copyToClipboard(context, paymentRef, loc.paymentIdCopied), ), ), ], diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index ecaa7406..b497b8f6 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -2,19 +2,20 @@ import 'package:flutter/foundation.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/quote/status_type.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/provider/payment/multiple/provider.dart'; import 'package:pshared/provider/payment/multiple/quotation.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/money.dart'; +import 'package:pshared/utils/payment/quote_helpers.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; import 'package:pweb/models/payment/multiple_payouts/state.dart'; import 'package:pweb/utils/payment/multiple_csv_parser.dart'; import 'package:pweb/utils/payment/multiple_intent_builder.dart'; - class MultiplePayoutsProvider extends ChangeNotifier { final MultipleCsvParser _csvParser; final MultipleIntentBuilder _intentBuilder; @@ -34,10 +35,7 @@ class MultiplePayoutsProvider extends ChangeNotifier { }) : _csvParser = csvParser ?? MultipleCsvParser(), _intentBuilder = intentBuilder ?? MultipleIntentBuilder(); - void update( - MultiQuotationProvider quotation, - MultiPaymentProvider payment, - ) { + void update(MultiQuotationProvider quotation, MultiPaymentProvider payment) { _bindQuotation(quotation); _payment = payment; } @@ -60,7 +58,9 @@ class MultiplePayoutsProvider extends ChangeNotifier { if (quotation.isLoading) return QuoteStatusType.loading; if (quotation.error != null) return QuoteStatusType.error; if (quotation.quotation == null) return QuoteStatusType.missing; - if (_isQuoteExpired(quotation.quoteExpiresAt)) return QuoteStatusType.expired; + if (_isQuoteExpired(quotation.quoteExpiresAt)) { + return QuoteStatusType.expired; + } return QuoteStatusType.active; } @@ -78,10 +78,10 @@ class MultiplePayoutsProvider extends ChangeNotifier { Money? aggregateDebitAmountFor(Wallet? sourceWallet) { if (_rows.isEmpty) return null; - return _moneyForSourceCurrency( - _quotation?.quotation?.aggregate?.debitAmounts, - sourceWallet, + final totals = aggregateMoneyByCurrency( + _quoteItems().map((quote) => quote.amounts?.sourceDebitTotal), ); + return _moneyForSourceCurrency(totals, sourceWallet); } Money? get requestedSentAmount { @@ -99,18 +99,16 @@ class MultiplePayoutsProvider extends ChangeNotifier { Money? aggregateSettlementAmountFor(Wallet? sourceWallet) { if (_rows.isEmpty) return null; - return _moneyForSourceCurrency( - _quotation?.quotation?.aggregate?.expectedSettlementAmounts, - sourceWallet, + final totals = aggregateMoneyByCurrency( + _quoteItems().map((quote) => quote.amounts?.destinationSettlement), ); + return _moneyForSourceCurrency(totals, sourceWallet); } Money? aggregateFeeAmountFor(Wallet? sourceWallet) { if (_rows.isEmpty) return null; - return _moneyForSourceCurrency( - _quotation?.quotation?.aggregate?.expectedFeeTotals, - sourceWallet, - ); + final totals = aggregateMoneyByCurrency(_quoteItems().map(quoteFeeTotal)); + return _moneyForSourceCurrency(totals, sourceWallet); } double? aggregateFeePercentFor(Wallet? sourceWallet) { @@ -256,10 +254,7 @@ class MultiplePayoutsProvider extends ChangeNotifier { }; } - Money? _moneyForSourceCurrency( - List? values, - Wallet? sourceWallet, - ) { + Money? _moneyForSourceCurrency(List? values, Wallet? sourceWallet) { if (values == null || values.isEmpty) return null; if (sourceWallet != null) { @@ -274,6 +269,9 @@ class MultiplePayoutsProvider extends ChangeNotifier { return values.first; } + List _quoteItems() => + _quotation?.quotation?.items ?? const []; + @override void dispose() { _quotation?.removeListener(_onQuotationChanged); diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart index f091fb17..eee30945 100644 --- a/frontend/pweb/lib/utils/report/payment_mapper.dart +++ b/frontend/pweb/lib/utils/report/payment_mapper.dart @@ -5,10 +5,9 @@ import 'package:pshared/utils/money.dart'; import 'package:pweb/models/payment/payment_state.dart'; - OperationItem mapPaymentToOperation(Payment payment) { - final debit = payment.lastQuote?.debitAmount; - final settlement = payment.lastQuote?.expectedSettlementAmount; + final debit = payment.lastQuote?.amounts?.sourceDebitTotal; + final settlement = payment.lastQuote?.amounts?.destinationSettlement; final amountMoney = debit ?? settlement; final amount = parseMoneyAmount(amountMoney?.amount); @@ -18,18 +17,17 @@ OperationItem mapPaymentToOperation(Payment payment) { : parseMoneyAmount(settlement.amount); final toCurrency = settlement?.currency ?? currency; - final payId = _firstNonEmpty([ - payment.paymentRef, - payment.idempotencyKey, - ]) ?? - '-'; - final name = _firstNonEmpty([ + final payId = + _firstNonEmpty([payment.paymentRef, payment.idempotencyKey]) ?? '-'; + final name = + _firstNonEmpty([ payment.lastQuote?.quoteRef, payment.paymentRef, payment.idempotencyKey, ]) ?? '-'; - final comment = _firstNonEmpty([ + final comment = + _firstNonEmpty([ payment.failureReason, payment.failureCode, payment.state, @@ -72,17 +70,17 @@ DateTime resolvePaymentDate(Payment payment) { final expiresAt = payment.lastQuote?.fxQuote?.expiresAtUnixMs; if (expiresAt != null && expiresAt > 0) { - return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true).toLocal(); + return DateTime.fromMillisecondsSinceEpoch( + expiresAt, + isUtc: true, + ).toLocal(); } return DateTime.fromMillisecondsSinceEpoch(0); } String? paymentIdFromOperation(OperationItem operation) { - final candidates = [ - operation.paymentRef, - operation.payId, - ]; + final candidates = [operation.paymentRef, operation.payId]; for (final candidate in candidates) { final trimmed = candidate?.trim(); if (trimmed != null && trimmed.isNotEmpty && trimmed != '-') { -- 2.49.1 From af4b68f4c7295cf0ca4505d6bc32be4a19bc0b0d Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 25 Feb 2026 19:25:51 +0100 Subject: [PATCH 07/23] Improved payment handling --- .../server/notificationimp/confcode.go | 2 +- .../internal/server/internal/serverimp.go | 4 +- .../service/orchestrationv2/agg/module.go | 66 +-- .../service/orchestrationv2/erecon/event.go | 133 +++-- .../service/orchestrationv2/erecon/module.go | 2 +- .../service/orchestrationv2/erecon/reduce.go | 4 +- .../service/orchestrationv2/erecon/service.go | 8 +- .../service/orchestrationv2/idem/module.go | 2 +- .../service/orchestrationv2/oobs/service.go | 2 +- .../service/orchestrationv2/ostate/module.go | 2 +- .../service/orchestrationv2/pquery/service.go | 2 +- .../service/orchestrationv2/prepo/indexes.go | 11 + .../service/orchestrationv2/prepo/module.go | 9 + .../orchestrationv2/prepo/mongo_store.go | 13 + .../service/orchestrationv2/prepo/service.go | 91 +++- .../orchestrationv2/prepo/service_test.go | 52 +- .../orchestrationv2/prmap/invariants.go | 4 +- .../service/orchestrationv2/prmap/module.go | 2 +- .../orchestrationv2/prmap/step_mapping.go | 2 +- .../orchestrationv2/psvc/aggregate_state.go | 4 +- .../orchestrationv2/psvc/default_executors.go | 189 ++++++- .../psvc/default_executors_test.go | 164 ++++++ .../service/orchestrationv2/psvc/execute.go | 6 +- .../service/orchestrationv2/psvc/runtime.go | 23 +- .../service/orchestrationv2/psvc/service.go | 35 +- .../orchestrationv2/psvc/service_e2e_test.go | 33 ++ .../service/orchestrationv2/qsnap/module.go | 2 +- .../service/orchestrationv2/reqval/module.go | 2 +- .../service/orchestrationv2/sexec/module.go | 8 +- .../service/orchestrationv2/sexec/routes.go | 9 + .../service/orchestrationv2/sexec/service.go | 22 +- .../orchestrationv2/sexec/service_test.go | 64 +++ .../service/orchestrationv2/ssched/input.go | 10 +- .../service/orchestrationv2/ssched/module.go | 5 +- .../service/orchestrationv2/ssched/service.go | 2 +- .../orchestrationv2/ssched/service_test.go | 35 ++ .../orchestrationv2/xplan/guard_ops.go | 52 ++ .../service/orchestrationv2/xplan/module.go | 2 +- .../xplan/service_boundaries.go | 6 +- .../service/orchestrator/crypto_executor.go | 348 ++++++++++++ .../orchestrator/crypto_executor_test.go | 221 ++++++++ .../service/orchestrator/external_runtime.go | 508 ++++++++++++++++++ .../orchestrator/external_runtime_test.go | 275 ++++++++++ .../service/orchestrator/guard_executor.go | 285 ++++++++++ .../orchestrator/guard_executor_test.go | 239 ++++++++ .../internal/service/orchestrator/options.go | 66 ++- .../internal/service/orchestrator/service.go | 54 +- .../service/orchestrator/service_v2.go | 67 ++- api/payments/quotation/config.dev.yml | 7 - api/payments/quotation/config.yml | 7 - .../internal/server/internal/config.go | 1 - .../internal/server/internal/dependencies.go | 25 +- .../server/internal/discovery_clients.go | 276 ++++++++++ .../internal/server/internal/serverimp.go | 7 +- .../internal/server/internal/types.go | 12 +- .../managed_wallet_network_resolver.go | 202 +++++++ .../managed_wallet_network_resolver_test.go | 139 +++++ .../internal/service/quotation/options.go | 15 - .../service/quotation/quotation_v2_wiring.go | 3 + .../managed_wallet_network.go | 69 +++ .../managed_wallet_network_test.go | 185 +++++++ .../quote_computation_service/planner.go | 20 +- .../quote_computation_service/service.go | 25 +- api/pkg/mutil/mzap/account.go | 6 +- .../server/verificationimp/sendcode.go | 3 +- 65 files changed, 3890 insertions(+), 259 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/guard_ops.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/external_runtime.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/guard_executor.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go create mode 100644 api/payments/quotation/internal/server/internal/discovery_clients.go create mode 100644 api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go create mode 100644 api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver_test.go create mode 100644 api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go create mode 100644 api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go diff --git a/api/notification/internal/server/notificationimp/confcode.go b/api/notification/internal/server/notificationimp/confcode.go index e88913c0..40dff7bd 100644 --- a/api/notification/internal/server/notificationimp/confcode.go +++ b/api/notification/internal/server/notificationimp/confcode.go @@ -23,6 +23,6 @@ func (a *NotificationAPI) onConfirmationCode(ctx context.Context, account *model a.logger.Warn("Failed to send confirmation code email", zap.Error(err), mzap.Login(account)) return err } - a.logger.Info("Confirmation code email sent", mzap.Login(account), zap.String("destination", target), zap.String("target", string(purpose))) + a.logger.Info("Confirmation code email sent", mzap.Login(account), mzap.MaskEmail("destination", target), zap.String("target", string(purpose))) return nil } diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 6dc93764..0228d010 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -58,9 +58,9 @@ func (i *Imp) Start() error { if broker != nil { opts = append(opts, orchestrator.WithPaymentGatewayBroker(broker)) } - svc := orchestrator.NewService(logger, repo, opts...) + svc, err := orchestrator.NewService(logger, repo, opts...) i.service = svc - return svc, nil + return svc, err } app, err := grpcapp.NewApp(i.logger, "payments.orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go index ce2460ab..cf06e307 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go @@ -43,54 +43,54 @@ const ( // StepShell defines one initial step telemetry item. type StepShell struct { - StepRef string - StepCode string + StepRef string `bson:"stepRef" json:"stepRef"` + StepCode string `bson:"stepCode" json:"stepCode"` } // StepExecution is runtime telemetry for one step. type StepExecution struct { - StepRef string - StepCode string - State StepState - Attempt uint32 - StartedAt *time.Time - CompletedAt *time.Time - FailureCode string - FailureMsg string - ExternalRefs []ExternalRef + StepRef string `bson:"stepRef" json:"stepRef"` + StepCode string `bson:"stepCode" json:"stepCode"` + State StepState `bson:"state" json:"state"` + Attempt uint32 `bson:"attempt" json:"attempt"` + StartedAt *time.Time `bson:"startedAt,omitempty" json:"startedAt,omitempty"` + CompletedAt *time.Time `bson:"completedAt,omitempty" json:"completedAt,omitempty"` + FailureCode string `bson:"failureCode,omitempty" json:"failureCode,omitempty"` + FailureMsg string `bson:"failureMsg,omitempty" json:"failureMsg,omitempty"` + ExternalRefs []ExternalRef `bson:"externalRefs,omitempty" json:"externalRefs,omitempty"` } // ExternalRef links step execution to an external operation. type ExternalRef struct { - GatewayInstanceID string - Kind string - Ref string + GatewayInstanceID string `bson:"gatewayInstanceId,omitempty" json:"gatewayInstanceId,omitempty"` + Kind string `bson:"kind" json:"kind"` + Ref string `bson:"ref" json:"ref"` } // Input defines payload for creating an initial payment aggregate. type Input struct { - OrganizationRef bson.ObjectID - IdempotencyKey string - QuotationRef string - ClientPaymentRef string - IntentSnapshot model.PaymentIntent - QuoteSnapshot *model.PaymentQuoteSnapshot - Steps []StepShell + OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` + IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` + QuotationRef string `bson:"quotationRef" json:"quotationRef"` + ClientPaymentRef string `bson:"clientPaymentRef,omitempty" json:"clientPaymentRef,omitempty"` + IntentSnapshot model.PaymentIntent `bson:"intentSnapshot" json:"intentSnapshot"` + QuoteSnapshot *model.PaymentQuoteSnapshot `bson:"quoteSnapshot" json:"quoteSnapshot"` + Steps []StepShell `bson:"steps,omitempty" json:"steps,omitempty"` } // Payment is orchestration-v2 runtime aggregate. type Payment struct { - storable.Base - pm.OrganizationBoundBase - PaymentRef string - IdempotencyKey string - QuotationRef string - ClientPaymentRef string - IntentSnapshot model.PaymentIntent - QuoteSnapshot *model.PaymentQuoteSnapshot - State State - Version uint64 - StepExecutions []StepExecution + storable.Base `bson:",inline" json:",inline"` + pm.OrganizationBoundBase `bson:",inline" json:",inline"` + PaymentRef string `bson:"paymentRef" json:"paymentRef"` + IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` + QuotationRef string `bson:"quotationRef" json:"quotationRef"` + ClientPaymentRef string `bson:"clientPaymentRef,omitempty" json:"clientPaymentRef,omitempty"` + IntentSnapshot model.PaymentIntent `bson:"intentSnapshot" json:"intentSnapshot"` + QuoteSnapshot *model.PaymentQuoteSnapshot `bson:"quoteSnapshot" json:"quoteSnapshot"` + State State `bson:"state" json:"state"` + Version uint64 `bson:"version" json:"version"` + StepExecutions []StepExecution `bson:"stepExecutions,omitempty" json:"stepExecutions,omitempty"` } // Dependencies configures aggregate factory integrations. @@ -108,7 +108,7 @@ func New(deps ...Dependencies) Factory { logger = zap.NewNop() } return &svc{ - logger: logger.Named("agg"), + logger: logger.Named("aggregator"), now: func() time.Time { return time.Now().UTC() }, newID: func() bson.ObjectID { return bson.NewObjectID() diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go index 9ca22800..bfb5e591 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go @@ -9,15 +9,23 @@ import ( ) type normalizedEvent struct { - stepRef string - matchRefs []agg.ExternalRef - appendRefs []agg.ExternalRef - targetState agg.StepState - failureCode string - failureMsg string - occurredAt *time.Time - forceAggregateFailed bool - forceAggregateNeedsAttention bool + stepRef string `bson:"stepRef"` + matchRefs []agg.ExternalRef `bson:"matchRefs"` + appendRefs []agg.ExternalRef `bson:"appendRefs"` + targetState agg.StepState `bson:"targetState"` + failureInfo *failureInfo `bson:"failure,omitempty"` + forceAggregate *forceAggregate `bson:"forceAggregate,omitempty"` +} + +type failureInfo struct { + code string `bson:"code"` + msg string `bson:"message"` + occurredAt *time.Time `bson:"occurredAt,omitempty"` +} + +type forceAggregate struct { + failed bool `bson:"failed"` + needsAttention bool `bson:"needsAttention"` } func normalizeEvent(event Event) (*normalizedEvent, error) { @@ -48,6 +56,56 @@ func countPayloads(event Event) int { return count } +func (e *normalizedEvent) failureCodeValue() string { + if e == nil || e.failureInfo == nil { + return "" + } + return strings.TrimSpace(e.failureInfo.code) +} + +func (e *normalizedEvent) failureMsgValue() string { + if e == nil || e.failureInfo == nil { + return "" + } + return strings.TrimSpace(e.failureInfo.msg) +} + +func (e *normalizedEvent) occurredAtValue() *time.Time { + if e == nil || e.failureInfo == nil { + return nil + } + return e.failureInfo.occurredAt +} + +func (e *normalizedEvent) forceAggregateFailedValue() bool { + return e != nil && e.forceAggregate != nil && e.forceAggregate.failed +} + +func (e *normalizedEvent) forceAggregateNeedsAttentionValue() bool { + return e != nil && e.forceAggregate != nil && e.forceAggregate.needsAttention +} + +func buildFailureInfo(code, msg string, occurredAt *time.Time) *failureInfo { + if code == "" && msg == "" && occurredAt == nil { + return nil + } + return &failureInfo{ + code: code, + msg: msg, + occurredAt: occurredAt, + } +} + +func buildForceAggregate(failed, needsAttention bool) *forceAggregate { + if !failed && !needsAttention { + return nil + } + return &forceAggregate{ + failed: failed, + needsAttention: needsAttention, + } +} + func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) { status, ok := normalizeGatewayStatus(src.Status) if !ok { @@ -55,14 +113,16 @@ func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) { } target, needsAttention := mapFailureTarget(status, src.Retryable) + failureCode := strings.TrimSpace(src.FailureCode) + failureMsg := strings.TrimSpace(src.FailureMsg) + if target == agg.StepStateFailed && failureMsg == "" { + failureMsg = "gateway operation failed" + } ev := &normalizedEvent{ - stepRef: strings.TrimSpace(src.StepRef), - targetState: target, - failureCode: strings.TrimSpace(src.FailureCode), - failureMsg: strings.TrimSpace(src.FailureMsg), - occurredAt: normalizeTimePtr(src.OccurredAt), - forceAggregateFailed: src.TerminalFailure, - forceAggregateNeedsAttention: needsAttention, + stepRef: strings.TrimSpace(src.StepRef), + targetState: target, + failureInfo: buildFailureInfo(failureCode, failureMsg, normalizeTimePtr(src.OccurredAt)), + forceAggregate: buildForceAggregate(src.TerminalFailure, needsAttention), } ev.matchRefs = normalizeRefList([]agg.ExternalRef{ { @@ -81,9 +141,6 @@ func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) { if ev.stepRef == "" && len(ev.matchRefs) == 0 { return nil, merrors.InvalidArgument("gateway event must include step_ref or operation/transfer reference") } - if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" { - ev.failureMsg = "gateway operation failed" - } return ev, nil } @@ -94,14 +151,16 @@ func normalizeLedgerEvent(src LedgerEvent) (*normalizedEvent, error) { } target, needsAttention := mapFailureTarget(status, src.Retryable) + failureCode := strings.TrimSpace(src.FailureCode) + failureMsg := strings.TrimSpace(src.FailureMsg) + if target == agg.StepStateFailed && failureMsg == "" { + failureMsg = "ledger operation failed" + } ev := &normalizedEvent{ - stepRef: strings.TrimSpace(src.StepRef), - targetState: target, - failureCode: strings.TrimSpace(src.FailureCode), - failureMsg: strings.TrimSpace(src.FailureMsg), - occurredAt: normalizeTimePtr(src.OccurredAt), - forceAggregateFailed: src.TerminalFailure, - forceAggregateNeedsAttention: needsAttention, + stepRef: strings.TrimSpace(src.StepRef), + targetState: target, + failureInfo: buildFailureInfo(failureCode, failureMsg, normalizeTimePtr(src.OccurredAt)), + forceAggregate: buildForceAggregate(src.TerminalFailure, needsAttention), } ev.matchRefs = normalizeRefList([]agg.ExternalRef{ { @@ -114,9 +173,6 @@ func normalizeLedgerEvent(src LedgerEvent) (*normalizedEvent, error) { if ev.stepRef == "" && len(ev.matchRefs) == 0 { return nil, merrors.InvalidArgument("ledger event must include step_ref or entry_ref") } - if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" { - ev.failureMsg = "ledger operation failed" - } return ev, nil } @@ -127,14 +183,16 @@ func normalizeCardEvent(src CardEvent) (*normalizedEvent, error) { } target, needsAttention := mapFailureTarget(status, src.Retryable) + failureCode := strings.TrimSpace(src.FailureCode) + failureMsg := strings.TrimSpace(src.FailureMsg) + if target == agg.StepStateFailed && failureMsg == "" { + failureMsg = "card payout failed" + } ev := &normalizedEvent{ - stepRef: strings.TrimSpace(src.StepRef), - targetState: target, - failureCode: strings.TrimSpace(src.FailureCode), - failureMsg: strings.TrimSpace(src.FailureMsg), - occurredAt: normalizeTimePtr(src.OccurredAt), - forceAggregateFailed: src.TerminalFailure, - forceAggregateNeedsAttention: needsAttention, + stepRef: strings.TrimSpace(src.StepRef), + targetState: target, + failureInfo: buildFailureInfo(failureCode, failureMsg, normalizeTimePtr(src.OccurredAt)), + forceAggregate: buildForceAggregate(src.TerminalFailure, needsAttention), } ev.matchRefs = normalizeRefList([]agg.ExternalRef{ { @@ -148,9 +206,6 @@ func normalizeCardEvent(src CardEvent) (*normalizedEvent, error) { if ev.stepRef == "" && len(ev.matchRefs) == 0 { return nil, merrors.InvalidArgument("card event must include step_ref or payout_ref") } - if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" { - ev.failureMsg = "card payout failed" - } return ev, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go index 5edb5dcd..b201c0e6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go @@ -140,7 +140,7 @@ func New(deps ...Dependencies) Reconciler { now = defaultNow } return &svc{ - logger: logger.Named("erecon"), + logger: logger.Named("reconciler"), now: now, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go index c4834eab..24fa6297 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go @@ -18,7 +18,7 @@ func deriveAggregateTarget(payment *agg.Payment, event *normalizedEvent, sm osta if payment == nil { return agg.StateUnspecified } - if event != nil && event.forceAggregateFailed { + if event != nil && event.forceAggregateFailedValue() { return agg.StateFailed } @@ -48,7 +48,7 @@ func deriveAggregateTarget(payment *agg.Payment, event *normalizedEvent, sm osta if allTerminalSuccessOrSkipped { return agg.StateSettled } - if hasNeedsAttention || (event != nil && event.forceAggregateNeedsAttention) { + if hasNeedsAttention || (event != nil && event.forceAggregateNeedsAttentionValue()) { return agg.StateNeedsAttention } if hasWork { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go index db8086b1..d85c6c47 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go @@ -177,8 +177,8 @@ func (s *svc) applyStepDiagnostics(step *agg.StepExecution, event *normalizedEve now := s.now().UTC() at := now - if event.occurredAt != nil { - at = event.occurredAt.UTC() + if eventAt := event.occurredAtValue(); eventAt != nil { + at = eventAt.UTC() } changed := false @@ -222,8 +222,8 @@ func (s *svc) applyStepDiagnostics(step *agg.StepExecution, event *normalizedEve step.CompletedAt = &at changed = true } - fc := strings.TrimSpace(event.failureCode) - fm := strings.TrimSpace(event.failureMsg) + fc := event.failureCodeValue() + fm := event.failureMsgValue() if step.FailureCode != fc || step.FailureMsg != fm { step.FailureCode = fc step.FailureMsg = fm diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go index 6add1c20..79b7f5aa 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go @@ -57,5 +57,5 @@ func New(deps ...Dependencies) Service { if logger == nil { logger = zap.NewNop() } - return &svc{logger: logger.Named("idem")} + return &svc{logger: logger.Named("idempotency")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go index af1d5df5..f7b384dd 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go @@ -29,7 +29,7 @@ func newService(deps Dependencies) (Observer, error) { if logger == nil { logger = zap.NewNop() } - logger = logger.Named("oobs") + logger = logger.Named("observer") metrics := deps.Metrics if metrics == nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go index 6da7736a..32aaa69d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go @@ -31,5 +31,5 @@ func New(deps ...Dependencies) StateMachine { if logger == nil { logger = zap.NewNop() } - return &svc{logger: logger.Named("ostate")} + return &svc{logger: logger.Named("state_machine")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go index 63b2dd69..5f88ecee 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go @@ -53,7 +53,7 @@ func newService(deps Dependencies) (Service, error) { logger = zap.NewNop() } return &svc{ - logger: logger.Named("pquery"), + logger: logger.Named("repository"), repo: deps.Repository, }, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go index ad9a4089..3d9126a0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go @@ -15,6 +15,11 @@ func requiredIndexes() []*indexDefinition { }, Unique: true, }, + { + Keys: []ri.Key{ + {Field: "paymentRef", Sort: ri.Asc}, + }, + }, { Keys: []ri.Key{ {Field: "organizationRef", Sort: ri.Asc}, @@ -36,5 +41,11 @@ func requiredIndexes() []*indexDefinition { {Field: "createdAt", Sort: ri.Desc}, }, }, + { + Keys: []ri.Key{ + {Field: "state", Sort: ri.Asc}, + {Field: "createdAt", Sort: ri.Desc}, + }, + }, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go index ad2d22b5..4377c91e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go @@ -15,9 +15,11 @@ type Repository interface { Create(ctx context.Context, payment *agg.Payment) error UpdateCAS(ctx context.Context, payment *agg.Payment, expectedVersion uint64) error GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*agg.Payment, error) + GetByPaymentRefGlobal(ctx context.Context, paymentRef string) (*agg.Payment, error) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*agg.Payment, error) ListByQuotationRef(ctx context.Context, in ListByQuotationRefInput) (*ListOutput, error) ListByState(ctx context.Context, in ListByStateInput) (*ListOutput, error) + ListByStateGlobal(ctx context.Context, in ListByStateGlobalInput) (*ListOutput, error) } // ListCursor is a stable pagination cursor sorted by created_at desc then id desc. @@ -48,6 +50,13 @@ type ListByStateInput struct { Cursor *ListCursor } +// ListByStateGlobalInput defines global listing scope by aggregate state. +type ListByStateGlobalInput struct { + State agg.State + Limit int32 + Cursor *ListCursor +} + // Dependencies configures repository integrations. type Dependencies struct { Logger mlogger.Logger diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go index 6f8ddefd..02f2fff4 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go @@ -122,6 +122,12 @@ func (s *mongoStore) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, }) } +func (s *mongoStore) GetByPaymentRefGlobal(ctx context.Context, paymentRef string) (*paymentDocument, error) { + return s.findOne(ctx, bson.D{ + {Key: "paymentRef", Value: paymentRef}, + }) +} + func (s *mongoStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) { return s.findOne(ctx, bson.D{ {Key: "organizationRef", Value: orgRef}, @@ -152,6 +158,13 @@ func (s *mongoStore) ListByState(ctx context.Context, orgRef bson.ObjectID, stat return s.list(ctx, filter, cursor, limit) } +func (s *mongoStore) ListByStateGlobal(ctx context.Context, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) { + filter := bson.D{ + {Key: "state", Value: state}, + } + return s.list(ctx, filter, cursor, limit) +} + func (s *mongoStore) findOne(ctx context.Context, filter bson.D) (*paymentDocument, error) { if s.collection == nil { return nil, merrors.InvalidArgument("payment repository v2: mongo collection is required") diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go index 50d2f4e0..3d6f1501 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go @@ -10,6 +10,7 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -29,10 +30,12 @@ type paymentStore interface { Create(ctx context.Context, doc *paymentDocument) error UpdateCAS(ctx context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) + GetByPaymentRefGlobal(ctx context.Context, paymentRef string) (*paymentDocument, error) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) GetByID(ctx context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) ListByQuotationRef(ctx context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) ListByState(ctx context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) + ListByStateGlobal(ctx context.Context, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) } type svc struct { @@ -56,7 +59,7 @@ func newWithStoreLogger(store paymentStore, logger mlogger.Logger) (Repository, return nil, err } return &svc{ - logger: logger.Named("prepo"), + logger: logger.Named("repository"), store: store, now: func() time.Time { return time.Now().UTC() @@ -209,6 +212,43 @@ func (s *svc) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, payment return payment, err } +func (s *svc) GetByPaymentRefGlobal(ctx context.Context, paymentRef string) (payment *agg.Payment, err error) { + logger := s.logger + requestPaymentRef := strings.TrimSpace(paymentRef) + logger.Debug("Starting Get by payment ref global", + zap.String("payment_ref", requestPaymentRef), + ) + defer func(start time.Time) { + fields := []zap.Field{ + zap.Int64("duration_ms", time.Since(start).Milliseconds()), + zap.String("payment_ref", requestPaymentRef), + } + if payment != nil { + fields = append(fields, + zap.String("organization_ref", payment.OrganizationRef.Hex()), + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + if err != nil { + logger.Warn("Failed to get by payment ref global", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Get by payment ref global", fields...) + }(time.Now()) + + paymentRef = strings.TrimSpace(paymentRef) + if paymentRef == "" { + return nil, merrors.InvalidArgument("payment_ref is required") + } + doc, err := s.store.GetByPaymentRefGlobal(ctx, paymentRef) + if err != nil { + return nil, err + } + payment, err = fromDocument(doc) + return payment, err +} + func (s *svc) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (payment *agg.Payment, err error) { logger := s.logger hasKey := strings.TrimSpace(idempotencyKey) != "" @@ -219,8 +259,8 @@ func (s *svc) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, ide defer func(start time.Time) { fields := []zap.Field{ zap.Int64("duration_ms", time.Since(start).Milliseconds()), - zap.String("organization_ref", orgRef.Hex()), - zap.Bool("has_idempotency_key", hasKey), + mzap.ObjRef("organization_ref", orgRef), + zap.String("idempotency_key", idempotencyKey), } if payment != nil { fields = append(fields, @@ -230,10 +270,14 @@ func (s *svc) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, ide ) } if err != nil { + if errors.Is(err, ErrPaymentNotFound) { + logger.Debug("Completed Get by idempotency key", append(fields, zap.Bool("found", false))...) + return + } logger.Warn("Failed to get by idempotency key", append(fields, zap.Error(err))...) return } - logger.Debug("Completed Get by idempotency key", fields...) + logger.Debug("Completed Get by idempotency key", append(fields, zap.Bool("found", true))...) }(time.Now()) if orgRef.IsZero() { @@ -329,6 +373,41 @@ func (s *svc) ListByState(ctx context.Context, in ListByStateInput) (out *ListOu return out, err } +func (s *svc) ListByStateGlobal(ctx context.Context, in ListByStateGlobalInput) (out *ListOutput, err error) { + logger := s.logger + logger.Debug("Starting List by state global", + zap.String("state", string(in.State)), + zap.Int32("limit", in.Limit), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, zap.Int("items_count", len(out.Items))) + } + if err != nil { + logger.Warn("Failed to list by state global", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed List by state global", fields...) + }(time.Now()) + + state, ok := normalizeAggregateState(in.State) + if !ok { + return nil, merrors.InvalidArgument("state is invalid") + } + cursor, err := normalizeCursor(in.Cursor) + if err != nil { + return nil, err + } + out, err = s.list(ctx, listQuery{ + limit: sanitizeLimit(in.Limit), + run: func(limit int64) ([]*paymentDocument, error) { + return s.store.ListByStateGlobal(ctx, state, cursor, limit) + }, + }) + return out, err +} + type listQuery struct { limit int64 run func(limit int64) ([]*paymentDocument, error) @@ -453,7 +532,7 @@ func normalizePayment(payment *agg.Payment, requirePaymentRef bool) (*paymentDoc step.FailureCode = strings.TrimSpace(step.FailureCode) step.FailureMsg = strings.TrimSpace(step.FailureMsg) if step.StepRef == "" { - return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref is required") + return nil, merrors.InvalidArgument("stepExecutions[" + itoa(i) + "].step_ref is required") } if step.StepCode == "" { step.StepCode = step.StepRef @@ -463,7 +542,7 @@ func normalizePayment(payment *agg.Payment, requirePaymentRef bool) (*paymentDoc } ss, ok := normalizeStepState(step.State) if !ok { - return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].state is invalid") + return nil, merrors.InvalidArgument("stepExecutions[" + itoa(i) + "].state is invalid") } step.State = ss } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go index f048bd59..a5370604 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go @@ -15,6 +15,9 @@ import ( pm "github.com/tech/sendico/pkg/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) { @@ -24,14 +27,16 @@ func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) { t.Fatalf("newWithStore returned error: %v", err) } - if len(store.indexes) != 4 { - t.Fatalf("index count mismatch: got=%d want=4", len(store.indexes)) + if len(store.indexes) != 6 { + t.Fatalf("index count mismatch: got=%d want=6", len(store.indexes)) } assertIndex(t, store.indexes[0], []string{"organizationRef", "paymentRef"}, true) - assertIndex(t, store.indexes[1], []string{"organizationRef", "idempotencyKey"}, true) - assertIndex(t, store.indexes[2], []string{"organizationRef", "quotationRef", "createdAt"}, false) - assertIndex(t, store.indexes[3], []string{"organizationRef", "state", "createdAt"}, false) + assertIndex(t, store.indexes[1], []string{"paymentRef"}, false) + assertIndex(t, store.indexes[2], []string{"organizationRef", "idempotencyKey"}, true) + assertIndex(t, store.indexes[3], []string{"organizationRef", "quotationRef", "createdAt"}, false) + assertIndex(t, store.indexes[4], []string{"organizationRef", "state", "createdAt"}, false) + assertIndex(t, store.indexes[5], []string{"state", "createdAt"}, false) } func TestCreateAndGet(t *testing.T) { @@ -89,6 +94,28 @@ func TestCreateAndGet(t *testing.T) { } } +func TestGetByIdempotencyKey_NotFoundDoesNotWarn(t *testing.T) { + store := newFakeStore() + core, observed := observer.New(zapcore.DebugLevel) + repo, err := newWithStoreLogger(store, zap.New(core)) + if err != nil { + t.Fatalf("newWithStoreLogger returned error: %v", err) + } + + org := bson.NewObjectID() + _, err = repo.GetByIdempotencyKey(context.Background(), org, "missing-idempotency-key") + if !errors.Is(err, ErrPaymentNotFound) { + t.Fatalf("expected ErrPaymentNotFound, got %v", err) + } + + if got := observed.FilterMessage("Failed to get by idempotency key").Len(); got != 0 { + t.Fatalf("expected no warning log for not found lookup, got=%d", got) + } + if got := observed.FilterMessage("Completed Get by idempotency key").Len(); got == 0 { + t.Fatal("expected completion debug log for not found lookup") + } +} + func TestCreate_Duplicate(t *testing.T) { store := newFakeStore() repo, err := newWithStore(store) @@ -354,6 +381,15 @@ func (f *fakeStore) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, pay return nil, ErrPaymentNotFound } +func (f *fakeStore) GetByPaymentRefGlobal(_ context.Context, paymentRef string) (*paymentDocument, error) { + for _, doc := range f.docs { + if doc.PaymentRef == paymentRef { + return cloneDocument(doc) + } + } + return nil, ErrPaymentNotFound +} + func (f *fakeStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) { for _, doc := range f.docs { if doc.OrganizationRef == orgRef && doc.IdempotencyKey == idempotencyKey { @@ -383,6 +419,12 @@ func (f *fakeStore) ListByState(_ context.Context, orgRef bson.ObjectID, state a }, cursor, limit) } +func (f *fakeStore) ListByStateGlobal(_ context.Context, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) { + return f.list(func(doc *paymentDocument) bool { + return doc.State == state + }, cursor, limit) +} + func (f *fakeStore) list(match func(*paymentDocument) bool, cursor *listCursor, limit int64) ([]*paymentDocument, error) { items := make([]*paymentDocument, 0) for _, doc := range f.docs { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go index 4399b715..b596a5c3 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go @@ -59,10 +59,10 @@ func validatePaymentInvariants(payment *agg.Payment) error { func validateStepInvariants(step agg.StepExecution, index int) error { if strings.TrimSpace(step.StepRef) == "" { - return merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].step_ref is required") + return merrors.InvalidArgument("payment.stepExecutions[" + itoa(index) + "].step_ref is required") } if _, ok := normalizeStepState(step.State); !ok { - return merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].state is invalid") + return merrors.InvalidArgument("payment.stepExecutions[" + itoa(index) + "].state is invalid") } return nil } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go index 3bd975b6..6068b286 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go @@ -37,5 +37,5 @@ func New(deps ...Dependencies) Mapper { if logger == nil { logger = zap.NewNop() } - return &svc{logger: logger.Named("prmap")} + return &svc{logger: logger.Named("mapper")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go index e9814d05..a56147b2 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go @@ -27,7 +27,7 @@ func mapStepExecutions(src []agg.StepExecution) ([]*orchestrationv2.StepExecutio func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepExecution, error) { state, ok := normalizeStepState(step.State) if !ok { - return nil, merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].state is invalid") + return nil, merrors.InvalidArgument("payment.stepExecutions[" + itoa(index) + "].state is invalid") } attempt := step.Attempt diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go index 1654b174..2dad8b01 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go @@ -25,14 +25,14 @@ func (s *svc) recomputeAggregateState(ctx context.Context, payment *agg.Payment) return false, nil } payment.State = next - logger.Debug("psvc.payment_state_changed", + logger.Debug("Recomputed payment state from step executions", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.String("from_state", string(current)), zap.String("to_state", string(next)), zap.Uint64("version", payment.Version), ) if next == agg.StateSettled || next == agg.StateNeedsAttention || next == agg.StateFailed { - logger.Debug("psvc.payment_finalization_state", + logger.Debug("Payment entered finalization state", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.String("state", string(next)), zap.Uint64("version", payment.Version), diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go index 20c18e76..9b3e7b17 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go @@ -6,6 +6,9 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/mlogger" + paymenttypes "github.com/tech/sendico/pkg/payments/types" ) type defaultLedgerExecutor struct{} @@ -13,6 +16,43 @@ type defaultCryptoExecutor struct{} type defaultProviderSettlementExecutor struct{} type defaultCardPayoutExecutor struct{} type defaultObserveConfirmExecutor struct{} +type defaultGuardExecutor struct{} + +// NewDefaultExecutors constructs the baseline executor registry and applies any +// provided overrides. +func NewDefaultExecutors(logger mlogger.Logger, overrides sexec.Dependencies) sexec.Registry { + deps := sexec.Dependencies{ + Logger: logger, + Ledger: defaultLedgerExecutor{}, + Crypto: defaultCryptoExecutor{}, + ProviderSettlement: defaultProviderSettlementExecutor{}, + CardPayout: defaultCardPayoutExecutor{}, + ObserveConfirm: defaultObserveConfirmExecutor{}, + Guard: defaultGuardExecutor{}, + } + if overrides.Logger != nil { + deps.Logger = overrides.Logger + } + if overrides.Ledger != nil { + deps.Ledger = overrides.Ledger + } + if overrides.Crypto != nil { + deps.Crypto = overrides.Crypto + } + if overrides.ProviderSettlement != nil { + deps.ProviderSettlement = overrides.ProviderSettlement + } + if overrides.CardPayout != nil { + deps.CardPayout = overrides.CardPayout + } + if overrides.ObserveConfirm != nil { + deps.ObserveConfirm = overrides.ObserveConfirm + } + if overrides.Guard != nil { + deps.Guard = overrides.Guard + } + return sexec.New(deps) +} func (defaultLedgerExecutor) ExecuteLedger(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { step := req.StepExecution @@ -35,7 +75,37 @@ func (defaultCardPayoutExecutor) ExecuteCardPayout(_ context.Context, req sexec. } func (defaultObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { - return asyncOutput(req.StepExecution, "operation_ref", "observe:"+req.Step.StepRef), nil + refs := inheritedExternalRefs(req.Payment, req.Step, req.StepExecution) + if len(refs) == 0 { + refs = append(refs, agg.ExternalRef{ + Kind: "operation_ref", + Ref: "observe:" + req.Step.StepRef, + }) + } + step := req.StepExecution + step.State = agg.StepStateRunning + step.ExternalRefs = refs + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{ + StepExecution: step, + Async: true, + }, nil +} + +func (defaultGuardExecutor) ExecuteGuard(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + conditions := quoteExecutionConditions(req.Payment) + switch guardKind(req.Step) { + case xplan.StepKindLiquidityCheck: + return executeLiquidityGuard(req.StepExecution, conditions), nil + case xplan.StepKindPrefunding: + return executePrefundingGuard(req.StepExecution, conditions), nil + default: + return failedOutput(req.StepExecution, + "guard.unsupported_step", + "unsupported guard step: step_code="+strings.TrimSpace(req.Step.StepCode), + ), nil + } } func asyncOutput(step agg.StepExecution, kind, ref string) *sexec.ExecuteOutput { @@ -51,3 +121,120 @@ func asyncOutput(step agg.StepExecution, kind, ref string) *sexec.ExecuteOutput Async: true, } } + +func completedOutput(step agg.StepExecution) *sexec.ExecuteOutput { + step.State = agg.StepStateCompleted + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{StepExecution: step} +} + +func failedOutput(step agg.StepExecution, code, msg string) *sexec.ExecuteOutput { + step.State = agg.StepStateFailed + step.FailureCode = strings.TrimSpace(code) + step.FailureMsg = strings.TrimSpace(msg) + return &sexec.ExecuteOutput{StepExecution: step} +} + +func executeLiquidityGuard( + step agg.StepExecution, + conditions *paymenttypes.QuoteExecutionConditions, +) *sexec.ExecuteOutput { + if conditions == nil { + return failedOutput(step, "guard.conditions_missing", "liquidity guard requires execution conditions") + } + + switch conditions.Readiness { + case paymenttypes.QuoteExecutionReadinessIndicative: + return failedOutput(step, "guard.indicative_quote", "liquidity guard cannot execute indicative quotes") + case paymenttypes.QuoteExecutionReadinessLiquidityObtainable: + return failedOutput(step, "guard.liquidity_not_ready", "liquidity is not yet available at execution time") + case paymenttypes.QuoteExecutionReadinessUnspecified: + return failedOutput(step, "guard.readiness_unspecified", "liquidity guard requires explicit readiness") + default: + return completedOutput(step) + } +} + +func executePrefundingGuard( + step agg.StepExecution, + conditions *paymenttypes.QuoteExecutionConditions, +) *sexec.ExecuteOutput { + if conditions == nil { + return failedOutput(step, "guard.conditions_missing", "prefunding guard requires execution conditions") + } + if conditions.Readiness == paymenttypes.QuoteExecutionReadinessIndicative { + return failedOutput(step, "guard.indicative_quote", "prefunding guard cannot execute indicative quotes") + } + // Prefunding confirmation is handled by upstream funding flows; this guard + // currently validates quote executability semantics only. + return completedOutput(step) +} + +func quoteExecutionConditions(payment *agg.Payment) *paymenttypes.QuoteExecutionConditions { + if payment == nil || payment.QuoteSnapshot == nil { + return nil + } + return payment.QuoteSnapshot.ExecutionConditions +} + +func guardKind(step xplan.Step) xplan.StepKind { + return xplan.GuardStepKind(step) +} + +func inheritedExternalRefs(payment *agg.Payment, step xplan.Step, current agg.StepExecution) []agg.ExternalRef { + refs := appendExternalRefs(nil, current.ExternalRefs...) + if payment == nil || len(step.DependsOn) == 0 { + return refs + } + index := stepIndexByRef(payment.StepExecutions) + for i := range step.DependsOn { + idx, ok := index[strings.TrimSpace(step.DependsOn[i])] + if !ok || idx < 0 || idx >= len(payment.StepExecutions) { + continue + } + refs = appendExternalRefs(refs, payment.StepExecutions[idx].ExternalRefs...) + } + return refs +} + +func appendExternalRefs(existing []agg.ExternalRef, additions ...agg.ExternalRef) []agg.ExternalRef { + out := append([]agg.ExternalRef{}, existing...) + seen := map[string]struct{}{} + for i := range out { + key := externalRefKey(out[i]) + if key == "" { + continue + } + seen[key] = struct{}{} + } + for i := range additions { + ref := agg.ExternalRef{ + GatewayInstanceID: strings.TrimSpace(additions[i].GatewayInstanceID), + Kind: strings.TrimSpace(additions[i].Kind), + Ref: strings.TrimSpace(additions[i].Ref), + } + key := externalRefKey(ref) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, ref) + } + if len(out) == 0 { + return nil + } + return out +} + +func externalRefKey(ref agg.ExternalRef) string { + if strings.TrimSpace(ref.Kind) == "" || strings.TrimSpace(ref.Ref) == "" { + return "" + } + return strings.ToLower(strings.TrimSpace(ref.GatewayInstanceID)) + "|" + + strings.ToLower(strings.TrimSpace(ref.Kind)) + "|" + + strings.ToLower(strings.TrimSpace(ref.Ref)) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors_test.go new file mode 100644 index 00000000..8a7898de --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors_test.go @@ -0,0 +1,164 @@ +package psvc + +import ( + "context" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func TestDefaultGuardExecutor_LiquidityReadyCompletes(t *testing.T) { + exec := defaultGuardExecutor{} + out, err := exec.ExecuteGuard(context.Background(), sexec.StepRequest{ + Payment: &agg.Payment{ + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: paymenttypes.QuoteExecutionReadinessLiquidityReady, + LiquidityCheckRequiredAtExecution: true, + }, + }, + }, + Step: xplan.Step{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Kind: xplan.StepKindLiquidityCheck, + }, + StepExecution: agg.StepExecution{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteGuard returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } +} + +func TestDefaultGuardExecutor_LiquidityObtainableFails(t *testing.T) { + exec := defaultGuardExecutor{} + out, err := exec.ExecuteGuard(context.Background(), sexec.StepRequest{ + Payment: &agg.Payment{ + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: paymenttypes.QuoteExecutionReadinessLiquidityObtainable, + LiquidityCheckRequiredAtExecution: true, + }, + }, + }, + Step: xplan.Step{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Kind: xplan.StepKindLiquidityCheck, + }, + StepExecution: agg.StepExecution{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteGuard returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := out.StepExecution.State, agg.StepStateFailed; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if got, want := out.StepExecution.FailureCode, "guard.liquidity_not_ready"; got != want { + t.Fatalf("failure code mismatch: got=%q want=%q", got, want) + } +} + +func TestDefaultGuardExecutor_UnknownGuardFails(t *testing.T) { + exec := defaultGuardExecutor{} + out, err := exec.ExecuteGuard(context.Background(), sexec.StepRequest{ + Payment: &agg.Payment{ + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: paymenttypes.QuoteExecutionReadinessLiquidityReady, + }, + }, + }, + Step: xplan.Step{ + StepRef: "guard_unknown", + StepCode: "guard.custom", + Kind: xplan.StepKindUnspecified, + }, + StepExecution: agg.StepExecution{ + StepRef: "guard_unknown", + StepCode: "guard.custom", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteGuard returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := out.StepExecution.State, agg.StepStateFailed; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if got, want := out.StepExecution.FailureCode, "guard.unsupported_step"; got != want { + t.Fatalf("failure code mismatch: got=%q want=%q", got, want) + } +} + +func TestDefaultObserveConfirmExecutor_InheritsDependencyRefs(t *testing.T) { + exec := defaultObserveConfirmExecutor{} + out, err := exec.ExecuteObserveConfirm(context.Background(), sexec.StepRequest{ + Payment: &agg.Payment{ + StepExecutions: []agg.StepExecution{ + { + StepRef: "hop_1_crypto_send", + ExternalRefs: []agg.ExternalRef{ + {GatewayInstanceID: "crypto-gw", Kind: "operation_ref", Ref: "op-1"}, + {GatewayInstanceID: "crypto-gw", Kind: "transfer_ref", Ref: "trf-1"}, + }, + }, + }, + }, + Step: xplan.Step{ + StepRef: "hop_1_crypto_observe", + StepCode: "hop.1.crypto.observe", + DependsOn: []string{"hop_1_crypto_send"}, + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_1_crypto_observe", + StepCode: "hop.1.crypto.observe", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteObserveConfirm returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if !out.Async { + t.Fatal("expected async observe output") + } + if got, want := out.StepExecution.State, agg.StepStateRunning; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if got, want := len(out.StepExecution.ExternalRefs), 2; got != want { + t.Fatalf("external refs count mismatch: got=%d want=%d", got, want) + } + if got, want := out.StepExecution.ExternalRefs[0].Kind, "operation_ref"; got != want { + t.Fatalf("first external ref kind mismatch: got=%q want=%q", got, want) + } + if got, want := out.StepExecution.ExternalRefs[0].Ref, "op-1"; got != want { + t.Fatalf("first external ref value mismatch: got=%q want=%q", got, want) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index 1e2e6289..afe1e0af 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -63,7 +63,7 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa } } if payment != nil { - logger.Debug("psvc.payment_started", + logger.Debug("Loaded payment for execution", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.Bool("reused", reused), zap.String("state", string(payment.State)), @@ -75,7 +75,7 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa return nil, err } if payment != nil { - logger.Debug("psvc.payment_execution_progressed", + logger.Debug("Completed runtime loop for payment execution", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.String("state", string(payment.State)), zap.Uint64("version", payment.Version), @@ -86,7 +86,7 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa return nil, err } if payment != nil { - logger.Debug("psvc.payment_finalized", + logger.Debug("Prepared finalized payment response", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.String("state", string(payment.State)), zap.Uint64("version", payment.Version), diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go index 62a36958..f8b54b2d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go @@ -27,7 +27,7 @@ func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Paymen state = payment.State stepCount = len(payment.StepExecutions) } - logger.Debug("Starting Run runtime", + logger.Debug("Starting payment runtime loop", zap.String("payment_ref", paymentRef), zap.String("state", string(state)), zap.Int("steps_count", stepCount), @@ -37,7 +37,10 @@ func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Paymen return nil, merrors.InvalidArgument("payment is required") } if s.state.IsAggregateTerminal(payment.State) { - logger.Debug("psvc.run_runtime.terminal", zap.String("payment_ref", paymentRef), zap.String("state", string(payment.State))) + logger.Debug("Skipping runtime loop because payment is already terminal", + zap.String("payment_ref", paymentRef), + zap.String("state", string(payment.State)), + ) return payment, nil } @@ -52,7 +55,7 @@ func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Paymen if err != nil { return nil, err } - logger.Debug("psvc.run_runtime.tick", + logger.Debug("Processed runtime tick for payment", zap.String("payment_ref", paymentRef), zap.Int("tick", tick), zap.Bool("changed", changed), @@ -72,7 +75,7 @@ func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Paymen } current = updated } - logger.Debug("psvc.run_runtime.max_ticks_reached", + logger.Debug("Stopped runtime loop after reaching max ticks", zap.String("payment_ref", paymentRef), zap.Int("max_ticks", s.maxTicks), zap.String("state", string(current.State)), @@ -93,7 +96,7 @@ func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Gr if err != nil { return nil, false, false, err } - logger.Debug("psvc.run_tick.scheduled", + logger.Debug("Calculated runnable, blocked, and skipped steps for tick", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.Int("runnable_count", len(scheduled.Runnable)), zap.Int("blocked_count", len(scheduled.Blocked)), @@ -126,7 +129,7 @@ func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Gr if err := s.repository.UpdateCAS(ctx, payment, expectedVersion); err != nil { if errors.Is(err, prepo.ErrVersionConflict) { - logger.Debug("psvc.run_tick.cas_conflict", + logger.Debug("Detected version conflict while persisting tick; reloading payment", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.Uint64("expected_version", expectedVersion), ) @@ -138,7 +141,7 @@ func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Gr } return nil, false, false, err } - logger.Debug("psvc.run_tick.persisted", + logger.Debug("Persisted tick updates to payment", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.Uint64("version", payment.Version), zap.String("state", string(payment.State)), @@ -200,7 +203,7 @@ func (s *svc) recordScheduleTransitions(ctx context.Context, payment *agg.Paymen func (s *svc) executeRunnable(ctx context.Context, payment *agg.Payment, graph *xplan.Graph, runnable ssched.RunnableStep) (bool, error) { logger := s.logger - logger.Debug("Starting Step execution", + logger.Debug("Starting step execution attempt", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.String("step_ref", strings.TrimSpace(runnable.StepRef)), zap.String("step_code", strings.TrimSpace(runnable.StepCode)), @@ -244,7 +247,7 @@ func (s *svc) executeRunnable(ctx context.Context, payment *agg.Payment, graph * StepExecution: stepExecution, }) if err != nil { - logger.Warn("psvc.step_execution.executor_error", + logger.Warn("Step executor returned error; marking step as failed", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.String("step_ref", strings.TrimSpace(runnable.StepRef)), zap.Uint32("attempt", runnable.Attempt), @@ -264,7 +267,7 @@ func (s *svc) executeRunnable(ctx context.Context, payment *agg.Payment, graph * next := normalizeExecutorOutput(stepExecution, out, s.nowUTC()) payment.StepExecutions[idx] = next - logger.Debug("Completed Step execution", + logger.Debug("Completed step execution attempt", zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), zap.String("step_ref", strings.TrimSpace(next.StepRef)), zap.String("state", string(next.State)), diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go index 9ed79e86..c6e46403 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go @@ -62,12 +62,12 @@ func newService(deps Dependencies) (Service, error) { if logger == nil { logger = zap.NewNop() } - logger = logger.Named("psvc") + logger = logger.Named("imp") observer := deps.Observer if observer == nil { var err error - observer, err = oobs.New(oobs.Dependencies{Logger: logger.Named("oobs")}) + observer, err = oobs.New(oobs.Dependencies{Logger: logger}) if err != nil { return nil, err } @@ -78,7 +78,7 @@ func newService(deps Dependencies) (Service, error) { var err error query, err = pquery.New(pquery.Dependencies{ Repository: deps.Repository, - Logger: logger.Named("pquery"), + Logger: logger, }) if err != nil { return nil, err @@ -90,18 +90,18 @@ func newService(deps Dependencies) (Service, error) { quoteStore: deps.QuoteStore, - validator: firstValidator(deps.Validator, logger.Named("reqval")), - idempotency: firstIdempotency(deps.Idempotency, logger.Named("idem")), - quote: firstQuoteResolver(deps.Quote, logger.Named("qsnap")), - aggregate: firstAggregateFactory(deps.Aggregate, logger.Named("agg")), - planner: firstPlanCompiler(deps.Planner, logger.Named("xplan")), - state: firstStateMachine(deps.State, logger.Named("ostate")), - scheduler: firstScheduler(deps.Scheduler, logger.Named("ssched")), - executors: firstExecutors(deps.Executors, logger.Named("sexec")), - reconciler: firstReconciler(deps.Reconciler, logger.Named("erecon")), + validator: firstValidator(deps.Validator, logger), + idempotency: firstIdempotency(deps.Idempotency, logger), + quote: firstQuoteResolver(deps.Quote, logger), + aggregate: firstAggregateFactory(deps.Aggregate, logger), + planner: firstPlanCompiler(deps.Planner, logger), + state: firstStateMachine(deps.State, logger), + scheduler: firstScheduler(deps.Scheduler, logger), + executors: firstExecutors(deps.Executors, logger), + reconciler: firstReconciler(deps.Reconciler, logger), repository: deps.Repository, query: query, - mapper: firstMapper(deps.Mapper, logger.Named("prmap")), + mapper: firstMapper(deps.Mapper, logger), observer: observer, retryPolicy: deps.RetryPolicy, @@ -173,14 +173,7 @@ func firstExecutors(v sexec.Registry, logger mlogger.Logger) sexec.Registry { if v != nil { return v } - return sexec.New(sexec.Dependencies{ - Logger: logger, - Ledger: defaultLedgerExecutor{}, - Crypto: defaultCryptoExecutor{}, - ProviderSettlement: defaultProviderSettlementExecutor{}, - CardPayout: defaultCardPayoutExecutor{}, - ObserveConfirm: defaultObserveConfirmExecutor{}, - }) + return NewDefaultExecutors(logger, sexec.Dependencies{}) } func firstReconciler(v erecon.Reconciler, logger mlogger.Logger) erecon.Reconciler { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 00b4770f..1ea46f17 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -287,6 +287,7 @@ func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) ( ProviderSettlement: script, CardPayout: script, ObserveConfirm: script, + Guard: script, }) svc, err := New(Dependencies{ @@ -328,6 +329,9 @@ func (s *scriptedExecutors) ExecuteCardPayout(_ context.Context, req sexec.StepR func (s *scriptedExecutors) ExecuteObserveConfirm(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { return s.handler("observe_confirm", req) } +func (s *scriptedExecutors) ExecuteGuard(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("guard", req) +} type memoryQuoteStore struct { mu sync.Mutex @@ -456,6 +460,19 @@ func (r *memoryRepo) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, pa return clonePayment(r.byID[id]), nil } +func (r *memoryRepo) GetByPaymentRefGlobal(_ context.Context, paymentRef string) (*agg.Payment, error) { + r.mu.Lock() + defer r.mu.Unlock() + ref := strings.TrimSpace(paymentRef) + for _, payment := range r.byID { + if strings.TrimSpace(payment.PaymentRef) != ref { + continue + } + return clonePayment(payment), nil + } + return nil, prepo.ErrPaymentNotFound +} + func (r *memoryRepo) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*agg.Payment, error) { r.mu.Lock() defer r.mu.Unlock() @@ -504,6 +521,22 @@ func (r *memoryRepo) ListByState(_ context.Context, in prepo.ListByStateInput) ( return paginatePayments(items, in.Limit), nil } +func (r *memoryRepo) ListByStateGlobal(_ context.Context, in prepo.ListByStateGlobalInput) (*prepo.ListOutput, error) { + r.mu.Lock() + defer r.mu.Unlock() + items := make([]*agg.Payment, 0) + for _, payment := range r.byID { + if payment.State != in.State { + continue + } + if !isBeforeCursor(payment, in.Cursor) { + continue + } + items = append(items, clonePayment(payment)) + } + return paginatePayments(items, in.Limit), nil +} + func repoPaymentRefKey(orgRef bson.ObjectID, paymentRef string) string { return orgRef.Hex() + "|" + strings.TrimSpace(paymentRef) } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index 12cc4d6d..1bf26c96 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -55,7 +55,7 @@ func New(deps ...Dependencies) Resolver { now = time.Now } return &svc{ - logger: logger.Named("qsnap"), + logger: logger.Named("quote_resolver"), now: now, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go index 95b6cf2c..242b2d7f 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go @@ -55,5 +55,5 @@ func New(deps ...Dependencies) Validator { if logger == nil { logger = zap.NewNop() } - return &svc{logger: logger.Named("reqval")} + return &svc{logger: logger.Named("request_validator")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go index 8b21229f..d55efd0c 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go @@ -60,6 +60,11 @@ type ObserveConfirmExecutor interface { ExecuteObserveConfirm(ctx context.Context, req StepRequest) (*ExecuteOutput, error) } +// GuardExecutor handles non-rail guard steps (liquidity/prefunding). +type GuardExecutor interface { + ExecuteGuard(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + // Dependencies defines concrete executors used by the registry. type Dependencies struct { Logger mlogger.Logger @@ -68,6 +73,7 @@ type Dependencies struct { ProviderSettlement ProviderSettlementExecutor CardPayout CardPayoutExecutor ObserveConfirm ObserveConfirmExecutor + Guard GuardExecutor } func New(deps Dependencies) Registry { @@ -76,7 +82,7 @@ func New(deps Dependencies) Registry { logger = zap.NewNop() } return &svc{ - logger: logger.Named("sexec"), + logger: logger.Named("executor"), deps: deps, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go index 2f526a5d..2d6cad0a 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go @@ -14,9 +14,14 @@ const ( routeProviderSettlement routeCardPayout routeObserveConfirm + routeGuard ) func classifyRoute(step xplan.Step) route { + if isGuardStep(step) { + return routeGuard + } + action := normalizeAction(step.Action) rail := normalizeRail(step.Rail) @@ -54,6 +59,10 @@ func classifyRoute(step xplan.Step) route { } } +func isGuardStep(step xplan.Step) bool { + return xplan.GuardStepKind(step) != xplan.StepKindUnspecified +} + func isLedgerAction(action model.RailOperation) bool { switch action { case model.RailOperationDebit, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go index 9a8130af..adb09675 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go @@ -79,6 +79,12 @@ func (s *svc) Execute(ctx context.Context, in ExecuteInput) (out *ExecuteOutput, } out, err = s.deps.ObserveConfirm.ExecuteObserveConfirm(ctx, req) return out, err + case routeGuard: + if s.deps.Guard == nil { + return nil, missingExecutorError("guard") + } + out, err = s.deps.Guard.ExecuteGuard(ctx, req) + return out, err default: return nil, unsupportedStepError(req.Step) } @@ -140,6 +146,20 @@ func missingExecutorError(kind string) error { } func unsupportedStepError(step xplan.Step) error { - msg := "action=" + strings.TrimSpace(string(step.Action)) + " rail=" + strings.TrimSpace(string(step.Rail)) + msg := strings.Join([]string{ + "step_ref=" + stepField(step.StepRef), + "step_code=" + stepField(step.StepCode), + "step_kind=" + stepField(string(step.Kind)), + "action=" + stepField(string(step.Action)), + "rail=" + stepField(string(step.Rail)), + }, " ") return xerr.Wrapf(ErrUnsupportedStep, "%s", msg) } + +func stepField(value string) string { + clean := strings.TrimSpace(value) + if clean == "" { + return "UNSPECIFIED" + } + return clean +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go index 3f85f087..caac0a67 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go @@ -3,6 +3,7 @@ package sexec import ( "context" "errors" + "strings" "testing" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" @@ -131,6 +132,40 @@ func TestExecute_DispatchRailsAndObserve(t *testing.T) { } } +func TestExecute_DispatchGuard(t *testing.T) { + guard := &fakeGuardExecutor{} + registry := New(Dependencies{Guard: guard}) + + out, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Kind: xplan.StepKindLiquidityCheck, + Action: model.RailOperationUnspecified, + Rail: model.RailUnspecified, + DependsOn: nil, + }, + StepExecution: agg.StepExecution{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if guard.calls != 1 { + t.Fatalf("expected guard executor to be called once, got %d", guard.calls) + } + if got, want := guard.lastReq.Step.StepRef, xplan.QuoteReadinessGuardStepRef; got != want { + t.Fatalf("step_ref mismatch: got=%q want=%q", got, want) + } +} + func TestExecute_UnsupportedStep(t *testing.T) { registry := New(Dependencies{}) @@ -142,6 +177,21 @@ func TestExecute_UnsupportedStep(t *testing.T) { if !errors.Is(err, ErrUnsupportedStep) { t.Fatalf("expected ErrUnsupportedStep, got %v", err) } + if err == nil { + t.Fatal("expected non-nil error") + } + msg := err.Error() + for _, token := range []string{ + "step_ref=s1", + "step_code=bad.send", + "step_kind=UNSPECIFIED", + "action=SEND", + "rail=LEDGER", + } { + if !strings.Contains(msg, token) { + t.Fatalf("expected error message to include %q, got %q", token, msg) + } + } } func TestExecute_UnsupportedProviderSettlementSend(t *testing.T) { @@ -280,3 +330,17 @@ func (f *fakeObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, re Async: true, }, nil } + +type fakeGuardExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeGuardExecutor) ExecuteGuard(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: false, + }, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go index 9a202502..2ff07d69 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go @@ -127,10 +127,10 @@ func (s *svc) normalizeStepExecutions( } stepRef := exec.StepRef if _, ok := stepsByRef[stepRef]; !ok { - return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref is unknown: " + stepRef) + return nil, merrors.InvalidArgument("stepExecutions[" + itoa(i) + "].step_ref is unknown: " + stepRef) } if _, exists := out[stepRef]; exists { - return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref must be unique") + return nil, merrors.InvalidArgument("stepExecutions[" + itoa(i) + "].step_ref must be unique") } if exec.Attempt == 0 { exec.Attempt = 1 @@ -156,16 +156,16 @@ func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.Ste exec.FailureMsg = strings.TrimSpace(exec.FailureMsg) exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs) if exec.StepRef == "" { - return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].step_ref is required") + return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].step_ref is required") } state, ok := normalizeStepState(exec.State) if !ok { - return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].state is invalid") + return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].state is invalid") } exec.State = state if err := s.stateMachine.EnsureStepTransition(exec.State, exec.State); err != nil { - return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].state is invalid") + return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].state is invalid") } return exec, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go index f7c3d1d0..dc749d32 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go @@ -78,9 +78,10 @@ func New(deps ...Dependencies) Runtime { if logger == nil { logger = zap.NewNop() } + logger = logger.Named("scheduler") stateMachine := dep.StateMachine if stateMachine == nil { - stateMachine = ostate.New(ostate.Dependencies{Logger: logger.Named("ssched.ostate")}) + stateMachine = ostate.New(ostate.Dependencies{Logger: logger}) } now := dep.Now if now == nil { @@ -89,7 +90,7 @@ func New(deps ...Dependencies) Runtime { } } return &svc{ - logger: logger.Named("ssched"), + logger: logger, stateMachine: stateMachine, now: now, } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go index 5f94a778..0b1a86de 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go @@ -222,7 +222,7 @@ func evaluateGate(step xplan.Step, executionsByRef map[string]*agg.StepExecution return evaluateTerminal(stepCommitTargets(step), executionsByRef, maxAttemptsByRef) default: for _, outcome := range depOutcomes { - if outcome == outcomeFailure { + if outcome == outcomeFailure || outcome == outcomeSkipped { return gateImpossible } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go index 7128937b..72949bd8 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go @@ -172,6 +172,41 @@ func TestSchedule_FailedDependencySkipsImmediateDependents(t *testing.T) { assertBlockedReason(t, out, "a", BlockedNeedsAttention) } +func TestSchedule_SkippedDependencyAlsoSkipsDependent(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("guard", nil), + step("send", []string{"guard"}), + step("observe", []string{"send"}), + }, + StepExecutions: []agg.StepExecution{ + exec("guard", agg.StepStateNeedsAttention, 2), + exec("send", agg.StepStatePending, 1), + exec("observe", agg.StepStatePending, 1), + }, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + if len(out.Runnable) != 0 { + t.Fatalf("expected no runnable steps, got %d", len(out.Runnable)) + } + assertSkippedRefs(t, out, []string{"send", "observe"}) + assertBlockedReason(t, out, "guard", BlockedNeedsAttention) + + send := mustExecution(t, out, "send") + if send.State != agg.StepStateSkipped { + t.Fatalf("send state mismatch: got=%q want=%q", send.State, agg.StepStateSkipped) + } + observe := mustExecution(t, out, "observe") + if observe.State != agg.StepStateSkipped { + t.Fatalf("observe state mismatch: got=%q want=%q", observe.State, agg.StepStateSkipped) + } +} + func TestSchedule_ValidationErrors(t *testing.T) { runtime := New() diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/guard_ops.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/guard_ops.go new file mode 100644 index 00000000..17444ae6 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/guard_ops.go @@ -0,0 +1,52 @@ +package xplan + +import "strings" + +// GuardOperationName identifies non-rail guard operation codes. +type GuardOperationName string + +const ( + GuardOperationUnspecified GuardOperationName = "" + GuardOperationQuoteReadinessGuard GuardOperationName = "quote.readiness.guard" + GuardOperationPrefundingEnsure GuardOperationName = "prefunding.ensure" +) + +const ( + QuoteReadinessGuardStepRef = "quote_readiness" + PrefundingGuardStepRef = "prefunding_ensure" +) + +func ParseGuardOperationName(value string) GuardOperationName { + switch strings.ToLower(strings.TrimSpace(value)) { + case string(GuardOperationQuoteReadinessGuard): + return GuardOperationQuoteReadinessGuard + case string(GuardOperationPrefundingEnsure): + return GuardOperationPrefundingEnsure + default: + return GuardOperationUnspecified + } +} + +func IsLiquidityGuardOperation(value string) bool { + return ParseGuardOperationName(value) == GuardOperationQuoteReadinessGuard +} + +func IsPrefundingGuardOperation(value string) bool { + return ParseGuardOperationName(value) == GuardOperationPrefundingEnsure +} + +func GuardStepKind(step Step) StepKind { + switch strings.ToLower(strings.TrimSpace(string(step.Kind))) { + case string(StepKindLiquidityCheck): + return StepKindLiquidityCheck + case string(StepKindPrefunding): + return StepKindPrefunding + } + if IsLiquidityGuardOperation(step.StepCode) { + return StepKindLiquidityCheck + } + if IsPrefundingGuardOperation(step.StepCode) { + return StepKindPrefunding + } + return StepKindUnspecified +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go index 8e8686cb..a00b74fc 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go @@ -122,6 +122,6 @@ func New(deps ...Dependencies) Compiler { logger = zap.NewNop() } return &svc{ - logger: logger.Named("xplan"), + logger: logger.Named("plan_compiler"), } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go index 314a0dd3..7546ce6c 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -122,7 +122,8 @@ func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditio if conditions.LiquidityCheckRequiredAtExecution { ex.appendMain(Step{ - StepCode: "liquidity.check", + StepRef: QuoteReadinessGuardStepRef, + StepCode: string(GuardOperationQuoteReadinessGuard), Kind: StepKindLiquidityCheck, Action: model.RailOperationUnspecified, Rail: model.RailUnspecified, @@ -132,7 +133,8 @@ func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditio if conditions.PrefundingRequired { ex.appendMain(Step{ - StepCode: "prefunding.ensure", + StepRef: PrefundingGuardStepRef, + StepCode: string(GuardOperationPrefundingEnsure), Kind: StepKindPrefunding, Action: model.RailOperationUnspecified, Rail: model.RailUnspecified, diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go new file mode 100644 index 00000000..c8275bbc --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go @@ -0,0 +1,348 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "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" +) + +type gatewayCryptoExecutor struct { + gatewayInvokeResolver GatewayInvokeResolver + gatewayRegistry GatewayRegistry + cardGatewayRoutes map[string]CardGatewayRoute +} + +func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if req.Payment == nil { + return nil, merrors.InvalidArgument("crypto send: payment is required") + } + action := model.ParseRailOperation(string(req.Step.Action)) + switch action { + case model.RailOperationSend, model.RailOperationFee: + default: + return nil, merrors.InvalidArgument("crypto send: unsupported action") + } + gateway, err := e.resolveGateway(ctx, req.Step) + if err != nil { + return nil, err + } + client, err := e.gatewayInvokeResolver.Resolve(ctx, gateway.InvokeURI) + if err != nil { + return nil, err + } + sourceWalletRef, err := sourceManagedWalletRef(req.Payment) + if err != nil { + return nil, err + } + destination, err := e.resolveDestination(req.Payment, action) + if err != nil { + return nil, err + } + amount, err := sourceAmount(req.Payment) + if err != nil { + return nil, err + } + + stepRef := strings.TrimSpace(req.Step.StepRef) + operationRef := strings.TrimSpace(req.Payment.PaymentRef) + ":" + stepRef + idempotencyKey := strings.TrimSpace(req.Payment.IdempotencyKey) + if idempotencyKey == "" { + idempotencyKey = operationRef + } + idempotencyKey += ":" + stepRef + + resp, err := client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: req.Payment.OrganizationRef.Hex(), + SourceWalletRef: sourceWalletRef, + Destination: destination, + Amount: amount, + OperationRef: operationRef, + IntentRef: strings.TrimSpace(req.Payment.IntentSnapshot.Ref), + PaymentRef: strings.TrimSpace(req.Payment.PaymentRef), + Metadata: transferMetadata(req.Step), + }) + if err != nil { + return nil, err + } + if resp == nil || resp.GetTransfer() == nil { + return nil, merrors.Internal("crypto send: transfer response is missing") + } + + step := req.StepExecution + refs, refsErr := transferExternalRefs(resp.GetTransfer(), firstNonEmpty( + strings.TrimSpace(req.Step.InstanceID), + strings.TrimSpace(gateway.InstanceID), + strings.TrimSpace(req.Step.Gateway), + strings.TrimSpace(gateway.ID), + )) + if refsErr != nil { + return nil, refsErr + } + step.ExternalRefs = refs + step.State = agg.StepStateCompleted + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{StepExecution: step}, nil +} + +func (e *gatewayCryptoExecutor) resolveGateway(ctx context.Context, step xplan.Step) (*model.GatewayInstanceDescriptor, error) { + if e.gatewayRegistry == nil { + return nil, merrors.InvalidArgument("crypto send: gateway registry is required") + } + items, err := e.gatewayRegistry.List(ctx) + if err != nil { + return nil, err + } + stepGateway := strings.TrimSpace(step.Gateway) + stepInstance := strings.TrimSpace(step.InstanceID) + + var byInstance *model.GatewayInstanceDescriptor + var byGateway *model.GatewayInstanceDescriptor + var single *model.GatewayInstanceDescriptor + cryptoCount := 0 + for i := range items { + item := items[i] + if item == nil || model.ParseRail(string(item.Rail)) != model.RailCrypto || !item.IsEnabled { + continue + } + cryptoCount++ + single = item + if stepInstance != "" && (strings.EqualFold(strings.TrimSpace(item.InstanceID), stepInstance) || strings.EqualFold(strings.TrimSpace(item.ID), stepInstance)) { + byInstance = item + break + } + if stepGateway != "" && (strings.EqualFold(strings.TrimSpace(item.ID), stepGateway) || strings.EqualFold(strings.TrimSpace(item.InstanceID), stepGateway)) { + byGateway = item + } + } + switch { + case byInstance != nil: + if strings.TrimSpace(byInstance.InvokeURI) == "" { + return nil, merrors.InvalidArgument("crypto send: gateway invoke uri is missing") + } + return byInstance, nil + case byGateway != nil: + if strings.TrimSpace(byGateway.InvokeURI) == "" { + return nil, merrors.InvalidArgument("crypto send: gateway invoke uri is missing") + } + return byGateway, nil + case stepGateway == "" && stepInstance == "" && cryptoCount == 1: + if strings.TrimSpace(single.InvokeURI) == "" { + return nil, merrors.InvalidArgument("crypto send: gateway invoke uri is missing") + } + return single, nil + default: + return nil, merrors.InvalidArgument("crypto send: gateway instance not found") + } +} + +func sourceManagedWalletRef(payment *agg.Payment) (string, error) { + if payment == nil { + return "", merrors.InvalidArgument("crypto send: payment is required") + } + if payment.IntentSnapshot.Source.Type != model.EndpointTypeManagedWallet || payment.IntentSnapshot.Source.ManagedWallet == nil { + return "", merrors.InvalidArgument("crypto send: managed wallet source is required") + } + ref := strings.TrimSpace(payment.IntentSnapshot.Source.ManagedWallet.ManagedWalletRef) + if ref == "" { + return "", merrors.InvalidArgument("crypto send: source managed wallet ref is required") + } + return ref, nil +} + +func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) { + if payment == nil { + return nil, merrors.InvalidArgument("crypto send: payment is required") + } + money := effectiveSourceAmount(payment) + if money == nil { + return nil, merrors.InvalidArgument("crypto send: source amount is required") + } + amount := strings.TrimSpace(money.Amount) + currency := strings.TrimSpace(money.Currency) + if amount == "" || currency == "" { + return nil, merrors.InvalidArgument("crypto send: source amount is invalid") + } + return &moneyv1.Money{ + Amount: amount, + Currency: currency, + }, nil +} + +func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money { + if payment == nil { + return nil + } + if payment.QuoteSnapshot != nil && payment.QuoteSnapshot.DebitAmount != nil { + return payment.QuoteSnapshot.DebitAmount + } + return payment.IntentSnapshot.Amount +} + +func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action model.RailOperation) (*chainv1.TransferDestination, error) { + if payment == nil { + return nil, merrors.InvalidArgument("crypto send: payment is required") + } + destination := payment.IntentSnapshot.Destination + switch destination.Type { + case model.EndpointTypeManagedWallet: + if destination.ManagedWallet == nil || strings.TrimSpace(destination.ManagedWallet.ManagedWalletRef) == "" { + return nil, merrors.InvalidArgument("crypto send: destination managed wallet ref is required") + } + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ + ManagedWalletRef: strings.TrimSpace(destination.ManagedWallet.ManagedWalletRef), + }, + }, nil + case model.EndpointTypeExternalChain: + if destination.ExternalChain == nil || strings.TrimSpace(destination.ExternalChain.Address) == "" { + return nil, merrors.InvalidArgument("crypto send: destination external address is required") + } + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ + ExternalAddress: strings.TrimSpace(destination.ExternalChain.Address), + }, + Memo: strings.TrimSpace(destination.ExternalChain.Memo), + }, nil + case model.EndpointTypeCard: + address, err := e.resolveCardFundingAddress(payment, action) + if err != nil { + return nil, err + } + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ + ExternalAddress: address, + }, + }, nil + default: + return nil, merrors.InvalidArgument("crypto send: unsupported destination type") + } +} + +func (e *gatewayCryptoExecutor) resolveCardFundingAddress(payment *agg.Payment, action model.RailOperation) (string, error) { + if payment == nil { + return "", merrors.InvalidArgument("crypto send: payment is required") + } + gatewayKey := destinationCardGatewayKey(payment) + if gatewayKey == "" { + return "", merrors.InvalidArgument("crypto send: destination card gateway is required") + } + route, ok := lookupCardGatewayRoute(e.cardGatewayRoutes, gatewayKey) + if !ok { + return "", merrors.InvalidArgument("crypto send: card gateway route is not configured") + } + switch action { + case model.RailOperationFee: + if feeAddress := strings.TrimSpace(route.FeeAddress); feeAddress != "" { + return feeAddress, nil + } + } + address := strings.TrimSpace(route.FundingAddress) + if address == "" { + return "", merrors.InvalidArgument("crypto send: card gateway funding address is required") + } + return address, nil +} + +func destinationCardGatewayKey(payment *agg.Payment) string { + if payment == nil || payment.QuoteSnapshot == nil || payment.QuoteSnapshot.Route == nil { + return "" + } + hops := payment.QuoteSnapshot.Route.Hops + fallback := "" + for i := range hops { + hop := hops[i] + if hop == nil || model.ParseRail(hop.Rail) != model.RailCardPayout { + continue + } + key := firstNonEmpty(strings.TrimSpace(hop.Gateway), strings.TrimSpace(hop.InstanceID)) + if key == "" { + continue + } + if hop.Role != paymenttypes.QuoteRouteHopRoleDestination { + fallback = key + continue + } + return key + } + return fallback +} + +func lookupCardGatewayRoute(routes map[string]CardGatewayRoute, key string) (CardGatewayRoute, bool) { + if len(routes) == 0 { + return CardGatewayRoute{}, false + } + normalized := strings.TrimSpace(strings.ToLower(key)) + if normalized == "" { + return CardGatewayRoute{}, false + } + route, ok := routes[normalized] + return route, ok +} + +func transferExternalRefs(transfer *chainv1.Transfer, gatewayInstanceID string) ([]agg.ExternalRef, error) { + if transfer == nil { + return nil, merrors.InvalidArgument("crypto send: transfer is required") + } + refs := make([]agg.ExternalRef, 0, 2) + if operationRef := strings.TrimSpace(transfer.GetOperationRef()); operationRef != "" { + refs = append(refs, agg.ExternalRef{ + GatewayInstanceID: strings.TrimSpace(gatewayInstanceID), + Kind: erecon.ExternalRefKindOperation, + Ref: operationRef, + }) + } + if transferRef := strings.TrimSpace(transfer.GetTransferRef()); transferRef != "" { + refs = append(refs, agg.ExternalRef{ + GatewayInstanceID: strings.TrimSpace(gatewayInstanceID), + Kind: erecon.ExternalRefKindTransfer, + Ref: transferRef, + }) + } + if len(refs) == 0 { + return nil, merrors.Internal("crypto send: transfer response does not contain references") + } + return refs, nil +} + +func transferMetadata(step xplan.Step) map[string]string { + items := map[string]string{ + "step_ref": strings.TrimSpace(step.StepRef), + "step_code": strings.TrimSpace(step.StepCode), + "gateway": strings.TrimSpace(step.Gateway), + "rail": strings.TrimSpace(string(step.Rail)), + "action": strings.TrimSpace(string(step.Action)), + } + out := map[string]string{} + for key, value := range items { + if value == "" { + continue + } + out[key] = value + } + if len(out) == 0 { + return nil + } + return out +} + +func firstNonEmpty(values ...string) string { + for i := range values { + if cleaned := strings.TrimSpace(values[i]); cleaned != "" { + return cleaned + } + } + return "" +} + +var _ sexec.CryptoExecutor = (*gatewayCryptoExecutor)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go new file mode 100644 index 00000000..a3721439 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go @@ -0,0 +1,221 @@ +package orchestrator + +import ( + "context" + "testing" + + chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + pm "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsTransfer(t *testing.T) { + orgID := bson.NewObjectID() + + var submitReq *chainv1.SubmitTransferRequest + client := &chainclient.Fake{ + SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + submitReq = req + return &chainv1.SubmitTransferResponse{ + Transfer: &chainv1.Transfer{ + TransferRef: "trf-1", + OperationRef: "op-1", + }, + }, nil + }, + } + resolver := &fakeGatewayInvokeResolver{client: client} + registry := &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto_rail_gateway_arbitrum_sepolia", + InstanceID: "crypto_rail_gateway_arbitrum_sepolia", + Rail: model.RailCrypto, + InvokeURI: "grpc://crypto-gateway", + IsEnabled: true, + }, + }, + } + executor := &gatewayCryptoExecutor{ + gatewayInvokeResolver: resolver, + gatewayRegistry: registry, + cardGatewayRoutes: map[string]CardGatewayRoute{ + "monetix": {FundingAddress: "TUA_DEST"}, + }, + } + + req := sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-1", + IdempotencyKey: "idem-1", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-1", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{Pan: "4111111111111111"}, + }, + Amount: &paymenttypes.Money{Amount: "1", Currency: "USDT"}, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"}, + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 4, Rail: "CARD", Gateway: "monetix", InstanceID: "monetix", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + }, + Step: xplan.Step{ + StepRef: "hop_1_crypto_send", + StepCode: "hop.1.crypto.send", + Action: model.RailOperationSend, + Rail: model.RailCrypto, + Gateway: "crypto_rail_gateway_arbitrum_sepolia", + InstanceID: "crypto_rail_gateway_arbitrum_sepolia", + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_1_crypto_send", + StepCode: "hop.1.crypto.send", + Attempt: 1, + }, + } + + out, err := executor.ExecuteCrypto(context.Background(), req) + if err != nil { + t.Fatalf("ExecuteCrypto returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if out.StepExecution.State != agg.StepStateCompleted { + t.Fatalf("expected completed state, got=%q", out.StepExecution.State) + } + if submitReq == nil { + t.Fatal("expected transfer submission request") + } + if got, want := submitReq.GetSourceWalletRef(), "wallet-src"; got != want { + t.Fatalf("source wallet mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetAmount().GetAmount(), "1.000000"; got != want { + t.Fatalf("amount mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetDestination().GetExternalAddress(), "TUA_DEST"; got != want { + t.Fatalf("destination mismatch: got=%q want=%q", got, want) + } + if got, want := resolver.lastInvokeURI, "grpc://crypto-gateway"; got != want { + t.Fatalf("invoke uri mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 2 { + t.Fatalf("expected two external refs, got=%d", len(out.StepExecution.ExternalRefs)) + } + if out.StepExecution.ExternalRefs[0].Kind != erecon.ExternalRefKindOperation { + t.Fatalf("unexpected first external ref kind: %q", out.StepExecution.ExternalRefs[0].Kind) + } +} + +func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) { + orgID := bson.NewObjectID() + executor := &gatewayCryptoExecutor{ + gatewayInvokeResolver: &fakeGatewayInvokeResolver{ + client: &chainclient.Fake{}, + }, + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto_1", + InstanceID: "crypto_1", + Rail: model.RailCrypto, + InvokeURI: "grpc://crypto-gateway", + IsEnabled: true, + }, + }, + }, + cardGatewayRoutes: map[string]CardGatewayRoute{}, + } + + _, err := executor.ExecuteCrypto(context.Background(), sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-2", + IdempotencyKey: "idem-2", + IntentSnapshot: model.PaymentIntent{ + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{Pan: "4111111111111111"}, + }, + Amount: &paymenttypes.Money{Amount: "1", Currency: "USDT"}, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 1, Rail: "CRYPTO", Gateway: "crypto_1", InstanceID: "crypto_1", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 4, Rail: "CARD", Gateway: "monetix", InstanceID: "monetix", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + }, + Step: xplan.Step{ + StepRef: "hop_1_crypto_send", + StepCode: "hop.1.crypto.send", + Action: model.RailOperationSend, + Rail: model.RailCrypto, + Gateway: "crypto_1", + InstanceID: "crypto_1", + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_1_crypto_send", + StepCode: "hop.1.crypto.send", + Attempt: 1, + }, + }) + if err == nil { + t.Fatal("expected error for missing card route") + } +} + +type fakeGatewayInvokeResolver struct { + lastInvokeURI string + client chainclient.Client + err error +} + +func (f *fakeGatewayInvokeResolver) Resolve(_ context.Context, invokeURI string) (chainclient.Client, error) { + f.lastInvokeURI = invokeURI + if f.err != nil { + return nil, f.err + } + return f.client, nil +} + +type fakeGatewayRegistry struct { + items []*model.GatewayInstanceDescriptor + err error +} + +func (f *fakeGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if f.err != nil { + return nil, f.err + } + return f.items, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go new file mode 100644 index 00000000..053a3aae --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go @@ -0,0 +1,508 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + cons "github.com/tech/sendico/pkg/messaging/consumer" + paymentgatewaynotifications "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" + pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/payments/rail" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +const ( + observePollInterval = 5 * time.Second + observePollPageLimit = int32(100) + observePollMaxPerTick = 200 + observeStepCodeToken = ".observe" + + failureCodeGatewayExecutionFailed = "gateway.execution_failed" + failureCodeGatewayExecutionCancel = "gateway.execution_cancelled" + failureCodeGatewayTransferFailed = "gateway.transfer_failed" + failureCodeGatewayTransferCanceled = "gateway.transfer_cancelled" +) + +type runningObserveCandidate struct { + stepRef string + transferRef string + operationRef string + gatewayInstanceID string +} + +func (s *Service) startExternalRuntime() { + if s == nil || s.v2 == nil || s.paymentRepo == nil { + return + } + + runCtx, cancel := context.WithCancel(context.Background()) + started := false + + if s.paymentGatewayBroker != nil { + processor := paymentgatewaynotifications.NewPaymentGatewayExecutionProcessor(s.logger, s.onPaymentGatewayExecution) + consumer, err := cons.NewConsumer(s.logger, s.paymentGatewayBroker, processor.GetSubject()) + if err != nil { + s.logger.Warn("Failed to start payment gateway execution consumer", zap.Error(err)) + } else { + s.gatewayConsumers = append(s.gatewayConsumers, consumer) + go func() { + if err := consumer.ConsumeMessages(processor.Process); err != nil && !errors.Is(err, context.Canceled) { + s.logger.Warn("Payment gateway execution consumer stopped", zap.Error(err)) + } + }() + started = true + } + } + + if s.gatewayInvokeResolver != nil && s.gatewayRegistry != nil { + go s.observePollLoop(runCtx) + started = true + } else { + s.logger.Warn("Observe polling fallback disabled: gateway resolver or registry is missing") + } + + if started { + s.stopExternalWorkers = cancel + return + } + cancel() +} + +func (s *Service) onPaymentGatewayExecution(ctx context.Context, msg *pmodel.PaymentGatewayExecution) error { + if s == nil || s.v2 == nil || s.paymentRepo == nil || msg == nil { + return nil + } + + paymentRef := strings.TrimSpace(msg.PaymentRef) + if paymentRef == "" { + s.logger.Debug("Skipping payment gateway execution event without payment_ref") + return nil + } + + payment, err := s.paymentRepo.GetByPaymentRefGlobal(ctx, paymentRef) + if err != nil { + if errors.Is(err, prepo.ErrPaymentNotFound) { + s.logger.Debug("Skipping payment gateway execution event for unknown payment", + zap.String("payment_ref", paymentRef), + ) + return nil + } + return err + } + + event, ok := buildGatewayExecutionEvent(payment, msg) + if !ok { + s.logger.Debug("Skipping payment gateway execution event with unsupported status", + zap.String("payment_ref", paymentRef), + zap.String("status", strings.TrimSpace(string(msg.Status))), + ) + return nil + } + + s.logger.Debug("Reconciling payment from gateway execution event", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("organization_ref", payment.OrganizationRef.Hex()), + zap.String("step_ref", strings.TrimSpace(event.StepRef)), + zap.String("status", strings.TrimSpace(string(event.Status))), + zap.String("transfer_ref", strings.TrimSpace(event.TransferRef)), + zap.String("operation_ref", strings.TrimSpace(event.OperationRef)), + ) + + _, err = s.v2.ReconcileExternal(ctx, psvc.ReconcileExternalInput{ + OrganizationRef: payment.OrganizationRef.Hex(), + PaymentRef: payment.PaymentRef, + Event: erecon.Event{Gateway: event}, + }) + return err +} + +func buildGatewayExecutionEvent(payment *agg.Payment, msg *pmodel.PaymentGatewayExecution) (*erecon.GatewayEvent, bool) { + if payment == nil || msg == nil { + return nil, false + } + + status, ok := mapGatewayExecutionStatus(msg.Status) + if !ok { + return nil, false + } + + stepRef, gatewayInstanceID := matchExecutionStep(payment, msg) + operationRef := strings.TrimSpace(msg.OperationRef) + transferRef := strings.TrimSpace(msg.TransferRef) + if stepRef == "" && operationRef == "" && transferRef == "" { + return nil, false + } + + event := &erecon.GatewayEvent{ + StepRef: stepRef, + OperationRef: operationRef, + TransferRef: transferRef, + GatewayInstanceID: gatewayInstanceID, + Status: status, + } + + switch status { + case erecon.GatewayStatusFailed: + retryable := false + event.Retryable = &retryable + event.FailureCode = failureCodeGatewayExecutionFailed + event.FailureMsg = strings.TrimSpace(msg.Error) + case erecon.GatewayStatusCancelled: + retryable := false + event.Retryable = &retryable + event.FailureCode = failureCodeGatewayExecutionCancel + event.FailureMsg = strings.TrimSpace(msg.Error) + default: + event.FailureCode = "" + event.FailureMsg = "" + } + + return event, true +} + +func mapGatewayExecutionStatus(status rail.OperationResult) (erecon.GatewayStatus, bool) { + switch strings.ToLower(strings.TrimSpace(string(status))) { + case string(rail.OperationResultSuccess): + return erecon.GatewayStatusSuccess, true + case string(rail.OperationResultFailed): + return erecon.GatewayStatusFailed, true + case string(rail.OperationResultCancelled): + return erecon.GatewayStatusCancelled, true + default: + return erecon.GatewayStatusUnspecified, false + } +} + +func matchExecutionStep(payment *agg.Payment, msg *pmodel.PaymentGatewayExecution) (stepRef string, gatewayInstanceID string) { + if payment == nil || msg == nil { + return "", "" + } + + transferRef := strings.TrimSpace(msg.TransferRef) + if transferRef != "" { + if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindTransfer, transferRef); ok { + return stepRef, gatewayInstanceID + } + } + + operationRef := strings.TrimSpace(msg.OperationRef) + if operationRef != "" { + if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindOperation, operationRef); ok { + return stepRef, gatewayInstanceID + } + } + + candidates := runningObserveCandidates(payment) + if len(candidates) == 1 { + return candidates[0].stepRef, candidates[0].gatewayInstanceID + } + return "", "" +} + +func findStepByExternalRef(payment *agg.Payment, kind, ref string) (stepRef string, gatewayInstanceID string, ok bool) { + if payment == nil { + return "", "", false + } + kind = strings.TrimSpace(kind) + ref = strings.TrimSpace(ref) + if kind == "" || ref == "" { + return "", "", false + } + + type match struct { + stepRef string + state agg.StepState + gatewayInstanceID string + } + var matches []match + for i := range payment.StepExecutions { + step := payment.StepExecutions[i] + for j := range step.ExternalRefs { + externalRef := step.ExternalRefs[j] + if !strings.EqualFold(strings.TrimSpace(externalRef.Kind), kind) { + continue + } + if !strings.EqualFold(strings.TrimSpace(externalRef.Ref), ref) { + continue + } + matches = append(matches, match{ + stepRef: strings.TrimSpace(step.StepRef), + state: step.State, + gatewayInstanceID: strings.TrimSpace(externalRef.GatewayInstanceID), + }) + break + } + } + + if len(matches) == 0 { + return "", "", false + } + for i := range matches { + if matches[i].state == agg.StepStateRunning { + return matches[i].stepRef, matches[i].gatewayInstanceID, true + } + } + return matches[0].stepRef, matches[0].gatewayInstanceID, true +} + +func (s *Service) observePollLoop(ctx context.Context) { + ticker := time.NewTicker(observePollInterval) + defer ticker.Stop() + + s.logger.Info("Started observe polling fallback", + zap.Duration("interval", observePollInterval), + zap.Int32("page_limit", observePollPageLimit), + zap.Int("max_per_tick", observePollMaxPerTick), + ) + defer s.logger.Info("Stopped observe polling fallback") + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.pollObserveCandidates(ctx); err != nil && !errors.Is(err, context.Canceled) { + s.logger.Warn("Observe polling fallback tick failed", zap.Error(err)) + } + } + } +} + +func (s *Service) pollObserveCandidates(ctx context.Context) error { + if s == nil || s.paymentRepo == nil { + return nil + } + + cursor := (*prepo.ListCursor)(nil) + processed := 0 + for processed < observePollMaxPerTick { + page, err := s.paymentRepo.ListByStateGlobal(ctx, prepo.ListByStateGlobalInput{ + State: agg.StateExecuting, + Limit: observePollPageLimit, + Cursor: cursor, + }) + if err != nil { + return err + } + if page == nil || len(page.Items) == 0 { + return nil + } + + for i := range page.Items { + payment := page.Items[i] + candidates := runningObserveCandidates(payment) + for j := range candidates { + if processed >= observePollMaxPerTick { + return nil + } + processed++ + s.pollObserveCandidate(ctx, payment, candidates[j]) + } + } + + if page.NextCursor == nil { + return nil + } + cursor = page.NextCursor + } + return nil +} + +func runningObserveCandidates(payment *agg.Payment) []runningObserveCandidate { + if payment == nil || len(payment.StepExecutions) == 0 { + return nil + } + out := make([]runningObserveCandidate, 0, len(payment.StepExecutions)) + for i := range payment.StepExecutions { + step := payment.StepExecutions[i] + if step.State != agg.StepStateRunning { + continue + } + if !isObserveStepCode(step.StepCode) { + continue + } + candidate, ok := buildObserveCandidate(step) + if !ok { + continue + } + out = append(out, candidate) + } + return out +} + +func isObserveStepCode(stepCode string) bool { + code := strings.ToLower(strings.TrimSpace(stepCode)) + return strings.Contains(code, observeStepCodeToken) +} + +func buildObserveCandidate(step agg.StepExecution) (runningObserveCandidate, bool) { + candidate := runningObserveCandidate{ + stepRef: strings.TrimSpace(step.StepRef), + } + for i := range step.ExternalRefs { + ref := step.ExternalRefs[i] + kind := strings.TrimSpace(ref.Kind) + value := strings.TrimSpace(ref.Ref) + if kind == "" || value == "" { + continue + } + switch { + case strings.EqualFold(kind, erecon.ExternalRefKindTransfer): + if candidate.transferRef == "" { + candidate.transferRef = value + candidate.gatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID) + } + case strings.EqualFold(kind, erecon.ExternalRefKindOperation): + if candidate.operationRef == "" { + candidate.operationRef = value + } + } + } + if candidate.stepRef == "" || candidate.transferRef == "" { + return runningObserveCandidate{}, false + } + return candidate, true +} + +func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment, candidate runningObserveCandidate) { + if s == nil || payment == nil || s.v2 == nil { + return + } + + gateway, err := s.resolveObserveGateway(ctx, payment, candidate) + if err != nil { + s.logger.Debug("Observe polling skipped: gateway resolution failed", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", candidate.stepRef), + zap.String("transfer_ref", candidate.transferRef), + zap.Error(err), + ) + return + } + + client, err := s.gatewayInvokeResolver.Resolve(ctx, strings.TrimSpace(gateway.InvokeURI)) + if err != nil { + s.logger.Warn("Observe polling failed to resolve gateway client", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", candidate.stepRef), + zap.String("gateway_instance_id", strings.TrimSpace(gateway.InstanceID)), + zap.Error(err), + ) + return + } + + transferResp, err := client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: candidate.transferRef}) + if err != nil { + s.logger.Warn("Observe polling transfer status call failed", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", candidate.stepRef), + zap.String("transfer_ref", candidate.transferRef), + zap.String("gateway_instance_id", strings.TrimSpace(gateway.InstanceID)), + zap.Error(err), + ) + return + } + transfer := transferResp.GetTransfer() + if transfer == nil { + s.logger.Warn("Observe polling transfer status response is empty", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", candidate.stepRef), + zap.String("transfer_ref", candidate.transferRef), + ) + return + } + + status, terminal, ok := mapTransferStatus(transfer.GetStatus()) + if !ok || !terminal { + return + } + + event := erecon.GatewayEvent{ + StepRef: candidate.stepRef, + OperationRef: firstNonEmpty(strings.TrimSpace(transfer.GetOperationRef()), candidate.operationRef), + TransferRef: strings.TrimSpace(candidate.transferRef), + GatewayInstanceID: firstNonEmpty(candidate.gatewayInstanceID, strings.TrimSpace(gateway.InstanceID), strings.TrimSpace(gateway.ID)), + Status: status, + } + switch status { + case erecon.GatewayStatusFailed: + retryable := false + event.Retryable = &retryable + event.FailureCode = failureCodeGatewayTransferFailed + event.FailureMsg = strings.TrimSpace(transfer.GetFailureReason()) + case erecon.GatewayStatusCancelled: + retryable := false + event.Retryable = &retryable + event.FailureCode = failureCodeGatewayTransferCanceled + event.FailureMsg = strings.TrimSpace(transfer.GetFailureReason()) + } + + s.logger.Debug("Reconciling payment from observe polling result", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("organization_ref", payment.OrganizationRef.Hex()), + zap.String("step_ref", candidate.stepRef), + zap.String("status", strings.TrimSpace(string(event.Status))), + zap.String("transfer_ref", candidate.transferRef), + zap.String("operation_ref", strings.TrimSpace(event.OperationRef)), + zap.String("gateway_instance_id", strings.TrimSpace(event.GatewayInstanceID)), + ) + + _, err = s.v2.ReconcileExternal(ctx, psvc.ReconcileExternalInput{ + OrganizationRef: payment.OrganizationRef.Hex(), + PaymentRef: strings.TrimSpace(payment.PaymentRef), + Event: erecon.Event{Gateway: &event}, + }) + if err != nil { + s.logger.Warn("Observe polling reconciliation failed", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", candidate.stepRef), + zap.String("transfer_ref", candidate.transferRef), + zap.Error(err), + ) + } +} + +func (s *Service) resolveObserveGateway(ctx context.Context, payment *agg.Payment, candidate runningObserveCandidate) (*model.GatewayInstanceDescriptor, error) { + executor := gatewayCryptoExecutor{ + gatewayRegistry: s.gatewayRegistry, + } + step := xplan.Step{ + Rail: model.RailCrypto, + } + if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" { + step.InstanceID = gatewayID + step.Gateway = gatewayID + } else if gateway, instanceID, ok := sourceCryptoHop(payment); ok { + step.Gateway = strings.TrimSpace(gateway) + step.InstanceID = strings.TrimSpace(instanceID) + } + return executor.resolveGateway(ctx, step) +} + +func mapTransferStatus(status chainv1.TransferStatus) (gatewayStatus erecon.GatewayStatus, terminal bool, ok bool) { + switch status { + case chainv1.TransferStatus_TRANSFER_CREATED: + return erecon.GatewayStatusCreated, false, true + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return erecon.GatewayStatusProcessing, false, true + case chainv1.TransferStatus_TRANSFER_WAITING: + return erecon.GatewayStatusWaiting, false, true + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return erecon.GatewayStatusSuccess, true, true + case chainv1.TransferStatus_TRANSFER_FAILED: + return erecon.GatewayStatusFailed, true, true + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return erecon.GatewayStatusCancelled, true, true + default: + return erecon.GatewayStatusUnspecified, false, false + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go new file mode 100644 index 00000000..3edb11f1 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go @@ -0,0 +1,275 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + pm "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/payments/rail" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func TestBuildGatewayExecutionEvent_MapsStatusAndMatchedStep(t *testing.T) { + orgID := bson.NewObjectID() + payment := &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-1", + StepExecutions: []agg.StepExecution{ + { + StepRef: "hop_1_crypto_observe", + StepCode: "hop.1.crypto.observe", + State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{ + { + GatewayInstanceID: "crypto_rail_gateway_tron_nile", + Kind: erecon.ExternalRefKindTransfer, + Ref: "trf-1", + }, + }, + }, + }, + } + + event, ok := buildGatewayExecutionEvent(payment, &pm.PaymentGatewayExecution{ + PaymentRef: payment.PaymentRef, + Status: rail.OperationResultSuccess, + TransferRef: "trf-1", + }) + if !ok { + t.Fatal("expected gateway execution event to be accepted") + } + if got, want := event.StepRef, "hop_1_crypto_observe"; got != want { + t.Fatalf("step_ref mismatch: got=%q want=%q", got, want) + } + if got, want := event.GatewayInstanceID, "crypto_rail_gateway_tron_nile"; got != want { + t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want) + } + if got, want := event.Status, erecon.GatewayStatusSuccess; got != want { + t.Fatalf("status mismatch: got=%q want=%q", got, want) + } +} + +func TestBuildGatewayExecutionEvent_FailedSetsTerminalNeedsAttentionHint(t *testing.T) { + orgID := bson.NewObjectID() + payment := &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-2", + StepExecutions: []agg.StepExecution{ + { + StepRef: "hop_1_crypto_observe", + StepCode: "hop.1.crypto.observe", + State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{ + {Kind: erecon.ExternalRefKindTransfer, Ref: "trf-2"}, + }, + }, + }, + } + + event, ok := buildGatewayExecutionEvent(payment, &pm.PaymentGatewayExecution{ + PaymentRef: payment.PaymentRef, + Status: rail.OperationResultFailed, + TransferRef: "trf-2", + Error: "insufficient funds", + }) + if !ok { + t.Fatal("expected failed gateway execution event to be accepted") + } + if event.Retryable == nil || *event.Retryable { + t.Fatal("expected retryable=false for failed gateway execution") + } + if got, want := event.FailureCode, "gateway.execution_failed"; got != want { + t.Fatalf("failure_code mismatch: got=%q want=%q", got, want) + } + if got, want := event.FailureMsg, "insufficient funds"; got != want { + t.Fatalf("failure_msg mismatch: got=%q want=%q", got, want) + } +} + +func TestOnPaymentGatewayExecution_ReconcilesUsingGlobalPaymentLookup(t *testing.T) { + orgID := bson.NewObjectID() + payment := &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-3", + StepExecutions: []agg.StepExecution{ + { + StepRef: "hop_1_crypto_observe", + StepCode: "hop.1.crypto.observe", + State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{ + {Kind: erecon.ExternalRefKindTransfer, Ref: "trf-3"}, + }, + }, + }, + } + + repo := &fakeExternalRuntimeRepo{payment: payment} + v2 := &fakeExternalRuntimeV2{} + svc := &Service{ + v2: v2, + paymentRepo: repo, + logger: zap.NewNop(), + } + + err := svc.onPaymentGatewayExecution(context.Background(), &pm.PaymentGatewayExecution{ + PaymentRef: payment.PaymentRef, + Status: rail.OperationResultSuccess, + TransferRef: "trf-3", + }) + if err != nil { + t.Fatalf("onPaymentGatewayExecution returned error: %v", err) + } + if v2.reconcileInput == nil { + t.Fatal("expected reconcile call") + } + if got, want := v2.reconcileInput.OrganizationRef, orgID.Hex(); got != want { + t.Fatalf("organization_ref mismatch: got=%q want=%q", got, want) + } + if got, want := v2.reconcileInput.PaymentRef, payment.PaymentRef; got != want { + t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want) + } + if v2.reconcileInput.Event.Gateway == nil || v2.reconcileInput.Event.Gateway.Status != erecon.GatewayStatusSuccess { + t.Fatal("expected success gateway reconcile event") + } +} + +type fakeExternalRuntimeRepo struct { + payment *agg.Payment + err error +} + +func (f *fakeExternalRuntimeRepo) Create(context.Context, *agg.Payment) error { return nil } + +func (f *fakeExternalRuntimeRepo) UpdateCAS(context.Context, *agg.Payment, uint64) error { return nil } + +func (f *fakeExternalRuntimeRepo) GetByPaymentRef(_ context.Context, _ bson.ObjectID, _ string) (*agg.Payment, error) { + return nil, prepo.ErrPaymentNotFound +} + +func (f *fakeExternalRuntimeRepo) GetByPaymentRefGlobal(_ context.Context, paymentRef string) (*agg.Payment, error) { + if f.err != nil { + return nil, f.err + } + if f.payment == nil || f.payment.PaymentRef != paymentRef { + return nil, prepo.ErrPaymentNotFound + } + return f.payment, nil +} + +func (f *fakeExternalRuntimeRepo) GetByIdempotencyKey(context.Context, bson.ObjectID, string) (*agg.Payment, error) { + return nil, prepo.ErrPaymentNotFound +} + +func (f *fakeExternalRuntimeRepo) ListByQuotationRef(context.Context, prepo.ListByQuotationRefInput) (*prepo.ListOutput, error) { + return &prepo.ListOutput{}, nil +} + +func (f *fakeExternalRuntimeRepo) ListByState(context.Context, prepo.ListByStateInput) (*prepo.ListOutput, error) { + return &prepo.ListOutput{}, nil +} + +func (f *fakeExternalRuntimeRepo) ListByStateGlobal(context.Context, prepo.ListByStateGlobalInput) (*prepo.ListOutput, error) { + return &prepo.ListOutput{}, nil +} + +type fakeExternalRuntimeV2 struct { + reconcileInput *psvc.ReconcileExternalInput +} + +func (f *fakeExternalRuntimeV2) ExecutePayment(context.Context, *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeExternalRuntimeV2) GetPayment(context.Context, *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeExternalRuntimeV2) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeExternalRuntimeV2) ReconcileExternal(_ context.Context, in psvc.ReconcileExternalInput) (*psvc.ReconcileExternalOutput, error) { + cloned := in + f.reconcileInput = &cloned + return &psvc.ReconcileExternalOutput{ + Payment: &orchestrationv2.Payment{ + PaymentRef: in.PaymentRef, + State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, + Version: 1, + }, + }, nil +} + +func TestMapTransferStatus(t *testing.T) { + cases := []struct { + status chainv1.TransferStatus + wantStatus erecon.GatewayStatus + wantTerminal bool + wantSupported bool + }{ + {status: chainv1.TransferStatus_TRANSFER_CREATED, wantStatus: erecon.GatewayStatusCreated, wantTerminal: false, wantSupported: true}, + {status: chainv1.TransferStatus_TRANSFER_PROCESSING, wantStatus: erecon.GatewayStatusProcessing, wantTerminal: false, wantSupported: true}, + {status: chainv1.TransferStatus_TRANSFER_WAITING, wantStatus: erecon.GatewayStatusWaiting, wantTerminal: false, wantSupported: true}, + {status: chainv1.TransferStatus_TRANSFER_SUCCESS, wantStatus: erecon.GatewayStatusSuccess, wantTerminal: true, wantSupported: true}, + {status: chainv1.TransferStatus_TRANSFER_FAILED, wantStatus: erecon.GatewayStatusFailed, wantTerminal: true, wantSupported: true}, + {status: chainv1.TransferStatus_TRANSFER_CANCELLED, wantStatus: erecon.GatewayStatusCancelled, wantTerminal: true, wantSupported: true}, + } + + for _, tc := range cases { + gotStatus, gotTerminal, gotSupported := mapTransferStatus(tc.status) + if gotStatus != tc.wantStatus || gotTerminal != tc.wantTerminal || gotSupported != tc.wantSupported { + t.Fatalf("status mapping mismatch: status=%v got=(%q,%v,%v) want=(%q,%v,%v)", + tc.status, gotStatus, gotTerminal, gotSupported, tc.wantStatus, tc.wantTerminal, tc.wantSupported) + } + } +} + +func TestRunningObserveCandidates(t *testing.T) { + payment := &agg.Payment{ + StepExecutions: []agg.StepExecution{ + { + StepRef: "hop_1_crypto_observe", + StepCode: "hop.1.crypto.observe", + State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{ + {Kind: erecon.ExternalRefKindTransfer, Ref: "trf-running"}, + }, + }, + { + StepRef: "hop_2_crypto_observe", + StepCode: "hop.2.crypto.observe", + State: agg.StepStateCompleted, + ExternalRefs: []agg.ExternalRef{ + {Kind: erecon.ExternalRefKindTransfer, Ref: "trf-completed"}, + }, + }, + { + StepRef: "hop_3_crypto_observe", + StepCode: "hop.3.crypto.observe", + State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{ + {Kind: erecon.ExternalRefKindOperation, Ref: "op-only"}, + }, + }, + }, + } + + candidates := runningObserveCandidates(payment) + if len(candidates) != 1 { + t.Fatalf("candidate count mismatch: got=%d want=1", len(candidates)) + } + if got, want := candidates[0].transferRef, "trf-running"; got != want { + t.Fatalf("transfer_ref mismatch: got=%q want=%q", got, want) + } +} + +var _ prepo.Repository = (*fakeExternalRuntimeRepo)(nil) +var _ psvc.Service = (*fakeExternalRuntimeV2)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/guard_executor.go b/api/payments/orchestrator/internal/service/orchestrator/guard_executor.go new file mode 100644 index 00000000..e8dbda4e --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/guard_executor.go @@ -0,0 +1,285 @@ +package orchestrator + +import ( + "context" + "fmt" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "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" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type gatewayGuardExecutor struct { + logger mlogger.Logger + gatewayInvokeResolver GatewayInvokeResolver + gatewayRegistry GatewayRegistry +} + +func (e *gatewayGuardExecutor) ExecuteGuard(ctx context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + conditions := quoteExecutionConditionsForGuard(req.Payment) + switch xplan.GuardStepKind(req.Step) { + case xplan.StepKindLiquidityCheck: + base := executeLiquidityGuardReadiness(req.StepExecution, conditions) + if base.StepExecution.State != agg.StepStateCompleted { + return base, nil + } + failCode, failMsg := e.probeLiquidity(ctx, req) + if failCode != "" { + if e.logger != nil { + e.logger.Warn("Liquidity preflight probe failed", + zap.String("payment_ref", paymentRef(req.Payment)), + zap.String("step_ref", strings.TrimSpace(req.Step.StepRef)), + zap.String("failure_code", failCode), + zap.String("failure_message", failMsg), + ) + } + return failedOutputForGuard(req.StepExecution, failCode, failMsg), nil + } + return completedOutputForGuard(req.StepExecution), nil + case xplan.StepKindPrefunding: + return executePrefundingGuardReadiness(req.StepExecution, conditions), nil + default: + return failedOutputForGuard( + req.StepExecution, + "guard.unsupported_step", + "unsupported guard step: step_code="+strings.TrimSpace(req.Step.StepCode), + ), nil + } +} + +func (e *gatewayGuardExecutor) probeLiquidity(ctx context.Context, req sexec.StepRequest) (string, string) { + payment := req.Payment + if payment == nil { + return "guard.payment_missing", "liquidity probe requires payment context" + } + + hopGateway, hopInstanceID, hasCryptoSource := sourceCryptoHop(payment) + if !hasCryptoSource { + if e.logger != nil { + e.logger.Debug("Liquidity preflight probe skipped for non-crypto source", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(req.Step.StepRef)), + ) + } + return "", "" + } + + if e.gatewayInvokeResolver == nil || e.gatewayRegistry == nil { + return "guard.liquidity_probe_unavailable", "liquidity probe dependencies are not configured" + } + + walletRef, err := sourceManagedWalletRef(payment) + if err != nil { + return "guard.liquidity_probe_unsupported_source", err.Error() + } + + requiredAmount, requiredCurrency, err := requiredLiquidity(payment) + if err != nil { + return "guard.liquidity_probe_error", err.Error() + } + + resolver := gatewayCryptoExecutor{ + gatewayRegistry: e.gatewayRegistry, + } + gateway, err := resolver.resolveGateway(ctx, xplan.Step{ + Gateway: hopGateway, + InstanceID: hopInstanceID, + Rail: model.RailCrypto, + }) + if err != nil { + return "guard.liquidity_probe_error", err.Error() + } + + client, err := e.gatewayInvokeResolver.Resolve(ctx, strings.TrimSpace(gateway.InvokeURI)) + if err != nil { + return "guard.liquidity_probe_error", err.Error() + } + + balanceResp, err := client.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: walletRef}) + if err != nil { + return "guard.liquidity_probe_error", err.Error() + } + if balanceResp == nil || balanceResp.GetBalance() == nil || balanceResp.GetBalance().GetAvailable() == nil { + return "guard.liquidity_probe_error", "wallet balance is missing from gateway response" + } + + available := balanceResp.GetBalance().GetAvailable() + availableCurrency := strings.ToUpper(strings.TrimSpace(available.GetCurrency())) + availableAmount := strings.TrimSpace(available.GetAmount()) + if availableCurrency == "" || availableAmount == "" { + return "guard.liquidity_probe_error", "wallet available balance is incomplete" + } + if !strings.EqualFold(availableCurrency, requiredCurrency) { + return "guard.liquidity_probe_currency_mismatch", fmt.Sprintf( + "wallet balance currency mismatch: available=%s required=%s", + availableCurrency, + requiredCurrency, + ) + } + + availableDec, err := decimal.NewFromString(availableAmount) + if err != nil { + return "guard.liquidity_probe_error", "wallet available amount is invalid" + } + requiredDec, err := decimal.NewFromString(requiredAmount) + if err != nil { + return "guard.liquidity_probe_error", "required liquidity amount is invalid" + } + if availableDec.Cmp(requiredDec) < 0 { + return "guard.liquidity_insufficient", fmt.Sprintf( + "insufficient liquidity: available=%s required=%s currency=%s wallet_ref=%s", + availableAmount, + requiredAmount, + requiredCurrency, + walletRef, + ) + } + + if e.logger != nil { + e.logger.Info("Liquidity preflight probe passed", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(req.Step.StepRef)), + zap.String("wallet_ref", walletRef), + zap.String("currency", requiredCurrency), + zap.String("required_amount", requiredAmount), + zap.String("available_amount", availableAmount), + zap.String("gateway_id", strings.TrimSpace(gateway.ID)), + zap.String("gateway_instance_id", strings.TrimSpace(gateway.InstanceID)), + ) + } + return "", "" +} + +func sourceCryptoHop(payment *agg.Payment) (gateway string, instanceID string, ok bool) { + if payment == nil || payment.QuoteSnapshot == nil || payment.QuoteSnapshot.Route == nil { + return "", "", false + } + hops := payment.QuoteSnapshot.Route.Hops + fallbackGateway := "" + fallbackInstance := "" + for i := range hops { + hop := hops[i] + if hop == nil || model.ParseRail(hop.Rail) != model.RailCrypto { + continue + } + gw := strings.TrimSpace(hop.Gateway) + inst := strings.TrimSpace(hop.InstanceID) + if gw == "" && inst == "" { + continue + } + if fallbackGateway == "" && fallbackInstance == "" { + fallbackGateway = gw + fallbackInstance = inst + } + if hop.Role == paymenttypes.QuoteRouteHopRoleSource { + return gw, inst, true + } + } + if fallbackGateway != "" || fallbackInstance != "" { + return fallbackGateway, fallbackInstance, true + } + return "", "", false +} + +func requiredLiquidity(payment *agg.Payment) (amount string, currency string, err error) { + if payment == nil { + return "", "", merrors.InvalidArgument("payment is required") + } + base := effectiveSourceAmount(payment) + if base == nil { + return "", "", merrors.InvalidArgument("source amount is required") + } + + amount = strings.TrimSpace(base.Amount) + currency = strings.ToUpper(strings.TrimSpace(base.Currency)) + if amount == "" || currency == "" { + return "", "", merrors.InvalidArgument("source amount is invalid") + } + + // If total cost is same-currency and greater, probe against total liquidity required. + if payment.QuoteSnapshot != nil && payment.QuoteSnapshot.TotalCost != nil { + total := payment.QuoteSnapshot.TotalCost + totalAmount := strings.TrimSpace(total.Amount) + totalCurrency := strings.ToUpper(strings.TrimSpace(total.Currency)) + if totalAmount != "" && strings.EqualFold(totalCurrency, currency) { + totalDec, totalErr := decimal.NewFromString(totalAmount) + baseDec, baseErr := decimal.NewFromString(amount) + if totalErr == nil && baseErr == nil && totalDec.Cmp(baseDec) > 0 { + amount = totalAmount + } + } + } + + return amount, currency, nil +} + +func quoteExecutionConditionsForGuard(payment *agg.Payment) *paymenttypes.QuoteExecutionConditions { + if payment == nil || payment.QuoteSnapshot == nil { + return nil + } + return payment.QuoteSnapshot.ExecutionConditions +} + +func executeLiquidityGuardReadiness( + step agg.StepExecution, + conditions *paymenttypes.QuoteExecutionConditions, +) *sexec.ExecuteOutput { + if conditions == nil { + return failedOutputForGuard(step, "guard.conditions_missing", "liquidity guard requires execution conditions") + } + + switch conditions.Readiness { + case paymenttypes.QuoteExecutionReadinessIndicative: + return failedOutputForGuard(step, "guard.indicative_quote", "liquidity guard cannot execute indicative quotes") + case paymenttypes.QuoteExecutionReadinessLiquidityObtainable: + return failedOutputForGuard(step, "guard.liquidity_not_ready", "liquidity is not yet available at execution time") + case paymenttypes.QuoteExecutionReadinessUnspecified: + return failedOutputForGuard(step, "guard.readiness_unspecified", "liquidity guard requires explicit readiness") + default: + return completedOutputForGuard(step) + } +} + +func executePrefundingGuardReadiness( + step agg.StepExecution, + conditions *paymenttypes.QuoteExecutionConditions, +) *sexec.ExecuteOutput { + if conditions == nil { + return failedOutputForGuard(step, "guard.conditions_missing", "prefunding guard requires execution conditions") + } + if conditions.Readiness == paymenttypes.QuoteExecutionReadinessIndicative { + return failedOutputForGuard(step, "guard.indicative_quote", "prefunding guard cannot execute indicative quotes") + } + return completedOutputForGuard(step) +} + +func completedOutputForGuard(step agg.StepExecution) *sexec.ExecuteOutput { + step.State = agg.StepStateCompleted + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{StepExecution: step} +} + +func failedOutputForGuard(step agg.StepExecution, code, msg string) *sexec.ExecuteOutput { + step.State = agg.StepStateFailed + step.FailureCode = strings.TrimSpace(code) + step.FailureMsg = strings.TrimSpace(msg) + return &sexec.ExecuteOutput{StepExecution: step} +} + +func paymentRef(payment *agg.Payment) string { + if payment == nil { + return "" + } + return strings.TrimSpace(payment.PaymentRef) +} + +var _ sexec.GuardExecutor = (*gatewayGuardExecutor)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go new file mode 100644 index 00000000..35c2d280 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go @@ -0,0 +1,239 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + pm "github.com/tech/sendico/pkg/model" + 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" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestGatewayGuardExecutor_ExecuteGuard_LiquidityProbePasses(t *testing.T) { + orgID := bson.NewObjectID() + var walletReq *chainv1.GetWalletBalanceRequest + executor := &gatewayGuardExecutor{ + gatewayInvokeResolver: &fakeGatewayInvokeResolver{ + client: &chainclient.Fake{ + GetWalletBalanceFn: func(_ context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { + walletReq = req + return &chainv1.GetWalletBalanceResponse{ + Balance: &chainv1.WalletBalance{ + Available: &moneyv1.Money{Amount: "5", Currency: "USDT"}, + }, + }, nil + }, + }, + }, + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto_1", + InstanceID: "crypto_1", + Rail: model.RailCrypto, + InvokeURI: "grpc://crypto-gateway", + IsEnabled: true, + }, + }, + }, + } + + out, err := executor.ExecuteGuard(context.Background(), sexec.StepRequest{ + Payment: testLiquidityProbePayment(orgID, "wallet-src", "1.00", "USDT", paymenttypes.QuoteExecutionReadinessLiquidityReady), + Step: xplan.Step{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Kind: xplan.StepKindLiquidityCheck, + }, + StepExecution: agg.StepExecution{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteGuard returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if walletReq == nil { + t.Fatal("expected wallet balance request") + } + if got, want := walletReq.GetWalletRef(), "wallet-src"; got != want { + t.Fatalf("wallet_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestGatewayGuardExecutor_ExecuteGuard_InsufficientLiquidity(t *testing.T) { + orgID := bson.NewObjectID() + executor := &gatewayGuardExecutor{ + gatewayInvokeResolver: &fakeGatewayInvokeResolver{ + client: &chainclient.Fake{ + GetWalletBalanceFn: func(_ context.Context, _ *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { + return &chainv1.GetWalletBalanceResponse{ + Balance: &chainv1.WalletBalance{ + Available: &moneyv1.Money{Amount: "0.5", Currency: "USDT"}, + }, + }, nil + }, + }, + }, + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto_1", + InstanceID: "crypto_1", + Rail: model.RailCrypto, + InvokeURI: "grpc://crypto-gateway", + IsEnabled: true, + }, + }, + }, + } + + out, err := executor.ExecuteGuard(context.Background(), sexec.StepRequest{ + Payment: testLiquidityProbePayment(orgID, "wallet-src", "1.00", "USDT", paymenttypes.QuoteExecutionReadinessLiquidityReady), + Step: xplan.Step{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Kind: xplan.StepKindLiquidityCheck, + }, + StepExecution: agg.StepExecution{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteGuard returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := out.StepExecution.State, agg.StepStateFailed; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if got, want := out.StepExecution.FailureCode, "guard.liquidity_insufficient"; got != want { + t.Fatalf("failure code mismatch: got=%q want=%q", got, want) + } + if !strings.Contains(out.StepExecution.FailureMsg, "available=0.5") { + t.Fatalf("expected failure message to include available balance, got=%q", out.StepExecution.FailureMsg) + } +} + +func TestGatewayGuardExecutor_ExecuteGuard_ReadinessStopsBeforeProbe(t *testing.T) { + orgID := bson.NewObjectID() + probeCalls := 0 + executor := &gatewayGuardExecutor{ + gatewayInvokeResolver: &fakeGatewayInvokeResolver{ + client: &chainclient.Fake{ + GetWalletBalanceFn: func(_ context.Context, _ *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { + probeCalls++ + return &chainv1.GetWalletBalanceResponse{}, nil + }, + }, + }, + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto_1", + InstanceID: "crypto_1", + Rail: model.RailCrypto, + InvokeURI: "grpc://crypto-gateway", + IsEnabled: true, + }, + }, + }, + } + + out, err := executor.ExecuteGuard(context.Background(), sexec.StepRequest{ + Payment: testLiquidityProbePayment(orgID, "wallet-src", "1.00", "USDT", paymenttypes.QuoteExecutionReadinessLiquidityObtainable), + Step: xplan.Step{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Kind: xplan.StepKindLiquidityCheck, + }, + StepExecution: agg.StepExecution{ + StepRef: xplan.QuoteReadinessGuardStepRef, + StepCode: string(xplan.GuardOperationQuoteReadinessGuard), + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteGuard returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := out.StepExecution.FailureCode, "guard.liquidity_not_ready"; got != want { + t.Fatalf("failure code mismatch: got=%q want=%q", got, want) + } + if probeCalls != 0 { + t.Fatalf("expected no probe calls when readiness is not ready, got=%d", probeCalls) + } +} + +func testLiquidityProbePayment( + orgID bson.ObjectID, + walletRef string, + amount string, + currency string, + readiness paymenttypes.QuoteExecutionReadiness, +) *agg.Payment { + return &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-guard", + IntentSnapshot: model.PaymentIntent{ + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: walletRef, + }, + }, + Amount: &paymenttypes.Money{ + Amount: amount, + Currency: currency, + }, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{ + Amount: amount, + Currency: currency, + }, + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: readiness, + LiquidityCheckRequiredAtExecution: true, + }, + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + { + Index: 1, + Rail: "CRYPTO", + Gateway: "crypto_1", + InstanceID: "crypto_1", + Role: paymenttypes.QuoteRouteHopRoleSource, + }, + { + Index: 2, + Rail: "CARD", + Gateway: "monetix", + InstanceID: "monetix", + Role: paymenttypes.QuoteRouteHopRoleDestination, + }, + }, + }, + }, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index 1b3591e2..d2eed75d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -54,9 +54,14 @@ func WithMntxGateway(_ mntxclient.Client) Option { return func(*Service) {} } -// WithPaymentGatewayBroker is retained for backward-compatible wiring and is currently a no-op. -func WithPaymentGatewayBroker(_ mb.Broker) Option { - return func(*Service) {} +// WithPaymentGatewayBroker wires broker subscription for payment gateway execution events. +func WithPaymentGatewayBroker(broker mb.Broker) Option { + return func(s *Service) { + if s == nil || broker == nil { + return + } + s.paymentGatewayBroker = broker + } } // WithClock is retained for backward-compatible wiring and is currently a no-op. @@ -69,14 +74,24 @@ func WithMaxFXQuoteTTLMillis(_ int64) Option { return func(*Service) {} } -// WithGatewayInvokeResolver is retained for backward-compatible wiring and is currently a no-op. -func WithGatewayInvokeResolver(_ GatewayInvokeResolver) Option { - return func(*Service) {} +// WithGatewayInvokeResolver configures invoke-URI-to-chain-client resolution. +func WithGatewayInvokeResolver(resolver GatewayInvokeResolver) Option { + return func(s *Service) { + if s == nil { + return + } + s.gatewayInvokeResolver = resolver + } } -// WithCardGatewayRoutes is retained for backward-compatible wiring and is currently a no-op. -func WithCardGatewayRoutes(_ map[string]CardGatewayRoute) Option { - return func(*Service) {} +// WithCardGatewayRoutes configures card gateway funding/fee route metadata. +func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option { + return func(s *Service) { + if s == nil { + return + } + s.cardGatewayRoutes = cloneCardGatewayRoutes(routes) + } } // WithFeeLedgerAccounts is retained for backward-compatible wiring and is currently a no-op. @@ -84,9 +99,14 @@ func WithFeeLedgerAccounts(_ map[string]string) Option { return func(*Service) {} } -// WithGatewayRegistry is retained for backward-compatible wiring and is currently a no-op. -func WithGatewayRegistry(_ GatewayRegistry) Option { - return func(*Service) {} +// WithGatewayRegistry configures runtime gateway descriptor discovery. +func WithGatewayRegistry(registry GatewayRegistry) Option { + return func(s *Service) { + if s == nil { + return + } + s.gatewayRegistry = registry + } } type discoveryGatewayRegistry struct { @@ -205,3 +225,25 @@ func limitsFromDiscovery(src *discovery.Limits) model.Limits { return limits } + +func cloneCardGatewayRoutes(src map[string]CardGatewayRoute) map[string]CardGatewayRoute { + if len(src) == 0 { + return nil + } + out := make(map[string]CardGatewayRoute, len(src)) + for key, route := range src { + normalizedKey := strings.TrimSpace(strings.ToLower(key)) + if normalizedKey == "" { + continue + } + out[normalizedKey] = CardGatewayRoute{ + FundingAddress: strings.TrimSpace(route.FundingAddress), + FeeAddress: strings.TrimSpace(route.FeeAddress), + FeeWalletRef: strings.TrimSpace(route.FeeWalletRef), + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index be53a50a..4601d1b0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -1,9 +1,14 @@ package orchestrator import ( + "context" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/pkg/api/routers" + msg "github.com/tech/sendico/pkg/messaging" + mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "go.uber.org/zap" @@ -12,19 +17,27 @@ import ( // Service is a v2-only payment orchestrator gRPC adapter. type Service struct { - logger mlogger.Logger - repo storage.Repository - v2 psvc.Service + logger mlogger.Logger + repo storage.Repository + v2 psvc.Service + paymentRepo prepo.Repository + + gatewayInvokeResolver GatewayInvokeResolver + gatewayRegistry GatewayRegistry + cardGatewayRoutes map[string]CardGatewayRoute + paymentGatewayBroker mb.Broker + gatewayConsumers []msg.Consumer + stopExternalWorkers context.CancelFunc } // NewService constructs the v2 orchestrator service. -func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { +func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) (*Service, error) { if logger == nil { logger = zap.NewNop() } svc := &Service{ - logger: logger.Named("payment_orchestrator"), + logger: logger.Named("service"), repo: repo, } @@ -34,8 +47,17 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) } } - svc.v2 = newOrchestrationV2Service(svc.logger, repo) - return svc + var err error + svc.v2, svc.paymentRepo, err = newOrchestrationV2Service(svc.logger, repo, v2RuntimeDeps{ + GatewayInvokeResolver: svc.gatewayInvokeResolver, + GatewayRegistry: svc.gatewayRegistry, + CardGatewayRoutes: svc.cardGatewayRoutes, + }) + svc.startExternalRuntime() + if err != nil { + svc.logger.Error("Failed to initialize", zap.Error(err)) + } + return svc, err } // Register attaches the service to the supplied gRPC router. @@ -48,5 +70,19 @@ func (s *Service) Register(router routers.GRPC) error { }) } -// Shutdown releases runtime resources. Orchestration v2 currently has no background workers. -func (s *Service) Shutdown() {} +// Shutdown releases runtime resources. +func (s *Service) Shutdown() { + if s == nil { + return + } + if s.stopExternalWorkers != nil { + s.stopExternalWorkers() + s.stopExternalWorkers = nil + } + for i := range s.gatewayConsumers { + if s.gatewayConsumers[i] != nil { + s.gatewayConsumers[i].Close() + } + } + s.gatewayConsumers = nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go index 3df78c32..47b8ece2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -7,7 +7,9 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" @@ -19,54 +21,75 @@ type v2MongoDBProvider interface { MongoDatabase() *mongo.Database } -func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository) psvc.Service { +type v2RuntimeDeps struct { + GatewayInvokeResolver GatewayInvokeResolver + GatewayRegistry GatewayRegistry + CardGatewayRoutes map[string]CardGatewayRoute +} + +func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, runtimeDeps v2RuntimeDeps) (psvc.Service, prepo.Repository, error) { if logger == nil { logger = zap.NewNop() } if repo == nil { - return nil + return nil, nil, merrors.Internal("No repo for orchestrator v2 provided") } paymentRepo := buildPaymentRepositoryV2(repo, logger) if paymentRepo == nil { - if logger != nil { - logger.Warn("Orchestration v2 disabled: mongo database not available") - } - return nil + logger.Error("Orchestration v2 disabled: database not available") + return nil, nil, merrors.Internal("database is not available") } query, err := pquery.New(pquery.Dependencies{ Repository: paymentRepo, - Logger: logger.Named("orchestration_v2_pquery"), + Logger: logger, }) if err != nil { - if logger != nil { - logger.Warn("Orchestration v2 disabled: query service init failed", zap.Error(err)) - } - return nil + logger.Error("Orchestration v2 disabled: query service init failed", zap.Error(err)) + return nil, paymentRepo, err } - observer, err := oobs.New(oobs.Dependencies{Logger: logger.Named("orchestration_v2_observer")}) + observer, err := oobs.New(oobs.Dependencies{Logger: logger}) if err != nil { - if logger != nil { - logger.Warn("Orchestration v2 disabled: observer init failed", zap.Error(err)) - } - return nil + logger.Error("Orchestration v2 disabled: observer init failed", zap.Error(err)) + return nil, paymentRepo, err } + executors := buildOrchestrationV2Executors(logger, runtimeDeps) svc, err := psvc.New(psvc.Dependencies{ - Logger: logger.Named("orchestration_v2_psvc"), + Logger: logger.Named("v2"), QuoteStore: repo.Quotes(), Repository: paymentRepo, Query: query, Observer: observer, + Executors: executors, }) if err != nil { - if logger != nil { - logger.Warn("Orchestration v2 disabled: service init failed", zap.Error(err)) - } + logger.Error("Orchestration v2 disabled: service init failed", zap.Error(err)) + return nil, paymentRepo, err + } + return svc, paymentRepo, err +} + +func buildOrchestrationV2Executors(logger mlogger.Logger, runtimeDeps v2RuntimeDeps) sexec.Registry { + if runtimeDeps.GatewayInvokeResolver == nil || runtimeDeps.GatewayRegistry == nil { return nil } - return svc + execLogger := logger.Named("v2") + cryptoExecutor := &gatewayCryptoExecutor{ + gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, + gatewayRegistry: runtimeDeps.GatewayRegistry, + cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes), + } + guardExecutor := &gatewayGuardExecutor{ + logger: execLogger.Named("guard"), + gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, + gatewayRegistry: runtimeDeps.GatewayRegistry, + } + return psvc.NewDefaultExecutors(execLogger, sexec.Dependencies{ + Crypto: cryptoExecutor, + Guard: guardExecutor, + }) } func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) prepo.Repository { @@ -83,7 +106,7 @@ func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) pr } paymentRepo, err := prepo.NewMongo( db.Collection(mservice.Payments), - prepo.Dependencies{Logger: logger.Named("orchestration_v2_prepo")}, + prepo.Dependencies{Logger: logger}, ) if err != nil { return nil diff --git a/api/payments/quotation/config.dev.yml b/api/payments/quotation/config.dev.yml index 964ab789..b93037e3 100644 --- a/api/payments/quotation/config.dev.yml +++ b/api/payments/quotation/config.dev.yml @@ -49,11 +49,4 @@ oracle: 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 index b52ebf45..6fa39188 100644 --- a/api/payments/quotation/config.yml +++ b/api/payments/quotation/config.yml @@ -49,11 +49,4 @@ oracle: 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/internal/server/internal/config.go b/api/payments/quotation/internal/server/internal/config.go index 5483bb55..6e1d15f3 100644 --- a/api/payments/quotation/internal/server/internal/config.go +++ b/api/payments/quotation/internal/server/internal/config.go @@ -15,7 +15,6 @@ 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"` } diff --git a/api/payments/quotation/internal/server/internal/dependencies.go b/api/payments/quotation/internal/server/internal/dependencies.go index 675e6b1c..2a42b116 100644 --- a/api/payments/quotation/internal/server/internal/dependencies.go +++ b/api/payments/quotation/internal/server/internal/dependencies.go @@ -7,7 +7,6 @@ import ( "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" @@ -47,18 +46,12 @@ func (i *Imp) initDependencies(cfg *config) *clientDependencies { } } - 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 - } + if i != nil && i.discoveryReg != nil { + i.discoveryClients = newDiscoveryClientResolver(i.logger, i.discoveryReg) + deps.gatewayResolver = discoveryChainGatewayResolver{resolver: i.discoveryClients} + deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients} + } else if i != nil && i.logger != nil { + i.logger.Warn("Discovery registry unavailable; chain gateway clients disabled") } return deps @@ -72,9 +65,9 @@ func (i *Imp) closeDependencies() { _ = i.deps.oracleClient.Close() i.deps.oracleClient = nil } - if i.deps.gatewayClient != nil { - _ = i.deps.gatewayClient.Close() - i.deps.gatewayClient = nil + if i.discoveryClients != nil { + i.discoveryClients.Close() + i.discoveryClients = nil } if i.deps.feesConn != nil { _ = i.deps.feesConn.Close() diff --git a/api/payments/quotation/internal/server/internal/discovery_clients.go b/api/payments/quotation/internal/server/internal/discovery_clients.go new file mode 100644 index 00000000..80d28754 --- /dev/null +++ b/api/payments/quotation/internal/server/internal/discovery_clients.go @@ -0,0 +1,276 @@ +package serverimp + +import ( + "context" + "fmt" + "net" + "net/url" + "sort" + "strings" + "sync" + "time" + + chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const discoveryLogThrottle = 30 * time.Second + +type discoveryEndpoint struct { + address string + insecure bool + raw string +} + +func (e discoveryEndpoint) key() string { + return fmt.Sprintf("%s|%t", e.address, e.insecure) +} + +type discoveryClientResolver struct { + logger mlogger.Logger + registry *discovery.Registry + + mu sync.Mutex + + chainClients map[string]chainclient.Client + + lastSelection map[string]string + lastMissing map[string]time.Time +} + +func newDiscoveryClientResolver(logger mlogger.Logger, registry *discovery.Registry) *discoveryClientResolver { + if logger != nil { + logger = logger.Named("discovery_clients") + } + return &discoveryClientResolver{ + logger: logger, + registry: registry, + chainClients: map[string]chainclient.Client{}, + lastSelection: map[string]string{}, + lastMissing: map[string]time.Time{}, + } +} + +func (r *discoveryClientResolver) Close() { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + for key, client := range r.chainClients { + if client != nil { + _ = client.Close() + } + delete(r.chainClients, key) + } +} + +type discoveryGatewayInvokeResolver struct { + resolver *discoveryClientResolver +} + +func (r discoveryGatewayInvokeResolver) Resolve(ctx context.Context, invokeURI string) (chainclient.Client, error) { + if r.resolver == nil { + return nil, merrors.NoData("discovery: chain gateway unavailable") + } + return r.resolver.ChainClientByInvokeURI(ctx, invokeURI) +} + +type discoveryChainGatewayResolver struct { + resolver *discoveryClientResolver +} + +func (r discoveryChainGatewayResolver) Resolve(ctx context.Context, network string) (chainclient.Client, error) { + if r.resolver == nil { + return nil, merrors.NoData("discovery: chain gateway unavailable") + } + return r.resolver.ChainClientByNetwork(ctx, network) +} + +func (r *discoveryClientResolver) ChainClientByInvokeURI(ctx context.Context, invokeURI string) (chainclient.Client, error) { + endpoint, err := parseDiscoveryEndpoint(invokeURI) + if err != nil { + r.logMissing("chain", "invalid chain gateway invoke uri", invokeURI, err) + return nil, err + } + if ctx == nil { + ctx = context.Background() + } + + r.mu.Lock() + defer r.mu.Unlock() + + if client, ok := r.chainClients[endpoint.key()]; ok && client != nil { + return client, nil + } + + client, dialErr := chainclient.New(ctx, chainclient.Config{ + Address: endpoint.address, + Insecure: endpoint.insecure, + }) + if dialErr != nil { + r.logMissing("chain", "failed to dial chain gateway", endpoint.raw, dialErr) + return nil, dialErr + } + r.chainClients[endpoint.key()] = client + return client, nil +} + +func (r *discoveryClientResolver) ChainClientByNetwork(ctx context.Context, network string) (chainclient.Client, error) { + entry, ok := r.findChainEntry(network) + if !ok { + if strings.TrimSpace(network) == "" { + return nil, merrors.NoData("discovery: chain gateway unavailable") + } + return nil, merrors.NoData(fmt.Sprintf("discovery: chain gateway unavailable for network %s", strings.ToUpper(strings.TrimSpace(network)))) + } + return r.ChainClientByInvokeURI(ctx, entry.InvokeURI) +} + +func (r *discoveryClientResolver) findChainEntry(network string) (*discovery.RegistryEntry, bool) { + if r == nil || r.registry == nil { + r.logMissing("chain", "discovery registry unavailable", "", nil) + return nil, false + } + + network = strings.ToUpper(strings.TrimSpace(network)) + entries := r.registry.List(time.Now(), true) + matches := make([]discovery.RegistryEntry, 0) + for _, entry := range entries { + if discovery.NormalizeRail(entry.Rail) != discovery.RailCrypto { + continue + } + if strings.TrimSpace(entry.InvokeURI) == "" { + continue + } + if network != "" && !strings.EqualFold(strings.TrimSpace(entry.Network), network) { + continue + } + matches = append(matches, entry) + } + if len(matches) == 0 { + r.logMissing("chain", "discovery chain entry missing", "", nil) + return nil, false + } + + sort.Slice(matches, func(i, j int) bool { + if matches[i].RoutingPriority != matches[j].RoutingPriority { + return matches[i].RoutingPriority > matches[j].RoutingPriority + } + if matches[i].ID != matches[j].ID { + return matches[i].ID < matches[j].ID + } + return matches[i].InstanceID < matches[j].InstanceID + }) + + entry := matches[0] + entryKey := discoveryEntryKey(entry) + r.logSelection("chain", entryKey, entry) + return &entry, true +} + +func (r *discoveryClientResolver) logSelection(key, entryKey string, entry discovery.RegistryEntry) { + if r == nil { + return + } + r.mu.Lock() + last := r.lastSelection[key] + if last == entryKey { + r.mu.Unlock() + return + } + r.lastSelection[key] = entryKey + r.mu.Unlock() + if r.logger == nil { + return + } + r.logger.Info("Discovery endpoint selected", + zap.String("service_key", key), + zap.String("service", entry.Service), + zap.String("rail", entry.Rail), + zap.String("network", entry.Network), + zap.String("entry_id", entry.ID), + zap.String("instance_id", entry.InstanceID), + zap.String("invoke_uri", entry.InvokeURI)) +} + +func (r *discoveryClientResolver) logMissing(key, message, invokeURI string, err error) { + if r == nil { + return + } + now := time.Now() + r.mu.Lock() + last := r.lastMissing[key] + if !last.IsZero() && now.Sub(last) < discoveryLogThrottle { + r.mu.Unlock() + return + } + r.lastMissing[key] = now + r.mu.Unlock() + + if r.logger == nil { + return + } + fields := []zap.Field{zap.String("service_key", key)} + if invokeURI != "" { + fields = append(fields, zap.String("invoke_uri", strings.TrimSpace(invokeURI))) + } + if err != nil { + fields = append(fields, zap.Error(err)) + } + r.logger.Warn(message, fields...) +} + +func discoveryEntryKey(entry discovery.RegistryEntry) string { + return fmt.Sprintf("%s|%s|%s|%s|%s|%s", + strings.TrimSpace(entry.Service), + strings.TrimSpace(entry.ID), + strings.TrimSpace(entry.InstanceID), + strings.TrimSpace(entry.Rail), + strings.TrimSpace(entry.Network), + strings.TrimSpace(entry.InvokeURI)) +} + +func parseDiscoveryEndpoint(raw string) (discoveryEndpoint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri is required") + } + + if !strings.Contains(raw, "://") { + if _, _, splitErr := net.SplitHostPort(raw); splitErr != nil { + return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port") + } + return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil + } + + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" { + if err != nil { + return discoveryEndpoint{}, err + } + return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port") + } + + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { + case "grpc": + address := strings.TrimSpace(parsed.Host) + if _, _, splitErr := net.SplitHostPort(address); splitErr != nil { + return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port") + } + 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{}, merrors.InvalidArgument("discovery: invoke uri must include host:port") + } + return discoveryEndpoint{address: address, insecure: false, raw: raw}, nil + case "dns", "passthrough": + return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil + default: + return discoveryEndpoint{}, merrors.InvalidArgument("discovery: unsupported invoke uri scheme") + } +} diff --git a/api/payments/quotation/internal/server/internal/serverimp.go b/api/payments/quotation/internal/server/internal/serverimp.go index b6b3fa7b..86c0276c 100644 --- a/api/payments/quotation/internal/server/internal/serverimp.go +++ b/api/payments/quotation/internal/server/internal/serverimp.go @@ -51,8 +51,11 @@ func (i *Imp) Start() error { 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)) + if i.deps.gatewayResolver != nil { + opts = append(opts, quotesvc.WithChainGatewayResolver(i.deps.gatewayResolver)) + } + if i.deps.gatewayInvokeResolver != nil { + opts = append(opts, quotesvc.WithGatewayInvokeResolver(i.deps.gatewayInvokeResolver)) } } if registry := quotesvc.NewDiscoveryGatewayRegistry(logger, i.discoveryReg); registry != nil { diff --git a/api/payments/quotation/internal/server/internal/types.go b/api/payments/quotation/internal/server/internal/types.go index f6c19199..07c6a957 100644 --- a/api/payments/quotation/internal/server/internal/types.go +++ b/api/payments/quotation/internal/server/internal/types.go @@ -2,7 +2,7 @@ package serverimp import ( oracleclient "github.com/tech/sendico/fx/oracle/client" - chainclient "github.com/tech/sendico/gateway/chain/client" + quotesvc "github.com/tech/sendico/payments/quotation/internal/service/quotation" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/mlogger" @@ -17,10 +17,11 @@ type quoteService interface { } type clientDependencies struct { - feesConn *grpc.ClientConn - feesClient feesv1.FeeEngineClient - oracleClient oracleclient.Client - gatewayClient chainclient.Client + feesConn *grpc.ClientConn + feesClient feesv1.FeeEngineClient + oracleClient oracleclient.Client + gatewayResolver quotesvc.ChainGatewayResolver + gatewayInvokeResolver quotesvc.GatewayInvokeResolver } type Imp struct { @@ -36,4 +37,5 @@ type Imp struct { discoveryWatcher *discovery.RegistryWatcher discoveryReg *discovery.Registry discoveryAnnouncer *discovery.Announcer + discoveryClients *discoveryClientResolver } diff --git a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go new file mode 100644 index 00000000..fefd1f9a --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go @@ -0,0 +1,202 @@ +package quotation + +import ( + "context" + "errors" + "sort" + "strings" + + "github.com/tech/sendico/payments/storage/model" + chainpkg "github.com/tech/sendico/pkg/chain" + "github.com/tech/sendico/pkg/merrors" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type managedWalletNetworkResolver struct { + resolver ChainGatewayResolver + gatewayRegistry GatewayRegistry + gatewayInvokeResolver GatewayInvokeResolver + logger *zap.Logger +} + +func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolver { + if core == nil { + return nil + } + logger := core.logger + if logger == nil { + logger = zap.NewNop() + } + return &managedWalletNetworkResolver{ + resolver: core.deps.gateway.resolver, + gatewayRegistry: core.deps.gatewayRegistry, + gatewayInvokeResolver: core.deps.gatewayInvokeResolver, + logger: logger, + } +} + +func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) { + if r == nil { + return "", merrors.NoData("chain gateway unavailable") + } + walletRef := strings.TrimSpace(managedWalletRef) + if walletRef == "" { + return "", merrors.InvalidArgument("managed_wallet_ref is required") + } + + var discoveryErr error + if r.gatewayRegistry != nil && r.gatewayInvokeResolver != nil { + network, err := r.resolveFromDiscoveredGateways(ctx, walletRef) + if err == nil { + return network, nil + } + discoveryErr = err + if r.logger != nil { + r.logger.Warn("Managed wallet network lookup via discovery failed", + zap.String("wallet_ref", walletRef), + zap.Error(err), + ) + } + } + + if r.resolver == nil { + if discoveryErr != nil { + return "", discoveryErr + } + return "", merrors.NoData("chain gateway unavailable") + } + + client, err := r.resolver.Resolve(ctx, "") + if err != nil { + return "", err + } + if client == nil { + return "", merrors.NoData("chain gateway unavailable") + } + resp, err := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef}) + if err != nil { + return "", err + } + return managedWalletNetworkFromResponse(resp) +} + +func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context.Context, walletRef string) (string, error) { + entries, err := r.gatewayRegistry.List(ctx) + if err != nil { + return "", err + } + + type candidate struct { + gatewayID string + instanceID string + network string + invokeURI string + } + candidates := make([]candidate, 0, len(entries)) + seenInvokeURI := map[string]struct{}{} + for _, entry := range entries { + if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto { + continue + } + invokeURI := strings.TrimSpace(entry.InvokeURI) + if invokeURI == "" { + continue + } + key := strings.ToLower(invokeURI) + if _, exists := seenInvokeURI[key]; exists { + continue + } + seenInvokeURI[key] = struct{}{} + candidates = append(candidates, candidate{ + gatewayID: strings.TrimSpace(entry.ID), + instanceID: strings.TrimSpace(entry.InstanceID), + network: strings.ToUpper(strings.TrimSpace(entry.Network)), + invokeURI: invokeURI, + }) + } + + if len(candidates) == 0 { + return "", merrors.NoData("chain gateway unavailable") + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].gatewayID != candidates[j].gatewayID { + return candidates[i].gatewayID < candidates[j].gatewayID + } + if candidates[i].instanceID != candidates[j].instanceID { + return candidates[i].instanceID < candidates[j].instanceID + } + return candidates[i].invokeURI < candidates[j].invokeURI + }) + + var firstErr error + for _, candidate := range candidates { + client, resolveErr := r.gatewayInvokeResolver.Resolve(ctx, candidate.invokeURI) + if resolveErr != nil { + if firstErr == nil { + firstErr = resolveErr + } + continue + } + resp, lookupErr := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef}) + if lookupErr != nil { + if isManagedWalletNotFound(lookupErr) { + continue + } + if firstErr == nil { + firstErr = lookupErr + } + continue + } + network, extractErr := managedWalletNetworkFromResponse(resp) + if extractErr != nil { + if firstErr == nil { + firstErr = extractErr + } + continue + } + if r.logger != nil { + r.logger.Debug("Resolved managed wallet network from discovered gateway", + zap.String("wallet_ref", walletRef), + zap.String("gateway_id", candidate.gatewayID), + zap.String("instance_id", candidate.instanceID), + zap.String("gateway_network", candidate.network), + zap.String("resolved_network", network), + ) + } + return network, nil + } + + if firstErr != nil { + return "", firstErr + } + return "", merrors.NoData("managed wallet not found in discovered gateways") +} + +func managedWalletNetworkFromResponse(resp *chainv1.GetManagedWalletResponse) (string, error) { + wallet := resp.GetWallet() + if wallet == nil || wallet.GetAsset() == nil { + return "", merrors.NoData("managed wallet asset is missing") + } + network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain()))) + if network == "" || network == "UNSPECIFIED" { + return "", merrors.NoData("managed wallet network is missing") + } + return network, nil +} + +func isManagedWalletNotFound(err error) bool { + if err == nil { + return false + } + if errors.Is(err, merrors.ErrNoData) { + return true + } + if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound { + return true + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + return strings.Contains(msg, "not_found") +} diff --git a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver_test.go b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver_test.go new file mode 100644 index 00000000..c80699fa --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver_test.go @@ -0,0 +1,139 @@ +package quotation + +import ( + "context" + "errors" + "reflect" + "testing" + + chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestManagedWalletNetworkResolver_ResolvesAcrossDiscoveredGateways(t *testing.T) { + invokeResolver := &fakeGatewayInvokeResolver{ + clients: map[string]chainclient.Client{ + "gw-a:50053": &chainclient.Fake{ + GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) { + return nil, status.Error(codes.NotFound, "not_found") + }, + }, + "gw-b:50053": &chainclient.Fake{ + GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) { + return &chainv1.GetManagedWalletResponse{ + Wallet: &chainv1.ManagedWallet{ + Asset: &chainv1.Asset{Chain: chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE}, + }, + }, nil + }, + }, + }, + } + resolver := &managedWalletNetworkResolver{ + gatewayRegistry: fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + {ID: "gw-a", Rail: model.RailCrypto, IsEnabled: true, InvokeURI: "gw-a:50053"}, + {ID: "gw-b", Rail: model.RailCrypto, IsEnabled: true, InvokeURI: "gw-b:50053"}, + }, + }, + gatewayInvokeResolver: invokeResolver, + logger: zap.NewNop(), + } + + network, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := network, "TRON_NILE"; got != want { + t.Fatalf("network mismatch: got=%q want=%q", got, want) + } + if got, want := invokeResolver.calls, []string{"gw-a:50053", "gw-b:50053"}; !reflect.DeepEqual(got, want) { + t.Fatalf("invoke calls mismatch: got=%v want=%v", got, want) + } +} + +func TestManagedWalletNetworkResolver_FallbacksToChainResolver(t *testing.T) { + chainResolver := &fakeChainResolver{ + client: &chainclient.Fake{ + GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) { + return &chainv1.GetManagedWalletResponse{ + Wallet: &chainv1.ManagedWallet{ + Asset: &chainv1.Asset{Chain: chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA}, + }, + }, nil + }, + }, + } + resolver := &managedWalletNetworkResolver{ + resolver: chainResolver, + logger: zap.NewNop(), + } + + network, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := network, "ARBITRUM_SEPOLIA"; got != want { + t.Fatalf("network mismatch: got=%q want=%q", got, want) + } + if got, want := chainResolver.args, []string{""}; !reflect.DeepEqual(got, want) { + t.Fatalf("resolver args mismatch: got=%v want=%v", got, want) + } +} + +func TestManagedWalletNetworkResolver_ReturnsNoDataWhenNoGateways(t *testing.T) { + resolver := &managedWalletNetworkResolver{ + gatewayRegistry: fakeGatewayRegistry{}, + gatewayInvokeResolver: &fakeGatewayInvokeResolver{ + clients: map[string]chainclient.Client{}, + }, + logger: zap.NewNop(), + } + + _, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref") + if !errors.Is(err, merrors.ErrNoData) { + t.Fatalf("expected no_data error, got %v", err) + } +} + +type fakeGatewayRegistry struct { + items []*model.GatewayInstanceDescriptor + err error +} + +func (f fakeGatewayRegistry) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { + return f.items, f.err +} + +type fakeGatewayInvokeResolver struct { + clients map[string]chainclient.Client + err error + calls []string +} + +func (f *fakeGatewayInvokeResolver) Resolve(_ context.Context, invokeURI string) (chainclient.Client, error) { + f.calls = append(f.calls, invokeURI) + if f.err != nil { + return nil, f.err + } + return f.clients[invokeURI], nil +} + +type fakeChainResolver struct { + client chainclient.Client + err error + args []string +} + +func (f *fakeChainResolver) Resolve(_ context.Context, network string) (chainclient.Client, error) { + f.args = append(f.args, network) + if f.err != nil { + return nil, f.err + } + return f.client, nil +} diff --git a/api/payments/quotation/internal/service/quotation/options.go b/api/payments/quotation/internal/service/quotation/options.go index ac04dad1..dc9d8147 100644 --- a/api/payments/quotation/internal/service/quotation/options.go +++ b/api/payments/quotation/internal/service/quotation/options.go @@ -74,14 +74,6 @@ func (o oracleDependency) available() bool { return true } -type staticChainGatewayResolver struct { - client chainclient.Client -} - -func (r staticChainGatewayResolver) Resolve(context.Context, string) (chainclient.Client, error) { - return r.client, nil -} - // WithFeeEngine wires the fee engine client. func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { return func(s *Service) { @@ -96,13 +88,6 @@ func WithOracleClient(client oracleclient.Client) Option { } } -// 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) { diff --git a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go index 205f4163..912fa9e5 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go +++ b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go @@ -53,6 +53,9 @@ func newQuoteComputationService(core *Service) *quote_computation_service.QuoteC if core != nil && core.deps.gatewayRegistry != nil { opts = append(opts, quote_computation_service.WithGatewayRegistry(core.deps.gatewayRegistry)) } + if resolver := newManagedWalletNetworkResolver(core); resolver != nil { + opts = append(opts, quote_computation_service.WithManagedWalletNetworkResolver(resolver)) + } if resolver := fundingProfileResolver(core); resolver != nil { opts = append(opts, quote_computation_service.WithFundingProfileResolver(resolver)) } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go new file mode 100644 index 00000000..fcd9ccb0 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go @@ -0,0 +1,69 @@ +package quote_computation_service + +import ( + "context" + "strings" + + "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 *QuoteComputationService) enrichManagedWalletEndpointNetwork( + ctx context.Context, + endpoint *model.PaymentEndpoint, + cache map[string]string, +) error { + if s == nil || endpoint == nil { + return nil + } + if endpoint.Type != model.EndpointTypeManagedWallet || endpoint.ManagedWallet == nil { + return nil + } + walletRef := strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef) + if walletRef == "" { + return merrors.InvalidArgument("managed_wallet_ref is required") + } + if endpoint.ManagedWallet.Asset != nil && strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()) != "" { + return nil + } + if s.managedWalletNetworkResolver == nil { + return nil + } + + network := "" + if cache != nil { + network = strings.ToUpper(strings.TrimSpace(cache[walletRef])) + } + if network == "" { + resolved, err := s.managedWalletNetworkResolver.ResolveManagedWalletNetwork(ctx, walletRef) + if err != nil { + return err + } + network = strings.ToUpper(strings.TrimSpace(resolved)) + if network == "" { + return merrors.NoData("managed wallet network is missing") + } + if cache != nil { + cache[walletRef] = network + } + } + + if s.logger != nil { + s.logger.Debug("Managed wallet network resolved for quote planning", + zap.String("wallet_ref", walletRef), + zap.String("network", network), + ) + } + + asset := endpoint.ManagedWallet.Asset + if asset == nil { + asset = &paymenttypes.Asset{} + endpoint.ManagedWallet.Asset = asset + } + asset.Chain = network + asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.TokenSymbol)) + asset.ContractAddress = strings.TrimSpace(asset.ContractAddress) + return nil +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go new file mode 100644 index 00000000..7b5981c8 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go @@ -0,0 +1,185 @@ +package quote_computation_service + +import ( + "context" + "errors" + "testing" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestBuildPlan_ResolvesManagedWalletNetworkFromResolver(t *testing.T) { + resolver := &fakeManagedWalletNetworkResolver{ + networks: map[string]string{ + "wallet-usdt-source": "TRON_NILE", + }, + } + svc := New(nil, + WithManagedWalletNetworkResolver(resolver), + WithGatewayRegistry(staticGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto-arbitrum", + InstanceID: "crypto-arbitrum", + Rail: model.RailCrypto, + Network: "ARBITRUM_SEPOLIA", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "crypto-tron", + InstanceID: "crypto-tron", + Rail: model.RailCrypto, + Network: "TRON_NILE", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "card-gw", + InstanceID: "card-gw", + Rail: model.RailCardPayout, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + }, + }), + ) + + intent := sampleCryptoToCardQuoteIntent() + intent.Source.ManagedWallet.Asset = nil + orgID := bson.NewObjectID() + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-wallet-network", + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil { + t.Fatalf("expected one plan item") + } + item := planModel.Items[0] + if got, want := item.Steps[0].GatewayID, "crypto-tron"; got != want { + t.Fatalf("unexpected source gateway: got=%q want=%q", got, want) + } + if item.Route == nil || len(item.Route.GetHops()) == 0 { + t.Fatalf("expected route hops") + } + if got, want := item.Route.GetHops()[0].GetNetwork(), "tron_nile"; got != want { + t.Fatalf("unexpected source hop network: got=%q want=%q", got, want) + } + if got, want := resolver.calls, 1; got != want { + t.Fatalf("unexpected resolver calls: got=%d want=%d", got, want) + } +} + +func TestBuildPlan_ManagedWalletNetworkResolverCachesByWalletRef(t *testing.T) { + resolver := &fakeManagedWalletNetworkResolver{ + networks: map[string]string{ + "wallet-usdt-source": "TRON_NILE", + }, + } + svc := New(nil, + WithManagedWalletNetworkResolver(resolver), + WithGatewayRegistry(staticGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto-tron", + InstanceID: "crypto-tron", + Rail: model.RailCrypto, + Network: "TRON_NILE", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "card-gw", + InstanceID: "card-gw", + Rail: model.RailCardPayout, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + }, + }), + ) + + intentA := sampleCryptoToCardQuoteIntent() + intentA.Ref = "intent-a" + intentA.Source.ManagedWallet.Asset = nil + intentB := sampleCryptoToCardQuoteIntent() + intentB.Ref = "intent-b" + intentB.Source.ManagedWallet.Asset = nil + + orgID := bson.NewObjectID() + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + PreviewOnly: true, + Intents: []*transfer_intent_hydrator.QuoteIntent{intentA, intentB}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 2 { + t.Fatalf("expected two plan items") + } + if got, want := resolver.calls, 1; got != want { + t.Fatalf("unexpected resolver calls: got=%d want=%d", got, want) + } +} + +func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) { + resolver := &fakeManagedWalletNetworkResolver{ + err: merrors.NoData("wallet not found"), + } + svc := New(nil, WithManagedWalletNetworkResolver(resolver)) + + intent := sampleCryptoToCardQuoteIntent() + intent.Source.ManagedWallet.Asset = nil + orgID := bson.NewObjectID() + _, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-wallet-network-fail", + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if !errors.Is(err, merrors.ErrNoData) { + t.Fatalf("expected no_data error, got %v", err) + } +} + +type fakeManagedWalletNetworkResolver struct { + networks map[string]string + err error + calls int +} + +func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context.Context, managedWalletRef string) (string, error) { + f.calls++ + if f.err != nil { + return "", f.err + } + if f.networks == nil { + return "", nil + } + return f.networks[managedWalletRef], nil +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index b478ba70..82e313d9 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -51,9 +51,10 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput BaseIdempotencyKey: strings.TrimSpace(in.BaseIdempotencyKey), Items: make([]*QuoteComputationPlanItem, 0, len(in.Intents)), } + managedWalletNetworks := map[string]string{} for i, intent := range in.Intents { - item, err := s.buildPlanItem(ctx, in, i, intent) + item, err := s.buildPlanItem(ctx, in, i, intent, managedWalletNetworks) if err != nil { s.logger.Warn("Computation plan item build failed", zap.String("org_ref", in.OrganizationRef), @@ -81,6 +82,7 @@ func (s *QuoteComputationService) buildPlanItem( in ComputeInput, index int, intent *transfer_intent_hydrator.QuoteIntent, + managedWalletNetworks map[string]string, ) (*QuoteComputationPlanItem, error) { if intent == nil { s.logger.Warn("Plan item build failed: intent is nil", zap.Int("index", index)) @@ -118,6 +120,22 @@ func (s *QuoteComputationService) buildPlanItem( source := clonePaymentEndpoint(modelIntent.Source) destination := clonePaymentEndpoint(modelIntent.Destination) + if err := s.enrichManagedWalletEndpointNetwork(ctx, &source, managedWalletNetworks); err != nil { + s.logger.Warn("Plan item build failed: source managed wallet network resolution error", + zap.Int("index", index), + zap.Error(err), + ) + return nil, err + } + if err := s.enrichManagedWalletEndpointNetwork(ctx, &destination, managedWalletNetworks); err != nil { + s.logger.Warn("Plan item build failed: destination managed wallet network resolution error", + zap.Int("index", index), + zap.Error(err), + ) + return nil, err + } + modelIntent.Source = clonePaymentEndpoint(source) + modelIntent.Destination = clonePaymentEndpoint(destination) sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true) if err != nil { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go index 3f225399..6f07935b 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go @@ -15,15 +15,20 @@ type Core interface { BuildQuote(ctx context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error) } +type ManagedWalletNetworkResolver interface { + ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) +} + type Option func(*QuoteComputationService) type QuoteComputationService struct { - core Core - fundingResolver gateway_funding_profile.FundingProfileResolver - gatewayRegistry plan.GatewayRegistry - routeStore plan.RouteStore - pathFinder *graph_path_finder.GraphPathFinder - logger mlogger.Logger + core Core + fundingResolver gateway_funding_profile.FundingProfileResolver + gatewayRegistry plan.GatewayRegistry + managedWalletNetworkResolver ManagedWalletNetworkResolver + routeStore plan.RouteStore + pathFinder *graph_path_finder.GraphPathFinder + logger mlogger.Logger } func New(core Core, opts ...Option) *QuoteComputationService { @@ -56,6 +61,14 @@ func WithGatewayRegistry(registry plan.GatewayRegistry) Option { } } +func WithManagedWalletNetworkResolver(resolver ManagedWalletNetworkResolver) Option { + return func(svc *QuoteComputationService) { + if svc != nil { + svc.managedWalletNetworkResolver = resolver + } + } +} + func WithRouteStore(store plan.RouteStore) Option { return func(svc *QuoteComputationService) { if svc != nil { diff --git a/api/pkg/mutil/mzap/account.go b/api/pkg/mutil/mzap/account.go index 7a10d280..909987ee 100644 --- a/api/pkg/mutil/mzap/account.go +++ b/api/pkg/mutil/mzap/account.go @@ -12,7 +12,11 @@ func AccRef(accountRef bson.ObjectID) zap.Field { } func Email(email string) zap.Field { - return zap.String("email", mask.Email(email)) + return MaskEmail("email", email) +} + +func MaskEmail(field, email string) zap.Field { + return zap.String(field, mask.Email(email)) } func Login(account *model.Account) zap.Field { diff --git a/api/server/internal/server/verificationimp/sendcode.go b/api/server/internal/server/verificationimp/sendcode.go index 77069510..f9e2feff 100644 --- a/api/server/internal/server/verificationimp/sendcode.go +++ b/api/server/internal/server/verificationimp/sendcode.go @@ -3,7 +3,6 @@ package verificationimp import ( cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation" "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/mutil/mask" "github.com/tech/sendico/pkg/mutil/mzap" "go.uber.org/zap" ) @@ -11,7 +10,7 @@ import ( func (a *VerificationAPI) sendCode(account *model.Account, target model.VerificationPurpose, destination, code string) { a.logger.Info("Confirmation code generated", zap.String("target", string(target)), - zap.String("destination", mask.Email(destination)), + mzap.MaskEmail("destination", destination), mzap.AccRef(account.ID)) if err := a.producer.SendMessage(cnotifications.Code(a.Name(), account.ID, destination, target, code)); err != nil { a.logger.Warn("Failed to send confirmation code notification", zap.Error(err), mzap.AccRef(account.ID)) -- 2.49.1 From 26bedc5743d149db7a961d1426029da6535d6157 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 25 Feb 2026 20:09:45 +0100 Subject: [PATCH 08/23] +implementation of settlement step execution --- .../service/orchestrationv2/prepo/indexes.go | 1 + .../orchestrationv2/prepo/service_test.go | 2 +- .../service/orchestrator/external_runtime.go | 19 ++ .../orchestrator/external_runtime_test.go | 56 +++++ .../service/orchestrator/service_v2.go | 28 +-- .../orchestrator/settlement_executor.go | 218 ++++++++++++++++++ .../orchestrator/settlement_executor_test.go | 191 +++++++++++++++ 7 files changed, 501 insertions(+), 14 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go index 3d9126a0..dd9523de 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go @@ -19,6 +19,7 @@ func requiredIndexes() []*indexDefinition { Keys: []ri.Key{ {Field: "paymentRef", Sort: ri.Asc}, }, + Unique: true, }, { Keys: []ri.Key{ diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go index a5370604..fc504b3b 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go @@ -32,7 +32,7 @@ func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) { } assertIndex(t, store.indexes[0], []string{"organizationRef", "paymentRef"}, true) - assertIndex(t, store.indexes[1], []string{"paymentRef"}, false) + assertIndex(t, store.indexes[1], []string{"paymentRef"}, true) assertIndex(t, store.indexes[2], []string{"organizationRef", "idempotencyKey"}, true) assertIndex(t, store.indexes[3], []string{"organizationRef", "quotationRef", "createdAt"}, false) assertIndex(t, store.indexes[4], []string{"organizationRef", "state", "createdAt"}, false) diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go index 053a3aae..6bebc067 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go @@ -472,6 +472,25 @@ func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment } func (s *Service) resolveObserveGateway(ctx context.Context, payment *agg.Payment, candidate runningObserveCandidate) (*model.GatewayInstanceDescriptor, error) { + if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" { + items, err := s.gatewayRegistry.List(ctx) + if err == nil { + for i := range items { + item := items[i] + if item == nil || !item.IsEnabled { + continue + } + if !strings.EqualFold(strings.TrimSpace(item.ID), gatewayID) && !strings.EqualFold(strings.TrimSpace(item.InstanceID), gatewayID) { + continue + } + if strings.TrimSpace(item.InvokeURI) == "" { + continue + } + return item, nil + } + } + } + executor := gatewayCryptoExecutor{ gatewayRegistry: s.gatewayRegistry, } diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go index 3edb11f1..6c253042 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go @@ -9,8 +9,10 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/payments/storage/model" pm "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/payments/rail" + paymenttypes "github.com/tech/sendico/pkg/payments/types" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "go.mongodb.org/mongo-driver/v2/bson" @@ -271,5 +273,59 @@ func TestRunningObserveCandidates(t *testing.T) { } } +func TestResolveObserveGateway_UsesExternalRefGatewayInstanceAcrossRails(t *testing.T) { + svc := &Service{ + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + Rail: model.RailProviderSettlement, + InvokeURI: "grpc://tgsettle-gateway", + IsEnabled: true, + }, + { + ID: "crypto_rail_gateway_tron_nile", + InstanceID: "crypto_rail_gateway_tron_nile", + Rail: model.RailCrypto, + InvokeURI: "grpc://tron-gateway", + IsEnabled: true, + }, + }, + }, + } + + payment := &agg.Payment{ + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + { + Index: 1, + Rail: "CRYPTO", + Gateway: "crypto_rail_gateway_tron_nile", + InstanceID: "crypto_rail_gateway_tron_nile", + Role: paymenttypes.QuoteRouteHopRoleSource, + }, + }, + }, + }, + } + + gateway, err := svc.resolveObserveGateway(context.Background(), payment, runningObserveCandidate{ + stepRef: "hop_2_settlement_observe", + transferRef: "trf-1", + gatewayInstanceID: "payment_gateway_settlement", + }) + if err != nil { + t.Fatalf("resolveObserveGateway returned error: %v", err) + } + if gateway == nil { + t.Fatal("expected gateway") + } + if got, want := gateway.ID, "payment_gateway_settlement"; got != want { + t.Fatalf("gateway id mismatch: got=%q want=%q", got, want) + } +} + var _ prepo.Repository = (*fakeExternalRuntimeRepo)(nil) var _ psvc.Service = (*fakeExternalRuntimeV2)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go index 47b8ece2..03ed9a66 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -35,9 +35,9 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r return nil, nil, merrors.Internal("No repo for orchestrator v2 provided") } - paymentRepo := buildPaymentRepositoryV2(repo, logger) - if paymentRepo == nil { - logger.Error("Orchestration v2 disabled: database not available") + paymentRepo, err := buildPaymentRepositoryV2(repo, logger) + if paymentRepo == nil || err != nil { + logger.Error("Orchestration v2 disabled: database not available", zap.Error(err)) return nil, nil, merrors.Internal("database is not available") } @@ -81,37 +81,39 @@ func buildOrchestrationV2Executors(logger mlogger.Logger, runtimeDeps v2RuntimeD gatewayRegistry: runtimeDeps.GatewayRegistry, cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes), } + providerSettlementExecutor := &gatewayProviderSettlementExecutor{ + gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, + gatewayRegistry: runtimeDeps.GatewayRegistry, + } guardExecutor := &gatewayGuardExecutor{ logger: execLogger.Named("guard"), gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, gatewayRegistry: runtimeDeps.GatewayRegistry, } return psvc.NewDefaultExecutors(execLogger, sexec.Dependencies{ - Crypto: cryptoExecutor, - Guard: guardExecutor, + Crypto: cryptoExecutor, + ProviderSettlement: providerSettlementExecutor, + Guard: guardExecutor, }) } -func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) prepo.Repository { +func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) (prepo.Repository, error) { if repo == nil { - return nil + return nil, merrors.InvalidArgument("repo must be provided") } provider, ok := repo.(v2MongoDBProvider) if !ok { - return nil + return nil, merrors.Internal("Failed to fetch correct repository interface") } db := provider.MongoDatabase() if db == nil { - return nil + return nil, merrors.Internal("Failed to fetch database") } paymentRepo, err := prepo.NewMongo( db.Collection(mservice.Payments), prepo.Dependencies{Logger: logger}, ) - if err != nil { - return nil - } - return paymentRepo + return paymentRepo, err } type v2GRPCServer struct { diff --git a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go new file mode 100644 index 00000000..0490787f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go @@ -0,0 +1,218 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +const ( + settlementMetadataQuoteRef = "quote_ref" + settlementMetadataOutgoingLeg = "outgoing_leg" +) + +type gatewayProviderSettlementExecutor struct { + gatewayInvokeResolver GatewayInvokeResolver + gatewayRegistry GatewayRegistry +} + +func (e *gatewayProviderSettlementExecutor) ExecuteProviderSettlement(ctx context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if req.Payment == nil { + return nil, merrors.InvalidArgument("settlement fx_convert: payment is required") + } + if model.ParseRailOperation(string(req.Step.Action)) != model.RailOperationFXConvert { + return nil, merrors.InvalidArgument("settlement fx_convert: unsupported action") + } + + gateway, err := e.resolveGateway(ctx, req.Step) + if err != nil { + return nil, err + } + client, err := e.gatewayInvokeResolver.Resolve(ctx, gateway.InvokeURI) + if err != nil { + return nil, err + } + + sourceWalletRef, err := sourceManagedWalletRef(req.Payment) + if err != nil { + return nil, err + } + destination := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ + ManagedWalletRef: sourceWalletRef, + }, + } + + amount, err := settlementAmount(req.Payment) + if err != nil { + return nil, err + } + + stepRef := strings.TrimSpace(req.Step.StepRef) + operationRef := strings.TrimSpace(req.Payment.PaymentRef) + ":" + stepRef + idempotencyKey := strings.TrimSpace(req.Payment.IdempotencyKey) + if idempotencyKey == "" { + idempotencyKey = operationRef + } + idempotencyKey += ":" + stepRef + + resp, err := client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: req.Payment.OrganizationRef.Hex(), + SourceWalletRef: sourceWalletRef, + Destination: destination, + Amount: amount, + OperationRef: operationRef, + IntentRef: strings.TrimSpace(req.Payment.IntentSnapshot.Ref), + PaymentRef: strings.TrimSpace(req.Payment.PaymentRef), + Metadata: settlementTransferMetadata(req.Payment, req.Step), + }) + if err != nil { + return nil, err + } + if resp == nil || resp.GetTransfer() == nil { + return nil, merrors.Internal("settlement fx_convert: transfer response is missing") + } + + step := req.StepExecution + refs, refsErr := transferExternalRefs(resp.GetTransfer(), firstNonEmpty( + strings.TrimSpace(req.Step.InstanceID), + strings.TrimSpace(gateway.InstanceID), + strings.TrimSpace(req.Step.Gateway), + strings.TrimSpace(gateway.ID), + )) + if refsErr != nil { + return nil, refsErr + } + step.ExternalRefs = refs + step.State = agg.StepStateCompleted + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{StepExecution: step}, nil +} + +func (e *gatewayProviderSettlementExecutor) resolveGateway(ctx context.Context, step xplan.Step) (*model.GatewayInstanceDescriptor, error) { + if e.gatewayRegistry == nil { + return nil, merrors.InvalidArgument("settlement fx_convert: gateway registry is required") + } + items, err := e.gatewayRegistry.List(ctx) + if err != nil { + return nil, err + } + + stepGateway := strings.TrimSpace(step.Gateway) + stepInstance := strings.TrimSpace(step.InstanceID) + + var byInstance *model.GatewayInstanceDescriptor + var byGateway *model.GatewayInstanceDescriptor + var single *model.GatewayInstanceDescriptor + settlementCount := 0 + for i := range items { + item := items[i] + if item == nil || model.ParseRail(string(item.Rail)) != model.RailProviderSettlement || !item.IsEnabled { + continue + } + settlementCount++ + single = item + if stepInstance != "" && (strings.EqualFold(strings.TrimSpace(item.InstanceID), stepInstance) || strings.EqualFold(strings.TrimSpace(item.ID), stepInstance)) { + byInstance = item + break + } + if stepGateway != "" && (strings.EqualFold(strings.TrimSpace(item.ID), stepGateway) || strings.EqualFold(strings.TrimSpace(item.InstanceID), stepGateway)) { + byGateway = item + } + } + switch { + case byInstance != nil: + if strings.TrimSpace(byInstance.InvokeURI) == "" { + return nil, merrors.InvalidArgument("settlement fx_convert: gateway invoke uri is missing") + } + return byInstance, nil + case byGateway != nil: + if strings.TrimSpace(byGateway.InvokeURI) == "" { + return nil, merrors.InvalidArgument("settlement fx_convert: gateway invoke uri is missing") + } + return byGateway, nil + case stepGateway == "" && stepInstance == "" && settlementCount == 1: + if strings.TrimSpace(single.InvokeURI) == "" { + return nil, merrors.InvalidArgument("settlement fx_convert: gateway invoke uri is missing") + } + return single, nil + default: + return nil, merrors.InvalidArgument("settlement fx_convert: gateway instance not found") + } +} + +func settlementAmount(payment *agg.Payment) (*moneyv1.Money, error) { + if payment == nil { + return nil, merrors.InvalidArgument("settlement fx_convert: payment is required") + } + + money := sourceAmountForSettlement(payment) + if money == nil { + return nil, merrors.InvalidArgument("settlement fx_convert: debit amount is required") + } + amount := strings.TrimSpace(money.Amount) + currency := strings.TrimSpace(money.Currency) + if amount == "" || currency == "" { + return nil, merrors.InvalidArgument("settlement fx_convert: debit amount is invalid") + } + return &moneyv1.Money{ + Amount: amount, + Currency: currency, + }, nil +} + +func sourceAmountForSettlement(payment *agg.Payment) *moneyv1.Money { + if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.DebitAmount != nil { + return &moneyv1.Money{ + Amount: strings.TrimSpace(payment.QuoteSnapshot.DebitAmount.Amount), + Currency: strings.TrimSpace(payment.QuoteSnapshot.DebitAmount.Currency), + } + } + if payment != nil && payment.IntentSnapshot.Amount != nil { + return &moneyv1.Money{ + Amount: strings.TrimSpace(payment.IntentSnapshot.Amount.Amount), + Currency: strings.TrimSpace(payment.IntentSnapshot.Amount.Currency), + } + } + return nil +} + +func settlementTransferMetadata(payment *agg.Payment, step xplan.Step) map[string]string { + out := transferMetadata(step) + if out == nil { + out = map[string]string{} + } + if payment != nil { + if quoteRef := firstNonEmpty( + strings.TrimSpace(payment.QuotationRef), + strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot)), + ); quoteRef != "" { + out[settlementMetadataQuoteRef] = quoteRef + } + } + if outgoingLeg := strings.TrimSpace(string(step.Rail)); outgoingLeg != "" { + out[settlementMetadataOutgoingLeg] = outgoingLeg + } + if len(out) == 0 { + return nil + } + return out +} + +func quoteRefFromSnapshot(snapshot *model.PaymentQuoteSnapshot) string { + if snapshot == nil { + return "" + } + return strings.TrimSpace(snapshot.QuoteRef) +} + +var _ sexec.ProviderSettlementExecutor = (*gatewayProviderSettlementExecutor)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go new file mode 100644 index 00000000..1d6cf0c6 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go @@ -0,0 +1,191 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + pm "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_SubmitsTransfer(t *testing.T) { + orgID := bson.NewObjectID() + + var submitReq *chainv1.SubmitTransferRequest + client := &chainclient.Fake{ + SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + submitReq = req + return &chainv1.SubmitTransferResponse{ + Transfer: &chainv1.Transfer{ + TransferRef: "trf-settlement-1", + OperationRef: "op-settlement-1", + }, + }, nil + }, + } + resolver := &fakeGatewayInvokeResolver{client: client} + registry := &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + Rail: model.RailProviderSettlement, + InvokeURI: "grpc://tgsettle-gateway", + IsEnabled: true, + }, + }, + } + executor := &gatewayProviderSettlementExecutor{ + gatewayInvokeResolver: resolver, + gatewayRegistry: registry, + } + + req := sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-1", + IdempotencyKey: "idem-1", + QuotationRef: "quote-1", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-1", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{Pan: "4111111111111111"}, + }, + Amount: &paymenttypes.Money{Amount: "1", Currency: "USDT"}, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"}, + ExpectedSettlementAmount: &paymenttypes.Money{Amount: "76.63", Currency: "RUB"}, + QuoteRef: "quote-1", + }, + }, + Step: xplan.Step{ + StepRef: "hop_2_settlement_fx_convert", + StepCode: "hop.2.settlement.fx_convert", + Action: model.RailOperationFXConvert, + Rail: model.RailProviderSettlement, + Gateway: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_2_settlement_fx_convert", + StepCode: "hop.2.settlement.fx_convert", + Attempt: 1, + }, + } + + out, err := executor.ExecuteProviderSettlement(context.Background(), req) + if err != nil { + t.Fatalf("ExecuteProviderSettlement returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if out.StepExecution.State != agg.StepStateCompleted { + t.Fatalf("expected completed state, got=%q", out.StepExecution.State) + } + if submitReq == nil { + t.Fatal("expected transfer submission request") + } + if got, want := resolver.lastInvokeURI, "grpc://tgsettle-gateway"; got != want { + t.Fatalf("invoke uri mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetSourceWalletRef(), "wallet-src"; got != want { + t.Fatalf("source wallet mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetDestination().GetManagedWalletRef(), "wallet-src"; got != want { + t.Fatalf("destination mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetAmount().GetAmount(), "1.000000"; got != want { + t.Fatalf("amount mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("currency mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetMetadata()[settlementMetadataQuoteRef], "quote-1"; got != want { + t.Fatalf("quote_ref metadata mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetMetadata()[settlementMetadataOutgoingLeg], string(model.RailProviderSettlement); got != want { + t.Fatalf("outgoing_leg metadata mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 2 { + t.Fatalf("expected two external refs, got=%d", len(out.StepExecution.ExternalRefs)) + } + if out.StepExecution.ExternalRefs[0].Kind != erecon.ExternalRefKindOperation { + t.Fatalf("unexpected first external ref kind: %q", out.StepExecution.ExternalRefs[0].Kind) + } +} + +func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_MissingSettlementAmount(t *testing.T) { + orgID := bson.NewObjectID() + + executor := &gatewayProviderSettlementExecutor{ + gatewayInvokeResolver: &fakeGatewayInvokeResolver{ + client: &chainclient.Fake{}, + }, + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + Rail: model.RailProviderSettlement, + InvokeURI: "grpc://tgsettle-gateway", + IsEnabled: true, + }, + }, + }, + } + + _, err := executor.ExecuteProviderSettlement(context.Background(), sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-2", + IdempotencyKey: "idem-2", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-2", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + }, + QuoteSnapshot: nil, + }, + Step: xplan.Step{ + StepRef: "hop_2_settlement_fx_convert", + StepCode: "hop.2.settlement.fx_convert", + Action: model.RailOperationFXConvert, + Rail: model.RailProviderSettlement, + Gateway: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_2_settlement_fx_convert", + StepCode: "hop.2.settlement.fx_convert", + Attempt: 1, + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "debit amount is required") { + t.Fatalf("unexpected error: %v", err) + } +} -- 2.49.1 From 53abb244822a0e13e97613d017c416ce3a8871a0 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 25 Feb 2026 22:17:12 +0100 Subject: [PATCH 09/23] +ledger ops --- api/billing/documents/go.mod | 2 +- api/billing/documents/go.sum | 4 +- api/billing/fees/go.mod | 2 +- api/billing/fees/go.sum | 4 +- api/discovery/go.mod | 2 +- api/discovery/go.sum | 4 +- api/fx/ingestor/go.mod | 2 +- api/fx/ingestor/go.sum | 4 +- api/fx/oracle/go.mod | 2 +- api/fx/oracle/go.sum | 4 +- api/gateway/chain/go.mod | 4 +- api/gateway/chain/go.sum | 8 +- api/gateway/mntx/go.mod | 2 +- api/gateway/mntx/go.sum | 4 +- api/gateway/tgsettle/go.mod | 2 +- api/gateway/tgsettle/go.sum | 4 +- api/gateway/tron/go.mod | 4 +- api/gateway/tron/go.sum | 8 +- api/ledger/go.mod | 2 +- api/ledger/go.sum | 4 +- api/notification/go.mod | 2 +- api/notification/go.sum | 4 +- api/payments/methods/go.mod | 2 +- api/payments/methods/go.sum | 4 +- api/payments/orchestrator/go.mod | 2 +- api/payments/orchestrator/go.sum | 4 +- .../orchestrator/card_payout_executor.go | 419 ++++++++++++++++++ .../orchestrator/card_payout_executor_test.go | 181 ++++++++ .../service/orchestrator/external_runtime.go | 8 + .../orchestrator/external_runtime_test.go | 67 +++ .../service/orchestrator/ledger_executor.go | 371 ++++++++++++++++ .../orchestrator/ledger_executor_test.go | 275 ++++++++++++ .../internal/service/orchestrator/options.go | 22 +- .../internal/service/orchestrator/service.go | 6 + .../service/orchestrator/service_v2.go | 55 ++- .../service/orchestrator/service_v2_test.go | 83 ++++ api/payments/quotation/go.mod | 2 +- api/payments/quotation/go.sum | 4 +- api/pkg/go.mod | 2 +- api/pkg/go.sum | 4 +- api/server/go.mod | 2 +- api/server/go.sum | 4 +- 42 files changed, 1521 insertions(+), 74 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index 18d3f1da..8fb17fef 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -53,7 +53,7 @@ require ( 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/prometheus/procfs v0.20.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index 0fd9262f..b814da1f 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -158,8 +158,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index 5239fa89..83fe0602 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -38,7 +38,7 @@ require ( github.com/prometheus/client_golang v1.23.2 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/prometheus/procfs v0.20.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index d26defcf..49be6d17 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/discovery/go.mod b/api/discovery/go.mod index 92250bfc..d37352a2 100644 --- a/api/discovery/go.mod +++ b/api/discovery/go.mod @@ -30,7 +30,7 @@ require ( 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/prometheus/procfs v0.20.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/discovery/go.sum b/api/discovery/go.sum index d26defcf..49be6d17 100644 --- a/api/discovery/go.sum +++ b/api/discovery/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index 9cf1a260..1b20b7ad 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -35,7 +35,7 @@ require ( 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/prometheus/procfs v0.20.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index d26defcf..49be6d17 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/fx/oracle/go.mod b/api/fx/oracle/go.mod index c61aa700..f9beac4f 100644 --- a/api/fx/oracle/go.mod +++ b/api/fx/oracle/go.mod @@ -36,7 +36,7 @@ require ( 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/prometheus/procfs v0.20.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/fx/oracle/go.sum b/api/fx/oracle/go.sum index d26defcf..49be6d17 100644 --- a/api/fx/oracle/go.sum +++ b/api/fx/oracle/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 571c7048..20c8580b 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -25,7 +25,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect @@ -67,7 +67,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // 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/prometheus/procfs v0.20.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/supranational/blst v0.3.16 // indirect diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index fdd2e6f0..cb72ee0f 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 h1:kLumy+keYsmuByIG8/G7Iay1vGCd1/WBq8a3vvPJWTM= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc h1:1stW1OipdBj8Me+nj26SzT8yil7OYve0r3cWobzk1JQ= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -236,8 +236,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index 9bff5860..2dbc6da0 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -37,7 +37,7 @@ require ( 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/prometheus/procfs v0.20.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index 7ad1510d..e8deda76 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= 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 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod index ac37d177..6207326f 100644 --- a/api/gateway/tgsettle/go.mod +++ b/api/gateway/tgsettle/go.mod @@ -36,7 +36,7 @@ require ( github.com/prometheus/client_golang v1.23.2 // 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/prometheus/procfs v0.20.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum index d26defcf..49be6d17 100644 --- a/api/gateway/tgsettle/go.sum +++ b/api/gateway/tgsettle/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index fa2176fb..e863f6ff 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -27,7 +27,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect @@ -73,7 +73,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // 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/prometheus/procfs v0.20.0 // indirect github.com/rjeczalik/notify v0.9.3 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index 7fc500da..d5911ef0 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63 h1:kLumy+keYsmuByIG8/G7Iay1vGCd1/WBq8a3vvPJWTM= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260224171832-d0e24df9ee63/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc h1:1stW1OipdBj8Me+nj26SzT8yil7OYve0r3cWobzk1JQ= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -245,8 +245,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/api/ledger/go.mod b/api/ledger/go.mod index dc77f0a6..0eef72e4 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -37,7 +37,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // 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/prometheus/procfs v0.20.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/ledger/go.sum b/api/ledger/go.sum index 4c6fc8f0..531bd1b6 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/notification/go.mod b/api/notification/go.mod index de5a8c30..530e0bac 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -37,7 +37,7 @@ require ( github.com/prometheus/client_golang v1.23.2 // 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/prometheus/procfs v0.20.0 // indirect github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/api/notification/go.sum b/api/notification/go.sum index 2a3943ad..dd4b4809 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -119,8 +119,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= diff --git a/api/payments/methods/go.mod b/api/payments/methods/go.mod index be3b733e..b167b07a 100644 --- a/api/payments/methods/go.mod +++ b/api/payments/methods/go.mod @@ -36,7 +36,7 @@ require ( github.com/prometheus/client_golang v1.23.2 // 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/prometheus/procfs v0.20.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect diff --git a/api/payments/methods/go.sum b/api/payments/methods/go.sum index 4c6fc8f0..531bd1b6 100644 --- a/api/payments/methods/go.sum +++ b/api/payments/methods/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index be14e998..b9fde247 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -51,7 +51,7 @@ require ( github.com/prometheus/client_golang v1.23.2 // 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/prometheus/procfs v0.20.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index e564a3ee..a1fcaf96 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go new file mode 100644 index 00000000..5315039c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go @@ -0,0 +1,419 @@ +package orchestrator + +import ( + "context" + "strconv" + "strings" + + "github.com/shopspring/decimal" + mntxclient "github.com/tech/sendico/gateway/mntx/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "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" +) + +type gatewayCardPayoutExecutor struct { + mntxClient mntxclient.Client +} + +type cardPayoutCustomer struct { + id string + firstName string + middleName string + lastName string + ip string + zip string + country string + state string + city string + address string +} + +func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if req.Payment == nil { + return nil, merrors.InvalidArgument("card payout send: payment is required") + } + if e == nil || e.mntxClient == nil { + return nil, merrors.InvalidArgument("card payout send: mntx client is required") + } + if model.ParseRailOperation(string(req.Step.Action)) != model.RailOperationSend { + return nil, merrors.InvalidArgument("card payout send: unsupported action") + } + + card, err := payoutDestinationCard(req.Payment) + if err != nil { + return nil, err + } + amountMinor, currency, err := cardPayoutAmountMinor(req.Payment) + if err != nil { + return nil, err + } + + stepToken := cardPayoutStepToken(req.Step) + operationRef := cardPayoutOperationRef(req.Payment, stepToken) + payoutRef := cardPayoutRef(req.Payment, stepToken) + idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken) + projectID := cardPayoutProjectID(req.Payment) + customer := cardPayoutCustomerFromPayment(req.Payment, card) + cardHolder := cardPayoutCardholder(card, customer) + metadata := cardPayoutMetadata(req.Payment, req.Step) + intentRef := strings.TrimSpace(req.Payment.IntentSnapshot.Ref) + + var responsePayout *mntxv1.CardPayoutState + if token := strings.TrimSpace(card.Token); token != "" { + resp, createErr := e.mntxClient.CreateCardTokenPayout(ctx, &mntxv1.CardTokenPayoutRequest{ + PayoutId: payoutRef, + ProjectId: projectID, + CustomerId: customer.id, + CustomerFirstName: customer.firstName, + CustomerMiddleName: customer.middleName, + CustomerLastName: customer.lastName, + CustomerIp: customer.ip, + CustomerZip: customer.zip, + CustomerCountry: customer.country, + CustomerState: customer.state, + CustomerCity: customer.city, + CustomerAddress: customer.address, + AmountMinor: amountMinor, + Currency: currency, + CardToken: token, + CardHolder: cardHolder, + MaskedPan: strings.TrimSpace(card.MaskedPan), + Metadata: metadata, + OperationRef: operationRef, + IdempotencyKey: idempotencyKey, + IntentRef: intentRef, + }) + if createErr != nil { + return nil, createErr + } + if resp == nil { + return nil, merrors.Internal("card payout send: card token payout response is missing") + } + responsePayout = resp.GetPayout() + } else { + pan := strings.TrimSpace(card.Pan) + if pan == "" { + return nil, merrors.InvalidArgument("card payout send: card pan is required") + } + if card.ExpMonth == 0 || card.ExpYear == 0 { + return nil, merrors.InvalidArgument("card payout send: card expiry is required") + } + resp, createErr := e.mntxClient.CreateCardPayout(ctx, &mntxv1.CardPayoutRequest{ + PayoutId: payoutRef, + ProjectId: projectID, + CustomerId: customer.id, + CustomerFirstName: customer.firstName, + CustomerMiddleName: customer.middleName, + CustomerLastName: customer.lastName, + CustomerIp: customer.ip, + CustomerZip: customer.zip, + CustomerCountry: customer.country, + CustomerState: customer.state, + CustomerCity: customer.city, + CustomerAddress: customer.address, + AmountMinor: amountMinor, + Currency: currency, + CardPan: pan, + CardExpYear: card.ExpYear, + CardExpMonth: card.ExpMonth, + CardHolder: cardHolder, + Metadata: metadata, + OperationRef: operationRef, + IdempotencyKey: idempotencyKey, + IntentRef: intentRef, + }) + if createErr != nil { + return nil, createErr + } + if resp == nil { + return nil, merrors.Internal("card payout send: card payout response is missing") + } + responsePayout = resp.GetPayout() + } + + resolvedPayoutRef := firstNonEmpty(strings.TrimSpace(responsePayout.GetPayoutId()), payoutRef) + resolvedOperationRef := firstNonEmpty(strings.TrimSpace(responsePayout.GetOperationRef()), operationRef) + gatewayInstanceID := firstNonEmpty(strings.TrimSpace(req.Step.InstanceID), strings.TrimSpace(req.Step.Gateway)) + externalRefs, refsErr := cardPayoutExternalRefs(resolvedPayoutRef, resolvedOperationRef, gatewayInstanceID) + if refsErr != nil { + return nil, refsErr + } + + step := req.StepExecution + step.State = agg.StepStateCompleted + step.ExternalRefs = externalRefs + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{StepExecution: step}, nil +} + +func payoutDestinationCard(payment *agg.Payment) (*model.CardEndpoint, error) { + if payment == nil { + return nil, merrors.InvalidArgument("card payout send: payment is required") + } + destination := payment.IntentSnapshot.Destination + if destination.Type != model.EndpointTypeCard || destination.Card == nil { + return nil, merrors.InvalidArgument("card payout send: destination card is required") + } + return destination.Card, nil +} + +func cardPayoutMoney(payment *agg.Payment) *paymenttypes.Money { + if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.ExpectedSettlementAmount != nil { + return payment.QuoteSnapshot.ExpectedSettlementAmount + } + if payment == nil { + return nil + } + return payment.IntentSnapshot.Amount +} + +func cardPayoutAmountMinor(payment *agg.Payment) (int64, string, error) { + money := cardPayoutMoney(payment) + if money == nil { + return 0, "", merrors.InvalidArgument("card payout send: payout amount is required") + } + amountText := strings.TrimSpace(money.GetAmount()) + currency := strings.ToUpper(strings.TrimSpace(money.GetCurrency())) + if idx := strings.Index(currency, "-"); idx > 0 { + currency = currency[:idx] + } + if amountText == "" || currency == "" { + return 0, "", merrors.InvalidArgument("card payout send: payout amount is invalid") + } + + value, err := decimal.NewFromString(amountText) + if err != nil || !value.IsPositive() { + return 0, "", merrors.InvalidArgument("card payout send: payout amount is invalid") + } + minor := value.Mul(decimal.NewFromInt(100)) + if !minor.Equal(minor.Truncate(0)) { + return 0, "", merrors.InvalidArgument("card payout send: payout amount supports at most 2 fractional digits") + } + return minor.IntPart(), currency, nil +} + +func cardPayoutStepToken(step xplan.Step) string { + return firstNonEmpty(strings.TrimSpace(step.StepRef), strings.TrimSpace(step.StepCode), "card_payout") +} + +func cardPayoutOperationRef(payment *agg.Payment, stepToken string) string { + base := "" + if payment != nil { + base = firstNonEmpty(strings.TrimSpace(payment.PaymentRef), strings.TrimSpace(payment.IdempotencyKey)) + } + return joinRef(base, stepToken) +} + +func cardPayoutRef(payment *agg.Payment, stepToken string) string { + base := "" + if payment != nil { + base = firstNonEmpty(strings.TrimSpace(payment.PaymentRef), strings.TrimSpace(payment.IdempotencyKey), "card_payout") + } + return joinRef(base, stepToken) +} + +func cardPayoutIdempotencyKey(payment *agg.Payment, stepToken string) string { + base := "" + if payment != nil { + base = strings.TrimSpace(payment.IdempotencyKey) + if base == "" { + base = strings.TrimSpace(payment.PaymentRef) + } + } + if base == "" { + base = "card_payout" + } + return joinRef(base, stepToken) +} + +func joinRef(base, suffix string) string { + base = strings.TrimSpace(base) + suffix = strings.TrimSpace(suffix) + switch { + case base == "": + return suffix + case suffix == "": + return base + default: + return base + ":" + suffix + } +} + +func cardPayoutProjectID(payment *agg.Payment) int64 { + if payment == nil { + return 0 + } + raw := cardPayoutAttribute(payment.IntentSnapshot.Attributes, "project_id", "projectId") + if raw == "" { + return 0 + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value < 0 { + return 0 + } + return value +} + +func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoint) cardPayoutCustomer { + customer := cardPayoutCustomer{} + if payment == nil { + return customer + } + + cardholder := "" + cardholderSurname := "" + cardCountry := "" + if card != nil { + cardholder = strings.TrimSpace(card.Cardholder) + cardholderSurname = strings.TrimSpace(card.CardholderSurname) + cardCountry = strings.ToUpper(strings.TrimSpace(card.Country)) + } + attrs := payment.IntentSnapshot.Attributes + intentCustomer := payment.IntentSnapshot.Customer + if intentCustomer != nil { + customer.id = strings.TrimSpace(intentCustomer.ID) + customer.firstName = strings.TrimSpace(intentCustomer.FirstName) + customer.middleName = strings.TrimSpace(intentCustomer.MiddleName) + customer.lastName = strings.TrimSpace(intentCustomer.LastName) + customer.ip = strings.TrimSpace(intentCustomer.IP) + customer.zip = strings.TrimSpace(intentCustomer.Zip) + customer.country = strings.ToUpper(strings.TrimSpace(intentCustomer.Country)) + customer.state = strings.TrimSpace(intentCustomer.State) + customer.city = strings.TrimSpace(intentCustomer.City) + customer.address = strings.TrimSpace(intentCustomer.Address) + } + + customer.id = firstNonEmpty(customer.id, + cardPayoutAttribute(attrs, "customer_id", "customerId", "initiator_ref", "initiatorRef"), + strings.TrimSpace(payment.PaymentRef), + strings.TrimSpace(payment.IdempotencyKey), + "unknown_customer") + customer.firstName = firstNonEmpty(customer.firstName, cardholder, "UNKNOWN") + customer.middleName = firstNonEmpty(customer.middleName, cardPayoutAttribute(attrs, "customer_middle_name", "customerMiddleName")) + customer.lastName = firstNonEmpty(customer.lastName, cardholderSurname, "UNKNOWN") + customer.ip = firstNonEmpty(customer.ip, + cardPayoutAttribute(attrs, "customer_ip", "customerIp", "ip", "ip_address", "ipAddress"), + "0.0.0.0") + customer.zip = firstNonEmpty(customer.zip, cardPayoutAttribute(attrs, "customer_zip", "customerZip")) + customer.country = firstNonEmpty(customer.country, cardCountry, cardPayoutAttribute(attrs, "customer_country", "customerCountry")) + customer.state = firstNonEmpty(customer.state, cardPayoutAttribute(attrs, "customer_state", "customerState")) + customer.city = firstNonEmpty(customer.city, cardPayoutAttribute(attrs, "customer_city", "customerCity")) + customer.address = firstNonEmpty(customer.address, cardPayoutAttribute(attrs, "customer_address", "customerAddress")) + + return customer +} + +func cardPayoutCardholder(card *model.CardEndpoint, customer cardPayoutCustomer) string { + holder := "" + if card != nil { + holder = strings.TrimSpace(card.Cardholder) + surname := strings.TrimSpace(card.CardholderSurname) + if holder != "" && surname != "" && !strings.Contains(strings.ToLower(holder), strings.ToLower(surname)) { + holder = holder + " " + surname + } + } + if holder == "" { + holder = strings.TrimSpace(firstNonEmpty(spaceJoin(customer.firstName, customer.lastName), customer.firstName, customer.lastName)) + } + if holder == "" { + holder = "UNKNOWN" + } + return holder +} + +func spaceJoin(values ...string) string { + parts := make([]string, 0, len(values)) + for i := range values { + item := strings.TrimSpace(values[i]) + if item == "" { + continue + } + parts = append(parts, item) + } + return strings.Join(parts, " ") +} + +func cardPayoutAttribute(attrs map[string]string, keys ...string) string { + if len(attrs) == 0 { + return "" + } + for i := range keys { + key := strings.TrimSpace(keys[i]) + if key == "" { + continue + } + if value, ok := attrs[key]; ok { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + for attrKey, value := range attrs { + if !strings.EqualFold(strings.TrimSpace(attrKey), key) { + continue + } + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + } + return "" +} + +func cardPayoutMetadata(payment *agg.Payment, step xplan.Step) map[string]string { + out := transferMetadata(step) + if out == nil { + out = map[string]string{} + } + if payment != nil { + if quoteRef := firstNonEmpty( + strings.TrimSpace(payment.QuotationRef), + strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot)), + ); quoteRef != "" { + out[settlementMetadataQuoteRef] = quoteRef + } + } + if outgoingLeg := strings.TrimSpace(string(step.Rail)); outgoingLeg != "" { + out[settlementMetadataOutgoingLeg] = outgoingLeg + } + if len(out) == 0 { + return nil + } + return out +} + +func cardPayoutExternalRefs(payoutRef, operationRef, gatewayInstanceID string) ([]agg.ExternalRef, error) { + gatewayInstanceID = strings.TrimSpace(gatewayInstanceID) + refs := make([]agg.ExternalRef, 0, 3) + if operationRef = strings.TrimSpace(operationRef); operationRef != "" { + refs = append(refs, agg.ExternalRef{ + GatewayInstanceID: gatewayInstanceID, + Kind: erecon.ExternalRefKindOperation, + Ref: operationRef, + }) + } + if payoutRef = strings.TrimSpace(payoutRef); payoutRef != "" { + refs = append(refs, agg.ExternalRef{ + GatewayInstanceID: gatewayInstanceID, + Kind: erecon.ExternalRefKindTransfer, + Ref: payoutRef, + }) + refs = append(refs, agg.ExternalRef{ + GatewayInstanceID: gatewayInstanceID, + Kind: erecon.ExternalRefKindCardPayout, + Ref: payoutRef, + }) + } + if len(refs) == 0 { + return nil, merrors.Internal("card payout send: payout response does not contain references") + } + return refs, nil +} + +var _ sexec.CardPayoutExecutor = (*gatewayCardPayoutExecutor)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go new file mode 100644 index 00000000..87e82f15 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go @@ -0,0 +1,181 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + mntxclient "github.com/tech/sendico/gateway/mntx/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + pm "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testing.T) { + orgID := bson.NewObjectID() + + var payoutReq *mntxv1.CardPayoutRequest + executor := &gatewayCardPayoutExecutor{ + mntxClient: &mntxclient.Fake{ + CreateCardPayoutFn: func(_ context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + payoutReq = req + return &mntxv1.CardPayoutResponse{ + Payout: &mntxv1.CardPayoutState{ + PayoutId: "payout-remote-1", + }, + }, nil + }, + }, + } + + req := sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-1", + IdempotencyKey: "idem-1", + QuotationRef: "quote-1", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-1", + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "2200700142860161", + Cardholder: "Stephan", + CardholderSurname: "Deshevikh", + ExpMonth: 3, + ExpYear: 2030, + }, + }, + Customer: &model.Customer{ + ID: "cust-1", + FirstName: "Stephan", + LastName: "Deshevikh", + IP: "198.51.100.10", + }, + Amount: &paymenttypes.Money{ + Amount: "1.000000", + Currency: "USDT", + }, + Attributes: map[string]string{ + "initiator_ref": "user-123", + }, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + ExpectedSettlementAmount: &paymenttypes.Money{ + Amount: "76.50", + Currency: "RUB", + }, + QuoteRef: "quote-1", + }, + }, + Step: xplan.Step{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Action: model.RailOperationSend, + Rail: model.RailCardPayout, + Gateway: "monetix", + InstanceID: "monetix", + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Attempt: 1, + }, + } + + out, err := executor.ExecuteCardPayout(context.Background(), req) + if err != nil { + t.Fatalf("ExecuteCardPayout returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if out.StepExecution.State != agg.StepStateCompleted { + t.Fatalf("expected completed state, got=%q", out.StepExecution.State) + } + if payoutReq == nil { + t.Fatal("expected payout request to be submitted") + } + if got, want := payoutReq.GetPayoutId(), "payment-1:hop_4_card_payout_send"; got != want { + t.Fatalf("payout_id mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReq.GetOperationRef(), "payment-1:hop_4_card_payout_send"; got != want { + t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReq.GetIdempotencyKey(), "idem-1:hop_4_card_payout_send"; got != want { + t.Fatalf("idempotency mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReq.GetAmountMinor(), int64(7650); got != want { + t.Fatalf("amount_minor mismatch: got=%d want=%d", got, want) + } + if got, want := payoutReq.GetCurrency(), "RUB"; got != want { + t.Fatalf("currency mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReq.GetMetadata()[settlementMetadataQuoteRef], "quote-1"; got != want { + t.Fatalf("quote_ref metadata mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReq.GetMetadata()[settlementMetadataOutgoingLeg], string(model.RailCardPayout); got != want { + t.Fatalf("outgoing_leg metadata mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 3 { + t.Fatalf("expected 3 external refs, got=%d", len(out.StepExecution.ExternalRefs)) + } + if out.StepExecution.ExternalRefs[0].Kind != erecon.ExternalRefKindOperation { + t.Fatalf("expected first external ref to be operation, got=%q", out.StepExecution.ExternalRefs[0].Kind) + } + if out.StepExecution.ExternalRefs[1].Kind != erecon.ExternalRefKindTransfer { + t.Fatalf("expected second external ref to be transfer, got=%q", out.StepExecution.ExternalRefs[1].Kind) + } + if out.StepExecution.ExternalRefs[2].Kind != erecon.ExternalRefKindCardPayout { + t.Fatalf("expected third external ref to be card payout, got=%q", out.StepExecution.ExternalRefs[2].Kind) + } + if got, want := out.StepExecution.ExternalRefs[1].Ref, "payout-remote-1"; got != want { + t.Fatalf("transfer_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestGatewayCardPayoutExecutor_ExecuteCardPayout_RequiresMntxClient(t *testing.T) { + orgID := bson.NewObjectID() + + executor := &gatewayCardPayoutExecutor{} + _, err := executor.ExecuteCardPayout(context.Background(), sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-2", + IntentSnapshot: model.PaymentIntent{ + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "4111111111111111", + ExpMonth: 3, + ExpYear: 2030, + }, + }, + Amount: &paymenttypes.Money{Amount: "10", Currency: "RUB"}, + }, + }, + Step: xplan.Step{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Action: model.RailOperationSend, + Rail: model.RailCardPayout, + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Attempt: 1, + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "mntx client is required") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go index 6bebc067..bc120c0a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go @@ -192,6 +192,9 @@ func matchExecutionStep(payment *agg.Payment, msg *pmodel.PaymentGatewayExecutio if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindTransfer, transferRef); ok { return stepRef, gatewayInstanceID } + if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindCardPayout, transferRef); ok { + return stepRef, gatewayInstanceID + } } operationRef := strings.TrimSpace(msg.OperationRef) @@ -361,6 +364,11 @@ func buildObserveCandidate(step agg.StepExecution) (runningObserveCandidate, boo candidate.transferRef = value candidate.gatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID) } + case strings.EqualFold(kind, erecon.ExternalRefKindCardPayout): + if candidate.transferRef == "" { + candidate.transferRef = value + candidate.gatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID) + } case strings.EqualFold(kind, erecon.ExternalRefKindOperation): if candidate.operationRef == "" { candidate.operationRef = value diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go index 6c253042..28e8a8cd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go @@ -96,6 +96,43 @@ func TestBuildGatewayExecutionEvent_FailedSetsTerminalNeedsAttentionHint(t *test } } +func TestBuildGatewayExecutionEvent_MatchesCardObserveByCardPayoutRef(t *testing.T) { + orgID := bson.NewObjectID() + payment := &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-card-1", + StepExecutions: []agg.StepExecution{ + { + StepRef: "hop_4_card_payout_observe", + StepCode: "hop.4.card_payout.observe", + State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{ + { + GatewayInstanceID: "monetix", + Kind: erecon.ExternalRefKindCardPayout, + Ref: "payout-1", + }, + }, + }, + }, + } + + event, ok := buildGatewayExecutionEvent(payment, &pm.PaymentGatewayExecution{ + PaymentRef: payment.PaymentRef, + Status: rail.OperationResultSuccess, + TransferRef: "payout-1", + }) + if !ok { + t.Fatal("expected gateway execution event to be accepted") + } + if got, want := event.StepRef, "hop_4_card_payout_observe"; got != want { + t.Fatalf("step_ref mismatch: got=%q want=%q", got, want) + } + if got, want := event.GatewayInstanceID, "monetix"; got != want { + t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want) + } +} + func TestOnPaymentGatewayExecution_ReconcilesUsingGlobalPaymentLookup(t *testing.T) { orgID := bson.NewObjectID() payment := &agg.Payment{ @@ -273,6 +310,36 @@ func TestRunningObserveCandidates(t *testing.T) { } } +func TestRunningObserveCandidates_UsesCardPayoutRefAsTransfer(t *testing.T) { + payment := &agg.Payment{ + StepExecutions: []agg.StepExecution{ + { + StepRef: "hop_4_card_payout_observe", + StepCode: "hop.4.card_payout.observe", + State: agg.StepStateRunning, + ExternalRefs: []agg.ExternalRef{ + { + GatewayInstanceID: "monetix", + Kind: erecon.ExternalRefKindCardPayout, + Ref: "payout-2", + }, + }, + }, + }, + } + + candidates := runningObserveCandidates(payment) + if len(candidates) != 1 { + t.Fatalf("candidate count mismatch: got=%d want=1", len(candidates)) + } + if got, want := candidates[0].transferRef, "payout-2"; got != want { + t.Fatalf("transfer_ref mismatch: got=%q want=%q", got, want) + } + if got, want := candidates[0].gatewayInstanceID, "monetix"; got != want { + t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want) + } +} + func TestResolveObserveGateway_UsesExternalRefGatewayInstanceAcrossRails(t *testing.T) { svc := &Service{ gatewayRegistry: &fakeGatewayRegistry{ diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go new file mode 100644 index 00000000..6cb6ff77 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go @@ -0,0 +1,371 @@ +package orchestrator + +import ( + "context" + "fmt" + "strings" + + ledgerclient "github.com/tech/sendico/ledger/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "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" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" +) + +const ( + ledgerMetadataMode = "mode" +) + +type gatewayLedgerExecutor struct { + ledgerClient ledgerclient.Client +} + +type ledgerRoles struct { + from account_role.AccountRole + to account_role.AccountRole +} + +func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if req.Payment == nil { + return nil, merrors.InvalidArgument("ledger step: payment is required") + } + if e == nil || e.ledgerClient == nil { + return nil, merrors.InvalidArgument("ledger step: ledger client is required") + } + + action := model.ParseRailOperation(string(req.Step.Action)) + switch action { + case model.RailOperationDebit, + model.RailOperationCredit, + model.RailOperationExternalDebit, + model.RailOperationExternalCredit, + model.RailOperationMove, + model.RailOperationBlock, + model.RailOperationRelease: + default: + return nil, merrors.InvalidArgument("ledger step: unsupported action") + } + + amount, err := ledgerAmountForStep(req.Payment, req.Step, action) + if err != nil { + return nil, err + } + roles, err := ledgerRolesForStep(req.Step, action) + if err != nil { + return nil, err + } + + transferReq := &ledgerv1.TransferRequest{ + IdempotencyKey: ledgerStepIdempotencyKey(req.Payment, req.Step), + OrganizationRef: req.Payment.OrganizationRef.Hex(), + Money: amount, + Description: ledgerDescription(req.Step), + Metadata: ledgerTransferMetadata(req.Payment, req.Step, roles), + FromRole: ledgerRoleToProto(roles.from), + ToRole: ledgerRoleToProto(roles.to), + } + + resp, err := e.ledgerClient.TransferInternal(ctx, transferReq) + if err != nil { + return nil, err + } + if resp == nil || strings.TrimSpace(resp.GetJournalEntryRef()) == "" { + return nil, merrors.Internal("ledger step: journal entry reference is missing") + } + + step := req.StepExecution + step.State = agg.StepStateCompleted + step.FailureCode = "" + step.FailureMsg = "" + step.ExternalRefs = appendLedgerExternalRef(step.ExternalRefs, agg.ExternalRef{ + GatewayInstanceID: firstNonEmpty(strings.TrimSpace(req.Step.InstanceID), strings.TrimSpace(req.Step.Gateway)), + Kind: erecon.ExternalRefKindLedger, + Ref: strings.TrimSpace(resp.GetJournalEntryRef()), + }) + + return &sexec.ExecuteOutput{StepExecution: step}, nil +} + +func ledgerAmountForStep( + payment *agg.Payment, + step xplan.Step, + action model.RailOperation, +) (*moneyv1.Money, error) { + sourceMoney := sourceMoneyForLedger(payment) + settlementMoney := settlementMoneyForLedger(payment, sourceMoney) + payoutMoney := payoutMoneyForLedger(payment, settlementMoney) + + if fromRail, toRail, ok := ledgerBoundaryRails(payment, step); ok { + switch { + case isLedgerExternalRail(fromRail) && isLedgerExternalRail(toRail): + return protoMoneyRequired(sourceMoney, "ledger step: source amount is required") + case isLedgerExternalRail(fromRail) && isLedgerInternalRail(toRail): + return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required") + case isLedgerInternalRail(fromRail) && isLedgerExternalRail(toRail): + if toRail == model.RailCardPayout { + return protoMoneyRequired(payoutMoney, "ledger step: payout amount is required") + } + return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required") + case isLedgerInternalRail(fromRail) && isLedgerInternalRail(toRail): + return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required") + } + } + + switch action { + case model.RailOperationCredit, model.RailOperationExternalCredit: + return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required") + case model.RailOperationDebit, model.RailOperationExternalDebit: + if sourceMoney != nil { + return protoMoneyRequired(sourceMoney, "ledger step: source amount is required") + } + return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required") + case model.RailOperationMove, model.RailOperationBlock, model.RailOperationRelease: + if settlementMoney != nil { + return protoMoneyRequired(settlementMoney, "ledger step: settlement amount is required") + } + return protoMoneyRequired(sourceMoney, "ledger step: source amount is required") + default: + return nil, merrors.InvalidArgument("ledger step: unsupported action") + } +} + +func sourceMoneyForLedger(payment *agg.Payment) *paymenttypes.Money { + if payment == nil { + return nil + } + if payment.QuoteSnapshot != nil && payment.QuoteSnapshot.DebitAmount != nil { + return payment.QuoteSnapshot.DebitAmount + } + return payment.IntentSnapshot.Amount +} + +func settlementMoneyForLedger(payment *agg.Payment, source *paymenttypes.Money) *paymenttypes.Money { + if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.ExpectedSettlementAmount != nil { + return payment.QuoteSnapshot.ExpectedSettlementAmount + } + return source +} + +func payoutMoneyForLedger(_ *agg.Payment, settlement *paymenttypes.Money) *paymenttypes.Money { + return settlement +} + +func protoMoneyRequired(m *paymenttypes.Money, errMsg string) (*moneyv1.Money, error) { + if m == nil { + return nil, merrors.InvalidArgument(errMsg) + } + amount := strings.TrimSpace(m.GetAmount()) + currency := strings.TrimSpace(m.GetCurrency()) + if amount == "" || currency == "" { + return nil, merrors.InvalidArgument(errMsg) + } + return &moneyv1.Money{Amount: amount, Currency: currency}, nil +} + +func ledgerRolesForStep(step xplan.Step, action model.RailOperation) (ledgerRoles, error) { + roles, ok, err := ledgerRolesFromMetadata(step.Metadata) + if err != nil { + return ledgerRoles{}, err + } + if ok { + return roles, nil + } + + mode := strings.ToLower(strings.TrimSpace(step.Metadata[ledgerMetadataMode])) + switch action { + case model.RailOperationBlock: + return ledgerRoles{from: account_role.AccountRoleOperating, to: account_role.AccountRoleHold}, nil + case model.RailOperationRelease: + return ledgerRoles{from: account_role.AccountRoleHold, to: account_role.AccountRoleOperating}, nil + case model.RailOperationCredit, model.RailOperationExternalCredit: + return ledgerRoles{from: account_role.AccountRolePending, to: account_role.AccountRoleOperating}, nil + case model.RailOperationDebit, model.RailOperationExternalDebit: + if mode == "finalize_debit" { + return ledgerRoles{from: account_role.AccountRoleHold, to: account_role.AccountRoleTransit}, nil + } + return ledgerRoles{from: account_role.AccountRoleOperating, to: account_role.AccountRoleTransit}, nil + case model.RailOperationMove: + return ledgerRoles{from: account_role.AccountRoleOperating, to: account_role.AccountRoleTransit}, nil + default: + return ledgerRoles{}, merrors.InvalidArgument("ledger step: unsupported action") + } +} + +func ledgerRolesFromMetadata(metadata map[string]string) (ledgerRoles, bool, error) { + fromValue, fromFound := metadataLookup(metadata, "from_role", "fromRole") + toValue, toFound := metadataLookup(metadata, "to_role", "toRole") + if !fromFound && !toFound { + return ledgerRoles{}, false, nil + } + if strings.TrimSpace(fromValue) == "" || strings.TrimSpace(toValue) == "" { + return ledgerRoles{}, false, merrors.InvalidArgument("ledger step: from_role and to_role must both be provided") + } + + fromRole, ok := account_role.Parse(fromValue) + if !ok || fromRole == "" { + return ledgerRoles{}, false, merrors.InvalidArgument("ledger step: invalid from_role") + } + toRole, ok := account_role.Parse(toValue) + if !ok || toRole == "" { + return ledgerRoles{}, false, merrors.InvalidArgument("ledger step: invalid to_role") + } + if fromRole == toRole { + return ledgerRoles{}, false, merrors.InvalidArgument("ledger step: from_role and to_role must differ") + } + return ledgerRoles{from: fromRole, to: toRole}, true, nil +} + +func metadataLookup(metadata map[string]string, keys ...string) (string, bool) { + if len(metadata) == 0 { + return "", false + } + for i := range keys { + key := strings.TrimSpace(keys[i]) + if key == "" { + continue + } + value, ok := metadata[key] + if !ok { + continue + } + return value, true + } + return "", false +} + +func ledgerRoleToProto(role account_role.AccountRole) ledgerv1.AccountRole { + parsed, _ := ledgerconv.ParseAccountRole(string(role)) + return parsed +} + +func ledgerTransferMetadata(payment *agg.Payment, step xplan.Step, roles ledgerRoles) map[string]string { + out := transferMetadata(step) + if out == nil { + out = map[string]string{} + } + if quoteRef := firstNonEmpty(strings.TrimSpace(payment.QuotationRef), strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot))); quoteRef != "" { + out[settlementMetadataQuoteRef] = quoteRef + } + if roles.from != "" { + out[account_role.MetadataKeyFromRole] = string(roles.from) + } + if roles.to != "" { + out[account_role.MetadataKeyToRole] = string(roles.to) + } + if mode := strings.TrimSpace(step.Metadata[ledgerMetadataMode]); mode != "" { + out[ledgerMetadataMode] = mode + } + if len(out) == 0 { + return nil + } + return out +} + +func ledgerStepIdempotencyKey(payment *agg.Payment, step xplan.Step) string { + base := strings.TrimSpace(payment.IdempotencyKey) + if base == "" { + base = strings.TrimSpace(payment.PaymentRef) + } + stepToken := firstNonEmpty(strings.TrimSpace(step.StepRef), strings.TrimSpace(step.StepCode), "ledger") + if base == "" { + return "ledger:" + stepToken + } + return base + ":" + stepToken +} + +func ledgerDescription(step xplan.Step) string { + code := strings.TrimSpace(step.StepCode) + if code == "" { + code = strings.TrimSpace(step.StepRef) + } + action := strings.ToLower(strings.TrimSpace(string(step.Action))) + if code == "" { + return "orchestration ledger " + action + } + return fmt.Sprintf("orchestration ledger %s %s", action, code) +} + +func appendLedgerExternalRef(existing []agg.ExternalRef, ref agg.ExternalRef) []agg.ExternalRef { + ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID) + ref.Kind = strings.TrimSpace(ref.Kind) + ref.Ref = strings.TrimSpace(ref.Ref) + if ref.Kind == "" || ref.Ref == "" { + return existing + } + for i := range existing { + item := existing[i] + if strings.EqualFold(strings.TrimSpace(item.GatewayInstanceID), ref.GatewayInstanceID) && + strings.EqualFold(strings.TrimSpace(item.Kind), ref.Kind) && + strings.EqualFold(strings.TrimSpace(item.Ref), ref.Ref) { + return existing + } + } + return append(existing, ref) +} + +func ledgerBoundaryRails(payment *agg.Payment, step xplan.Step) (model.Rail, model.Rail, bool) { + fromIndex, toIndex, ok := parseLedgerEdgeStepCode(step.StepCode) + if !ok || payment == nil || payment.QuoteSnapshot == nil || payment.QuoteSnapshot.Route == nil { + return model.RailUnspecified, model.RailUnspecified, false + } + + fromRail := model.RailUnspecified + toRail := model.RailUnspecified + for i := range payment.QuoteSnapshot.Route.Hops { + hop := payment.QuoteSnapshot.Route.Hops[i] + if hop == nil { + continue + } + if hop.Index == fromIndex { + fromRail = model.ParseRail(hop.Rail) + } + if hop.Index == toIndex { + toRail = model.ParseRail(hop.Rail) + } + } + if fromRail == model.RailUnspecified || toRail == model.RailUnspecified { + return model.RailUnspecified, model.RailUnspecified, false + } + return fromRail, toRail, true +} + +func parseLedgerEdgeStepCode(stepCode string) (uint32, uint32, bool) { + code := strings.ToLower(strings.TrimSpace(stepCode)) + if !strings.HasPrefix(code, "edge.") || !strings.Contains(code, ".ledger.") { + return 0, 0, false + } + var ( + from uint32 + to uint32 + op string + ) + if _, err := fmt.Sscanf(code, "edge.%d_%d.ledger.%s", &from, &to, &op); err != nil { + return 0, 0, false + } + if strings.TrimSpace(op) == "" { + return 0, 0, false + } + return from, to, true +} + +func isLedgerInternalRail(rail model.Rail) bool { + return rail == model.RailLedger +} + +func isLedgerExternalRail(rail model.Rail) bool { + switch rail { + case model.RailCrypto, model.RailProviderSettlement, model.RailCardPayout, model.RailFiatOnRamp: + return true + default: + return false + } +} + +var _ sexec.LedgerExecutor = (*gatewayLedgerExecutor)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go new file mode 100644 index 00000000..37dc7aa4 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go @@ -0,0 +1,275 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + ledgerclient "github.com/tech/sendico/ledger/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + pm "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRoles(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var transferReq *ledgerv1.TransferRequest + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-1"}, nil + }, + }, + } + + out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_1_2_ledger_credit", + StepCode: "edge.1_2.ledger.credit", + Action: model.RailOperationCredit, + Rail: model.RailLedger, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_1_2_ledger_credit", + StepCode: "edge.1_2.ledger.credit", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if transferReq == nil { + t.Fatal("expected ledger transfer request") + } + if got, want := transferReq.GetMoney().GetAmount(), "1.000000"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetMoney().GetCurrency(), "USDT"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING; got != want { + t.Fatalf("from_role mismatch: got=%v want=%v", got, want) + } + if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING; got != want { + t.Fatalf("to_role mismatch: got=%v want=%v", got, want) + } + if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 1 { + t.Fatalf("expected one external ref, got=%d", len(out.StepExecution.ExternalRefs)) + } + if got, want := out.StepExecution.ExternalRefs[0].Kind, erecon.ExternalRefKindLedger; got != want { + t.Fatalf("external ref kind mismatch: got=%q want=%q", got, want) + } + if got, want := out.StepExecution.ExternalRefs[0].Ref, "entry-1"; got != want { + t.Fatalf("external ref value mismatch: got=%q want=%q", got, want) + } +} + +func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSettlementAmount(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var transferReq *ledgerv1.TransferRequest + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-2"}, nil + }, + }, + } + + out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_3_4_ledger_debit", + StepCode: "edge.3_4.ledger.debit", + Action: model.RailOperationDebit, + Rail: model.RailLedger, + Metadata: map[string]string{"mode": "finalize_debit"}, + HopIndex: 4, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + DependsOn: []string{"hop_4_card_payout_observe"}, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_3_4_ledger_debit", + StepCode: "edge.3_4.ledger.debit", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if transferReq == nil { + t.Fatal("expected ledger transfer request") + } + if got, want := transferReq.GetMoney().GetAmount(), "76.5"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetMoney().GetCurrency(), "RUB"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD; got != want { + t.Fatalf("from_role mismatch: got=%v want=%v", got, want) + } + if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT; got != want { + t.Fatalf("to_role mismatch: got=%v want=%v", got, want) + } + if got, want := transferReq.GetMetadata()[ledgerMetadataMode], "finalize_debit"; got != want { + t.Fatalf("mode metadata mismatch: got=%q want=%q", got, want) + } +} + +func TestGatewayLedgerExecutor_ExecuteLedger_UsesMetadataRoleOverrides(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var transferReq *ledgerv1.TransferRequest + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-3"}, nil + }, + }, + } + + _, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_2_3_ledger_credit", + StepCode: "edge.2_3.ledger.credit", + Action: model.RailOperationCredit, + Rail: model.RailLedger, + Metadata: map[string]string{ + "from_role": "reserve", + "to_role": "liquidity", + }, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_2_3_ledger_credit", + StepCode: "edge.2_3.ledger.credit", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if transferReq == nil { + t.Fatal("expected ledger transfer request") + } + if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE; got != want { + t.Fatalf("from_role mismatch: got=%v want=%v", got, want) + } + if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY; got != want { + t.Fatalf("to_role mismatch: got=%v want=%v", got, want) + } +} + +func TestGatewayLedgerExecutor_ExecuteLedger_ValidatesMetadataRoles(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{}, + } + + _, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_2_3_ledger_credit", + StepCode: "edge.2_3.ledger.credit", + Action: model.RailOperationCredit, + Rail: model.RailLedger, + Metadata: map[string]string{ + "from_role": "bad_role", + "to_role": "operating", + }, + }, + StepExecution: agg.StepExecution{StepRef: "edge_2_3_ledger_credit", StepCode: "edge.2_3.ledger.credit", Attempt: 1}, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "invalid from_role") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGatewayLedgerExecutor_ExecuteLedger_RequiresLedgerClient(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + executor := &gatewayLedgerExecutor{} + + _, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_1_2_ledger_credit", + StepCode: "edge.1_2.ledger.credit", + Action: model.RailOperationCredit, + Rail: model.RailLedger, + }, + StepExecution: agg.StepExecution{StepRef: "edge_1_2_ledger_credit", StepCode: "edge.1_2.ledger.credit", Attempt: 1}, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "ledger client is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func testLedgerExecutorPayment(orgID bson.ObjectID) *agg.Payment { + return &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-ledger-1", + IdempotencyKey: "idem-ledger-1", + QuotationRef: "quote-ledger-1", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-ledger-1", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{Pan: "4111111111111111"}, + }, + Amount: &paymenttypes.Money{Amount: "1", Currency: "USDT"}, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"}, + ExpectedSettlementAmount: &paymenttypes.Money{Amount: "76.5", Currency: "RUB"}, + QuoteRef: "quote-ledger-1", + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 2, Rail: "SETTLEMENT", Role: paymenttypes.QuoteRouteHopRoleTransit}, + {Index: 3, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit}, + {Index: 4, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index d2eed75d..f3ec7237 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -44,14 +44,24 @@ func WithFeeEngine(_ feesv1.FeeEngineClient, _ time.Duration) Option { return func(*Service) {} } -// WithLedgerClient is retained for backward-compatible wiring and is currently a no-op. -func WithLedgerClient(_ ledgerclient.Client) Option { - return func(*Service) {} +// WithLedgerClient configures internal ledger execution for ledger-bound steps. +func WithLedgerClient(client ledgerclient.Client) Option { + return func(s *Service) { + if s == nil { + return + } + s.ledgerClient = client + } } -// WithMntxGateway is retained for backward-compatible wiring and is currently a no-op. -func WithMntxGateway(_ mntxclient.Client) Option { - return func(*Service) {} +// WithMntxGateway configures card payout execution for card-bound steps. +func WithMntxGateway(client mntxclient.Client) Option { + return func(s *Service) { + if s == nil { + return + } + s.mntxClient = client + } } // WithPaymentGatewayBroker wires broker subscription for payment gateway execution events. diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 4601d1b0..0d4d1b1b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -3,6 +3,8 @@ package orchestrator import ( "context" + mntxclient "github.com/tech/sendico/gateway/mntx/client" + ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" "github.com/tech/sendico/payments/storage" @@ -22,6 +24,8 @@ type Service struct { v2 psvc.Service paymentRepo prepo.Repository + ledgerClient ledgerclient.Client + mntxClient mntxclient.Client gatewayInvokeResolver GatewayInvokeResolver gatewayRegistry GatewayRegistry cardGatewayRoutes map[string]CardGatewayRoute @@ -49,6 +53,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) var err error svc.v2, svc.paymentRepo, err = newOrchestrationV2Service(svc.logger, repo, v2RuntimeDeps{ + LedgerClient: svc.ledgerClient, + MntxClient: svc.mntxClient, GatewayInvokeResolver: svc.gatewayInvokeResolver, GatewayRegistry: svc.gatewayRegistry, CardGatewayRoutes: svc.cardGatewayRoutes, diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go index 03ed9a66..639b7c9f 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -3,6 +3,8 @@ package orchestrator import ( "context" + mntxclient "github.com/tech/sendico/gateway/mntx/client" + ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" @@ -22,6 +24,8 @@ type v2MongoDBProvider interface { } type v2RuntimeDeps struct { + LedgerClient ledgerclient.Client + MntxClient mntxclient.Client GatewayInvokeResolver GatewayInvokeResolver GatewayRegistry GatewayRegistry CardGatewayRoutes map[string]CardGatewayRoute @@ -34,6 +38,14 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r if repo == nil { return nil, nil, merrors.Internal("No repo for orchestrator v2 provided") } + if runtimeDeps.LedgerClient == nil { + logger.Error("Orchestration v2 disabled: ledger client is missing") + return nil, nil, merrors.Internal("ledger client is required") + } + if checker, ok := runtimeDeps.LedgerClient.(interface{ Available() bool }); ok && !checker.Available() { + logger.Error("Orchestration v2 disabled: ledger client is unavailable") + return nil, nil, merrors.Internal("ledger client is unavailable") + } paymentRepo, err := buildPaymentRepositoryV2(repo, logger) if paymentRepo == nil || err != nil { @@ -72,27 +84,42 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r } func buildOrchestrationV2Executors(logger mlogger.Logger, runtimeDeps v2RuntimeDeps) sexec.Registry { - if runtimeDeps.GatewayInvokeResolver == nil || runtimeDeps.GatewayRegistry == nil { - return nil - } execLogger := logger.Named("v2") - cryptoExecutor := &gatewayCryptoExecutor{ - gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, - gatewayRegistry: runtimeDeps.GatewayRegistry, - cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes), + + var cryptoExecutor sexec.CryptoExecutor + var providerSettlementExecutor sexec.ProviderSettlementExecutor + var guardExecutor sexec.GuardExecutor + if runtimeDeps.GatewayInvokeResolver != nil && runtimeDeps.GatewayRegistry != nil { + cryptoExecutor = &gatewayCryptoExecutor{ + gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, + gatewayRegistry: runtimeDeps.GatewayRegistry, + cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes), + } + providerSettlementExecutor = &gatewayProviderSettlementExecutor{ + gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, + gatewayRegistry: runtimeDeps.GatewayRegistry, + } + guardExecutor = &gatewayGuardExecutor{ + logger: execLogger.Named("guard"), + gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, + gatewayRegistry: runtimeDeps.GatewayRegistry, + } } - providerSettlementExecutor := &gatewayProviderSettlementExecutor{ - gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, - gatewayRegistry: runtimeDeps.GatewayRegistry, + + ledgerExecutor := &gatewayLedgerExecutor{ + ledgerClient: runtimeDeps.LedgerClient, } - guardExecutor := &gatewayGuardExecutor{ - logger: execLogger.Named("guard"), - gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, - gatewayRegistry: runtimeDeps.GatewayRegistry, + var cardPayoutExecutor sexec.CardPayoutExecutor + if runtimeDeps.MntxClient != nil { + cardPayoutExecutor = &gatewayCardPayoutExecutor{ + mntxClient: runtimeDeps.MntxClient, + } } return psvc.NewDefaultExecutors(execLogger, sexec.Dependencies{ + Ledger: ledgerExecutor, Crypto: cryptoExecutor, ProviderSettlement: providerSettlementExecutor, + CardPayout: cardPayoutExecutor, Guard: guardExecutor, }) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go new file mode 100644 index 00000000..f5a61010 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go @@ -0,0 +1,83 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + ledgerclient "github.com/tech/sendico/ledger/client" + "github.com/tech/sendico/payments/storage" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "go.uber.org/zap" +) + +func TestNewOrchestrationV2Service_FailsWhenLedgerClientMissing(t *testing.T) { + svc, repo, err := newOrchestrationV2Service(zap.NewNop(), fakeStorageRepo{}, v2RuntimeDeps{}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "ledger client is required") { + t.Fatalf("unexpected error: %v", err) + } + if svc != nil { + t.Fatal("expected nil service") + } + if repo != nil { + t.Fatal("expected nil payment repo") + } +} + +func TestNewOrchestrationV2Service_FailsWhenLedgerClientUnavailable(t *testing.T) { + ledger := unavailableLedgerClient{Fake: &ledgerclient.Fake{}} + svc, repo, err := newOrchestrationV2Service(zap.NewNop(), fakeStorageRepo{}, v2RuntimeDeps{ + LedgerClient: ledger, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "ledger client is unavailable") { + t.Fatalf("unexpected error: %v", err) + } + if svc != nil { + t.Fatal("expected nil service") + } + if repo != nil { + t.Fatal("expected nil payment repo") + } +} + +type unavailableLedgerClient struct { + *ledgerclient.Fake +} + +func (u unavailableLedgerClient) Available() bool { + return false +} + +type fakeStorageRepo struct{} + +func (fakeStorageRepo) Ping(context.Context) error { + return nil +} + +func (fakeStorageRepo) Payments() storage.PaymentsStore { + return nil +} + +func (fakeStorageRepo) PaymentMethods() storage.PaymentMethodsStore { + return nil +} + +func (fakeStorageRepo) Quotes() quotestorage.QuotesStore { + return nil +} + +func (fakeStorageRepo) Routes() storage.RoutesStore { + return nil +} + +func (fakeStorageRepo) PlanTemplates() storage.PlanTemplatesStore { + return nil +} + +var _ storage.Repository = fakeStorageRepo{} diff --git a/api/payments/quotation/go.mod b/api/payments/quotation/go.mod index e83786c5..ea25e748 100644 --- a/api/payments/quotation/go.mod +++ b/api/payments/quotation/go.mod @@ -50,7 +50,7 @@ require ( github.com/prometheus/client_golang v1.23.2 // 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/prometheus/procfs v0.20.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum index 3800b6fb..ce1a2bb9 100644 --- a/api/payments/quotation/go.sum +++ b/api/payments/quotation/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= 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= diff --git a/api/pkg/go.mod b/api/pkg/go.mod index 39a0374a..a803f28b 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -66,7 +66,7 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.0 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect diff --git a/api/pkg/go.sum b/api/pkg/go.sum index 4e72b695..a8298edd 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -128,8 +128,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= diff --git a/api/server/go.mod b/api/server/go.mod index eb24f4a1..cee4b5fb 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -118,7 +118,7 @@ require ( github.com/prometheus/client_golang v1.23.2 // 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/prometheus/procfs v0.20.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect diff --git a/api/server/go.sum b/api/server/go.sum index 34698344..0fb933c3 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -200,8 +200,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= +github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= -- 2.49.1 From 7235ca1897e523c71ddd9d062fb54a3d095e59e4 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 25 Feb 2026 23:02:18 +0100 Subject: [PATCH 10/23] restricted CORS settings --- api/server/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/server/config.yml b/api/server/config.yml index 42440297..e94ccb5d 100755 --- a/api/server/config.yml +++ b/api/server/config.yml @@ -16,7 +16,9 @@ api: CORS: max_age: 300 allowed_origins: - - "*" + - "https://sendico.io" + - "https://app.sendico.io" + - "https://www.sendico.io" allowed_methods: - "GET" - "POST" -- 2.49.1 From 008427483c53be534414974f2297a7729b94cee1 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Wed, 25 Feb 2026 23:20:03 +0100 Subject: [PATCH 11/23] - legacy payment template fee lines picking --- .../service/orchestrator/service_v2_test.go | 4 - .../internal/service/plan_builder/default.go | 103 ---- .../service/plan_builder/default_test.go | 462 ----------------- .../service/plan_builder/endpoints.go | 101 ---- .../internal/service/plan_builder/gateways.go | 142 ------ .../internal/service/plan_builder/helpers.go | 463 ------------------ .../service/plan_builder/plan_builder.go | 22 - .../internal/service/plan_builder/plans.go | 78 --- .../internal/service/plan_builder/routes.go | 123 ----- .../internal/service/plan_builder/steps.go | 446 ----------------- .../service/plan_builder/templates.go | 212 -------- api/payments/orchestrator/main.go | 2 +- .../internal/service/plan/builder.go | 20 - .../service/plan/plan_builder_default.go | 102 ---- .../service/plan/plan_builder_steps.go | 447 ----------------- .../service/plan/plan_builder_templates.go | 211 -------- .../quotation/plan_builder_adapters.go | 14 - .../service/quotation/quote_engine.go | 47 +- .../quote_engine_conversion_fee_test.go | 264 ++++++++++ api/payments/storage/model/plan_template.go | 95 ---- api/payments/storage/mongo/repository.go | 23 +- .../storage/mongo/store/plan_templates.go | 174 ------- api/payments/storage/storage.go | 13 - api/pkg/mservice/services.go | 99 ++-- 24 files changed, 321 insertions(+), 3346 deletions(-) delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/default.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/default_test.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/endpoints.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/gateways.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/helpers.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/plan_builder.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/plans.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/routes.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/steps.go delete mode 100644 api/payments/orchestrator/internal/service/plan_builder/templates.go delete mode 100644 api/payments/quotation/internal/service/plan/plan_builder_default.go delete mode 100644 api/payments/quotation/internal/service/plan/plan_builder_steps.go delete mode 100644 api/payments/quotation/internal/service/plan/plan_builder_templates.go create mode 100644 api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go delete mode 100644 api/payments/storage/model/plan_template.go delete mode 100644 api/payments/storage/mongo/store/plan_templates.go diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go index f5a61010..9eed5915 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go @@ -76,8 +76,4 @@ func (fakeStorageRepo) Routes() storage.RoutesStore { return nil } -func (fakeStorageRepo) PlanTemplates() storage.PlanTemplatesStore { - return nil -} - var _ storage.Repository = fakeStorageRepo{} diff --git a/api/payments/orchestrator/internal/service/plan_builder/default.go b/api/payments/orchestrator/internal/service/plan_builder/default.go deleted file mode 100644 index 3401e887..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/default.go +++ /dev/null @@ -1,103 +0,0 @@ -package plan_builder - -import ( - "context" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mutil/mzap" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -type defaultPlanBuilder struct { - logger mlogger.Logger -} - -// New constructs the default plan builder. -func New(logger mlogger.Logger) *defaultPlanBuilder { - return &defaultPlanBuilder{ - logger: logger.Named("plan_builder"), - } -} - -func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { - if payment == nil { - return nil, merrors.InvalidArgument("plan builder: payment is required") - } - if routes == nil { - return nil, merrors.InvalidArgument("plan builder: routes store is required") - } - if templates == nil { - return nil, merrors.InvalidArgument("plan builder: plan templates store is required") - } - - logger := b.logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("payment_kind", string(payment.Intent.Kind)), - ) - logger.Debug("Building payment plan") - - intent := payment.Intent - if intent.Kind == model.PaymentKindFXConversion { - logger.Debug("Building fx conversion plan") - plan, err := buildFXConversionPlan(payment) - if err != nil { - logger.Warn("Failed to build fx conversion plan", zap.Error(err)) - return nil, err - } - logger.Info("Fx conversion plan built", zap.Int("steps", len(plan.Steps))) - return plan, nil - } - - sourceRail, sourceNetwork, err := railFromEndpoint(intent.Source, intent.Attributes, true) - if err != nil { - logger.Warn("Failed to resolve source rail", zap.Error(err)) - return nil, err - } - destRail, destNetwork, err := railFromEndpoint(intent.Destination, intent.Attributes, false) - if err != nil { - logger.Warn("Failed to resolve destination rail", zap.Error(err)) - return nil, err - } - - logger = logger.With( - zap.String("source_rail", string(sourceRail)), - zap.String("dest_rail", string(destRail)), - zap.String("source_network", sourceNetwork), - zap.String("dest_network", destNetwork), - ) - - if sourceRail == model.RailUnspecified || destRail == model.RailUnspecified { - logger.Warn("Source and destination rails are required") - return nil, merrors.InvalidArgument("plan builder: source and destination rails are required") - } - if sourceRail == destRail && sourceRail != model.RailLedger { - logger.Warn("Unsupported same-rail payment") - return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment") - } - - network, err := ResolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) - if err != nil { - logger.Warn("Failed to resolve route network", zap.Error(err)) - return nil, err - } - logger = logger.With(zap.String("network", network)) - - route, err := selectRoute(ctx, routes, sourceRail, destRail, network) - if err != nil { - logger.Warn("Failed to select route", zap.Error(err)) - return nil, err - } - logger.Debug("Route selected", mzap.StorableRef(route)) - - template, err := SelectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network) - if err != nil { - logger.Warn("Failed to select plan template", zap.Error(err)) - return nil, err - } - logger.Debug("Plan template selected", mzap.StorableRef(template)) - - return b.buildPlanFromTemplate(ctx, payment, quote, template, sourceRail, destRail, sourceNetwork, destNetwork, gateways) -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/default_test.go b/api/payments/orchestrator/internal/service/plan_builder/default_test.go deleted file mode 100644 index 6c47772d..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/default_test.go +++ /dev/null @@ -1,462 +0,0 @@ -package plan_builder - -import ( - "context" - "strings" - "testing" - - "github.com/tech/sendico/payments/storage/model" - mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" - "github.com/tech/sendico/pkg/model/account_role" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { - ctx := context.Background() - builder := New(mloggerfactory.NewLogger(false)) - - payment := &model.Payment{ - PaymentRef: "pay-1", - IdempotencyKey: "idem-1", - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-1", - Asset: &paymenttypes.Asset{ - Chain: "TRON_MAINNET", - TokenSymbol: "USDT", - }, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{MaskedPan: "4111"}, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}, - SettlementCurrency: "USDT", - }, - LastQuote: &model.PaymentQuoteSnapshot{ - ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, - ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, - }, - } - - quote := &sharedv1.PaymentQuote{ - ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "95"}, - ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"}, - } - - routes := &stubRouteStore{ - routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON_MAINNET", IsEnabled: true}, - }, - } - - templates := &stubPlanTemplateStore{ - templates: []*model.PaymentPlanTemplate{ - { - FromRail: model.RailCrypto, - ToRail: model.RailCardPayout, - Network: "TRON_MAINNET", - IsEnabled: true, - Steps: []model.OrchestrationStep{ - {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, - {StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}}, - {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, - {StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}}, - {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, - }, - }, - }, - } - - registry := &stubGatewayRegistry{ - items: []*model.GatewayInstanceDescriptor{ - { - ID: "crypto-tron", - InstanceID: "crypto-tron-1", - Rail: model.RailCrypto, - Network: "TRON_MAINNET", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - CanSendFee: true, - RequiresObserveConfirm: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - { - ID: "settlement", - InstanceID: "settlement-1", - Rail: model.RailProviderSettlement, - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - RequiresObserveConfirm: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - { - ID: "card", - InstanceID: "card-1", - Rail: model.RailCardPayout, - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - }, - } - - plan, err := builder.Build(ctx, payment, quote, routes, templates, registry) - if err != nil { - t.Fatalf("expected plan, got error: %v", err) - } - if plan == nil { - t.Fatal("expected plan") - } - if len(plan.Steps) != 6 { - t.Fatalf("expected 6 steps, got %d", len(plan.Steps)) - } - - assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-1", "USDT", "95") - assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-1", "USDT", "5") - assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-1", "", "") - assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationMove, "", "", "USDT", "95") - assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-1", "USDT", "95") - assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationMove, "", "", "USDT", "95") -} - -func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { - ctx := context.Background() - builder := New(mloggerfactory.NewLogger(false)) - - payment := &model.Payment{ - PaymentRef: "pay-1", - IdempotencyKey: "idem-1", - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-1", - Asset: &paymenttypes.Asset{Chain: "TRON_MAINNET"}, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{MaskedPan: "4111"}, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "10"}, - }, - } - - routes := &stubRouteStore{} - templates := &stubPlanTemplateStore{} - registry := &stubGatewayRegistry{} - - plan, err := builder.Build(ctx, payment, &sharedv1.PaymentQuote{}, routes, templates, registry) - if err == nil { - t.Fatalf("expected error, got plan: %#v", plan) - } -} - -func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t *testing.T) { - ctx := context.Background() - builder := New(mloggerfactory.NewLogger(false)) - - payment := &model.Payment{ - PaymentRef: "pay-settle-1", - IdempotencyKey: "idem-settle-1", - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - SettlementMode: model.SettlementModeFixReceived, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-1", - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{MaskedPan: "4111"}, - }, - }, - } - - quote := &sharedv1.PaymentQuote{ - DebitAmount: &moneyv1.Money{Currency: "USDT", Amount: "105"}, - ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "100"}, - ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"}, - } - - template := &model.PaymentPlanTemplate{ - Steps: []model.OrchestrationStep{ - {StepID: "settle", Rail: model.RailProviderSettlement, Operation: "send"}, - }, - } - - registry := &stubGatewayRegistry{ - items: []*model.GatewayInstanceDescriptor{ - { - ID: "settlement", - InstanceID: "settlement-1", - Rail: model.RailProviderSettlement, - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - }, - } - - plan, err := builder.buildPlanFromTemplate(ctx, payment, quote, template, model.RailCrypto, model.RailProviderSettlement, "TRON_MAINNET", "", registry) - if err != nil { - t.Fatalf("expected plan, got error: %v", err) - } - if len(plan.Steps) != 1 { - t.Fatalf("expected 1 step, got %d", len(plan.Steps)) - } - - assertPlanStep(t, plan.Steps[0], "settle", model.RailProviderSettlement, model.RailOperationSend, "settlement", "settlement-1", "USDT", "95") -} - -func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) { - ctx := context.Background() - builder := New(mloggerfactory.NewLogger(false)) - - payment := &model.Payment{ - PaymentRef: "pay-2", - IdempotencyKey: "idem-2", - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - RequiresFX: true, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-2", - Asset: &paymenttypes.Asset{ - Chain: "TRON_MAINNET", - TokenSymbol: "USDT", - }, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{MaskedPan: "4111"}, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1.4"}, - SettlementMode: model.SettlementModeFixReceived, - SettlementCurrency: "RUB", - }, - LastQuote: &model.PaymentQuoteSnapshot{ - DebitAmount: &paymenttypes.Money{Currency: "USDT", Amount: "1.498"}, - ExpectedSettlementAmount: &paymenttypes.Money{Currency: "RUB", Amount: "108.99"}, - ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "0.098"}, - }, - } - - quote := &sharedv1.PaymentQuote{ - DebitAmount: &moneyv1.Money{Currency: "USDT", Amount: "1.498"}, - ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "108.99"}, - ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.098"}, - } - - routes := &stubRouteStore{ - routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON_MAINNET", IsEnabled: true}, - }, - } - - templates := &stubPlanTemplateStore{ - templates: []*model.PaymentPlanTemplate{ - { - FromRail: model.RailCrypto, - ToRail: model.RailCardPayout, - Network: "TRON_MAINNET", - IsEnabled: true, - Steps: []model.OrchestrationStep{ - {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, - {StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}}, - {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, - {StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}}, - {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, - }, - }, - }, - } - - registry := &stubGatewayRegistry{ - items: []*model.GatewayInstanceDescriptor{ - { - ID: "crypto-tron", - InstanceID: "crypto-tron-2", - Rail: model.RailCrypto, - Network: "TRON_MAINNET", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - CanSendFee: true, - RequiresObserveConfirm: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - { - ID: "card", - InstanceID: "card-2", - Rail: model.RailCardPayout, - Currencies: []string{"RUB"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - }, - } - - plan, err := builder.Build(ctx, payment, quote, routes, templates, registry) - if err != nil { - t.Fatalf("expected plan, got error: %v", err) - } - if plan == nil { - t.Fatal("expected plan") - } - if len(plan.Steps) != 6 { - t.Fatalf("expected 6 steps, got %d", len(plan.Steps)) - } - - assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-2", "USDT", "1.4") - assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-2", "USDT", "0.098") - assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-2", "", "") - assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationMove, "", "", "RUB", "108.99") - assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-2", "RUB", "108.99") - assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationMove, "", "", "RUB", "108.99") -} - -// --- test doubles --- - -type stubRouteStore struct { - routes []*model.PaymentRoute -} - -func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) { - items := make([]*model.PaymentRoute, 0, len(s.routes)) - for _, route := range s.routes { - if route == nil { - continue - } - if filter != nil { - if filter.FromRail != "" && route.FromRail != filter.FromRail { - continue - } - if filter.ToRail != "" && route.ToRail != filter.ToRail { - continue - } - if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) { - continue - } - } - if filter != nil && filter.IsEnabled != nil { - if route.IsEnabled != *filter.IsEnabled { - continue - } - } - items = append(items, route) - } - return &model.PaymentRouteList{Items: items}, nil -} - -type stubPlanTemplateStore struct { - templates []*model.PaymentPlanTemplate -} - -func (s *stubPlanTemplateStore) List(_ context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { - items := make([]*model.PaymentPlanTemplate, 0, len(s.templates)) - for _, tpl := range s.templates { - if tpl == nil { - continue - } - if filter != nil { - if filter.FromRail != "" && tpl.FromRail != filter.FromRail { - continue - } - if filter.ToRail != "" && tpl.ToRail != filter.ToRail { - continue - } - if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) { - continue - } - } - if filter != nil && filter.IsEnabled != nil { - if tpl.IsEnabled != *filter.IsEnabled { - continue - } - } - items = append(items, tpl) - } - return &model.PaymentPlanTemplateList{Items: items}, nil -} - -type stubGatewayRegistry struct { - items []*model.GatewayInstanceDescriptor -} - -func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} - -func rolePtr(role account_role.AccountRole) *account_role.AccountRole { - return &role -} - -func assertPlanStep(t *testing.T, step *model.PaymentStep, stepID string, rail model.Rail, action model.RailOperation, gatewayID, instanceID, currency, amount string) { - t.Helper() - if step == nil { - t.Fatal("expected step") - } - if step.StepID != stepID { - t.Fatalf("expected step id %q, got %q", stepID, step.StepID) - } - if step.Rail != rail { - t.Fatalf("expected rail %s, got %s", rail, step.Rail) - } - if step.Action != action { - t.Fatalf("expected action %s, got %s", action, step.Action) - } - if step.GatewayID != gatewayID { - t.Fatalf("expected gateway %q, got %q", gatewayID, step.GatewayID) - } - if step.InstanceID != instanceID { - t.Fatalf("expected instance %q, got %q", instanceID, step.InstanceID) - } - if currency == "" && amount == "" { - if step.Amount != nil && step.Amount.Amount != "" { - t.Fatalf("expected empty amount, got %v", step.Amount) - } - return - } - if step.Amount == nil { - t.Fatalf("expected amount %s %s, got nil", currency, amount) - } - if step.Amount.GetCurrency() != currency || step.Amount.GetAmount() != amount { - t.Fatalf("expected amount %s %s, got %s %s", currency, amount, step.Amount.GetCurrency(), step.Amount.GetAmount()) - } -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/endpoints.go b/api/payments/orchestrator/internal/service/plan_builder/endpoints.go deleted file mode 100644 index 4785fa75..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/endpoints.go +++ /dev/null @@ -1,101 +0,0 @@ -package plan_builder - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" -) - -func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { - override := railOverrideFromAttributes(attrs, isSource) - if override != model.RailUnspecified { - return override, networkFromEndpoint(endpoint), nil - } - switch endpoint.Type { - case model.EndpointTypeLedger: - return model.RailLedger, "", nil - case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain: - return model.RailCrypto, networkFromEndpoint(endpoint), nil - case model.EndpointTypeCard: - return model.RailCardPayout, "", nil - default: - return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint") - } -} - -func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail { - if len(attrs) == 0 { - return model.RailUnspecified - } - keys := []string{"source_rail", "sourceRail"} - if !isSource { - keys = []string{"destination_rail", "destinationRail"} - } - lookup := map[string]struct{}{} - for _, key := range keys { - lookup[strings.ToLower(key)] = struct{}{} - } - for key, value := range attrs { - if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok { - continue - } - rail := parseRailValue(value) - if rail != model.RailUnspecified { - return rail - } - } - return model.RailUnspecified -} - -func parseRailValue(value string) model.Rail { - val := strings.ToUpper(strings.TrimSpace(value)) - switch val { - case string(model.RailCrypto): - return model.RailCrypto - case string(model.RailProviderSettlement): - return model.RailProviderSettlement - case string(model.RailLedger): - return model.RailLedger - case string(model.RailCardPayout): - return model.RailCardPayout - case string(model.RailFiatOnRamp): - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } -} - -func gatewayNetworkForRail(rail model.Rail, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string { - switch rail { - case model.RailCrypto: - if sourceRail == model.RailCrypto { - return strings.ToUpper(strings.TrimSpace(sourceNetwork)) - } - if destRail == model.RailCrypto { - return strings.ToUpper(strings.TrimSpace(destNetwork)) - } - case model.RailFiatOnRamp: - if sourceRail == model.RailFiatOnRamp { - return strings.ToUpper(strings.TrimSpace(sourceNetwork)) - } - if destRail == model.RailFiatOnRamp { - return strings.ToUpper(strings.TrimSpace(destNetwork)) - } - } - return "" -} - -func networkFromEndpoint(endpoint model.PaymentEndpoint) string { - switch endpoint.Type { - case model.EndpointTypeManagedWallet: - if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil { - return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain())) - } - case model.EndpointTypeExternalChain: - if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil { - return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain())) - } - } - return "" -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/gateways.go b/api/payments/orchestrator/internal/service/plan_builder/gateways.go deleted file mode 100644 index 50de480f..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/gateways.go +++ /dev/null @@ -1,142 +0,0 @@ -package plan_builder - -import ( - "context" - "sort" - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - "go.uber.org/zap" -) - -func ensureGatewayForAction(ctx context.Context, logger mlogger.Logger, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { - if registry == nil { - return nil, merrors.InvalidArgument("plan builder: gateway registry is required") - } - if gw, ok := cache[rail]; ok && gw != nil { - if instanceID == "" || strings.EqualFold(gw.InstanceID, instanceID) { - if err := validateGatewayAction(gw, network, amount, action, dir); err != nil { - logger.Warn("Failed to validate gateway", zap.Error(err), - zap.String("instance_id", instanceID), zap.String("rail", string(rail)), - zap.String("network", network), zap.String("action", string(action)), - zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)), - ) - return nil, err - } - return gw, nil - } - } - gw, err := selectGateway(ctx, registry, rail, network, amount, action, instanceID, dir) - if err != nil { - logger.Warn("Failed to select gateway", zap.Error(err), - zap.String("instance_id", instanceID), zap.String("rail", string(rail)), - zap.String("network", network), zap.String("action", string(action)), - zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)), - ) - return nil, err - } - cache[rail] = gw - return gw, nil -} - -func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) error { - if gw == nil { - return merrors.InvalidArgument("plan builder: gateway instance is required") - } - currency := "" - amt := decimal.Zero - if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" { - value, err := decimalFromMoney(amount) - if err != nil { - return err - } - amt = value - currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) - } - if err := model.IsGatewayEligible(gw, gw.Rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { - return merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) - } - return nil -} - -type sendDirection int - -const ( - sendDirectionAny sendDirection = iota - sendDirectionOut - sendDirectionIn -) - -func sendDirectionForRail(rail model.Rail) sendDirection { - switch rail { - case model.RailFiatOnRamp: - return sendDirectionIn - default: - return sendDirectionOut - } -} - -func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { - if registry == nil { - return nil, merrors.InvalidArgument("plan builder: gateway registry is required") - } - all, err := registry.List(ctx) - if err != nil { - return nil, err - } - if len(all) == 0 { - return nil, merrors.InvalidArgument("plan builder: no gateway instances available") - } - - currency := "" - amt := decimal.Zero - if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" { - amt, err = decimalFromMoney(amount) - if err != nil { - return nil, err - } - currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) - } - network = strings.ToUpper(strings.TrimSpace(network)) - - eligible := make([]*model.GatewayInstanceDescriptor, 0) - for _, gw := range all { - if err := model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { - continue - } - eligible = append(eligible, gw) - } - if len(eligible) == 0 { - return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) - } - sort.Slice(eligible, func(i, j int) bool { - return eligible[i].ID < eligible[j].ID - }) - if instanceID != "" { - for _, gw := range eligible { - if strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) { - return gw, nil - } - } - } - return eligible[0], nil -} - -func sendDirectionLabel(dir sendDirection) string { - return toGatewayDirection(dir).String() -} - -func toGatewayDirection(dir sendDirection) model.GatewayDirection { - switch dir { - case sendDirectionOut: - return model.GatewayDirectionOut - case sendDirectionIn: - return model.GatewayDirectionIn - default: - return model.GatewayDirectionAny - } -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/helpers.go b/api/payments/orchestrator/internal/service/plan_builder/helpers.go deleted file mode 100644 index 865eaa6f..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/helpers.go +++ /dev/null @@ -1,463 +0,0 @@ -package plan_builder - -import ( - "strings" - "time" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "google.golang.org/protobuf/types/known/timestamppb" -) - -type moneyGetter interface { - GetAmount() string - GetCurrency() string -} - -func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money { - if input == nil { - return nil - } - return &paymenttypes.Money{ - Currency: input.GetCurrency(), - Amount: input.GetAmount(), - } -} - -func cloneStringList(values []string) []string { - if len(values) == 0 { - return nil - } - result := make([]string, 0, len(values)) - for _, value := range values { - clean := strings.TrimSpace(value) - if clean == "" { - continue - } - result = append(result, clean) - } - if len(result) == 0 { - return nil - } - return result -} - -func cloneMetadata(input map[string]string) map[string]string { - if len(input) == 0 { - return nil - } - clone := make(map[string]string, len(input)) - for k, v := range input { - clone[k] = v - } - return clone -} - -func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole { - if role == nil { - return nil - } - cloned := *role - return &cloned -} - -func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) { - if m == nil { - return decimal.Zero, nil - } - return decimal.NewFromString(m.GetAmount()) -} - -func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money { - if m == nil { - return nil - } - return &paymenttypes.Money{ - Currency: m.GetCurrency(), - Amount: m.GetAmount(), - } -} - -func protoMoney(m *paymenttypes.Money) *moneyv1.Money { - if m == nil { - return nil - } - return &moneyv1.Money{ - Currency: m.GetCurrency(), - Amount: m.GetAmount(), - } -} - -func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money { - if input == nil { - return nil - } - return &moneyv1.Money{ - Currency: input.GetCurrency(), - Amount: input.GetAmount(), - } -} - -func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money { - return &moneyv1.Money{ - Currency: currency, - Amount: value.String(), - } -} - -func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) { - if m == nil || strings.TrimSpace(targetCurrency) == "" { - return nil, nil - } - if strings.EqualFold(m.GetCurrency(), targetCurrency) { - return cloneProtoMoney(m), nil - } - return convertWithQuote(m, quote, targetCurrency) -} - -func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) { - if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil { - return nil, nil - } - - base := strings.TrimSpace(quote.GetPair().GetBase()) - qt := strings.TrimSpace(quote.GetPair().GetQuote()) - if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" { - return nil, nil - } - - price, err := decimal.NewFromString(quote.GetPrice().GetValue()) - if err != nil || price.IsZero() { - return nil, err - } - value, err := decimalFromMoney(m) - if err != nil { - return nil, err - } - - switch { - case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt): - return makeMoney(targetCurrency, value.Mul(price)), nil - case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base): - return makeMoney(targetCurrency, value.Div(price)), nil - default: - return nil, nil - } -} - -func attributeLookup(attrs map[string]string, keys ...string) string { - if len(keys) == 0 { - return "" - } - for _, key := range keys { - if key == "" || attrs == nil { - continue - } - if val := strings.TrimSpace(attrs[key]); val != "" { - return val - } - } - return "" -} - -func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) { - if payment == nil { - return nil, merrors.InvalidArgument("payment is required") - } - amount := cloneMoney(payment.Intent.Amount) - if payment.LastQuote != nil { - settlement := payment.LastQuote.ExpectedSettlementAmount - if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" { - amount = cloneMoney(settlement) - } - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("card payout: amount is required") - } - return amount, nil -} - -func executionQuote(payment *model.Payment, quote *sharedv1.PaymentQuote) *sharedv1.PaymentQuote { - if quote != nil { - return quote - } - if payment != nil && payment.LastQuote != nil { - return modelQuoteToProto(payment.LastQuote) - } - return &sharedv1.PaymentQuote{} -} - -func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote { - if src == nil { - return nil - } - return &sharedv1.PaymentQuote{ - DebitAmount: protoMoney(src.DebitAmount), - DebitSettlementAmount: protoMoney(src.DebitSettlementAmount), - ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount), - ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal), - FeeLines: feeLinesToProto(src.FeeLines), - FeeRules: feeRulesToProto(src.FeeRules), - FxQuote: fxQuoteToProto(src.FXQuote), - NetworkFee: networkFeeToProto(src.NetworkFee), - QuoteRef: strings.TrimSpace(src.QuoteRef), - } -} - -func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse { - if resp == nil { - return nil - } - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: protoMoney(resp.NetworkFee), - EstimationContext: strings.TrimSpace(resp.EstimationContext), - } -} - -func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote { - if quote == nil { - return nil - } - pricedAtUnixMs := int64(0) - if ts := quote.GetPricedAt(); ts != nil { - pricedAtUnixMs = ts.AsTime().UnixMilli() - } - return &paymenttypes.FXQuote{ - QuoteRef: strings.TrimSpace(quote.GetQuoteRef()), - Pair: pairFromProto(quote.GetPair()), - Side: fxSideFromProto(quote.GetSide()), - Price: decimalFromProto(quote.GetPrice()), - BaseAmount: moneyFromProto(quote.GetBaseAmount()), - QuoteAmount: moneyFromProto(quote.GetQuoteAmount()), - ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(), - PricedAtUnixMs: pricedAtUnixMs, - Provider: strings.TrimSpace(quote.GetProvider()), - RateRef: strings.TrimSpace(quote.GetRateRef()), - Firm: quote.GetFirm(), - } -} - -func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote { - if quote == nil { - return nil - } - var pricedAt *timestamppb.Timestamp - if quote.PricedAtUnixMs > 0 { - pricedAt = timestamppb.New(time.UnixMilli(quote.PricedAtUnixMs).UTC()) - } - return &oraclev1.Quote{ - QuoteRef: strings.TrimSpace(quote.QuoteRef), - Pair: pairToProto(quote.Pair), - Side: fxSideToProto(quote.Side), - Price: decimalToProto(quote.Price), - BaseAmount: protoMoney(quote.BaseAmount), - QuoteAmount: protoMoney(quote.QuoteAmount), - ExpiresAtUnixMs: quote.ExpiresAtUnixMs, - PricedAt: pricedAt, - Provider: strings.TrimSpace(quote.Provider), - RateRef: strings.TrimSpace(quote.RateRef), - Firm: quote.Firm, - } -} - -func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine { - if len(lines) == 0 { - return nil - } - result := make([]*paymenttypes.FeeLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - continue - } - result = append(result, &paymenttypes.FeeLine{ - LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), - Money: moneyFromProto(line.GetMoney()), - LineType: postingLineTypeFromProto(line.GetLineType()), - Side: entrySideFromProto(line.GetSide()), - Meta: cloneMetadata(line.GetMeta()), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine { - if len(lines) == 0 { - return nil - } - result := make([]*feesv1.DerivedPostingLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - continue - } - result = append(result, &feesv1.DerivedPostingLine{ - LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), - Money: protoMoney(line.Money), - LineType: postingLineTypeToProto(line.LineType), - Side: entrySideToProto(line.Side), - Meta: cloneMetadata(line.Meta), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule { - if len(rules) == 0 { - return nil - } - result := make([]*feesv1.AppliedRule, 0, len(rules)) - for _, rule := range rules { - if rule == nil { - continue - } - result = append(result, &feesv1.AppliedRule{ - RuleId: strings.TrimSpace(rule.RuleID), - RuleVersion: strings.TrimSpace(rule.RuleVersion), - Formula: strings.TrimSpace(rule.Formula), - Rounding: roundingModeToProto(rule.Rounding), - TaxCode: strings.TrimSpace(rule.TaxCode), - TaxRate: strings.TrimSpace(rule.TaxRate), - Parameters: cloneMetadata(rule.Parameters), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair { - if pair == nil { - return nil - } - return &paymenttypes.CurrencyPair{ - Base: pair.GetBase(), - Quote: pair.GetQuote(), - } -} - -func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair { - if pair == nil { - return nil - } - return &fxv1.CurrencyPair{ - Base: pair.GetBase(), - Quote: pair.GetQuote(), - } -} - -func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide { - switch side { - case fxv1.Side_BUY_BASE_SELL_QUOTE: - return paymenttypes.FXSideBuyBaseSellQuote - case fxv1.Side_SELL_BASE_BUY_QUOTE: - return paymenttypes.FXSideSellBaseBuyQuote - default: - return paymenttypes.FXSideUnspecified - } -} - -func fxSideToProto(side paymenttypes.FXSide) fxv1.Side { - switch side { - case paymenttypes.FXSideBuyBaseSellQuote: - return fxv1.Side_BUY_BASE_SELL_QUOTE - case paymenttypes.FXSideSellBaseBuyQuote: - return fxv1.Side_SELL_BASE_BUY_QUOTE - default: - return fxv1.Side_SIDE_UNSPECIFIED - } -} - -func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal { - if value == nil { - return nil - } - return &paymenttypes.Decimal{Value: value.GetValue()} -} - -func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal { - if value == nil { - return nil - } - return &moneyv1.Decimal{Value: value.GetValue()} -} - -func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType { - switch lineType { - case accountingv1.PostingLineType_POSTING_LINE_FEE: - return paymenttypes.PostingLineTypeFee - case accountingv1.PostingLineType_POSTING_LINE_TAX: - return paymenttypes.PostingLineTypeTax - case accountingv1.PostingLineType_POSTING_LINE_SPREAD: - return paymenttypes.PostingLineTypeSpread - case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: - return paymenttypes.PostingLineTypeReversal - default: - return paymenttypes.PostingLineTypeUnspecified - } -} - -func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType { - switch lineType { - case paymenttypes.PostingLineTypeFee: - return accountingv1.PostingLineType_POSTING_LINE_FEE - case paymenttypes.PostingLineTypeTax: - return accountingv1.PostingLineType_POSTING_LINE_TAX - case paymenttypes.PostingLineTypeSpread: - return accountingv1.PostingLineType_POSTING_LINE_SPREAD - case paymenttypes.PostingLineTypeReversal: - return accountingv1.PostingLineType_POSTING_LINE_REVERSAL - default: - return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED - } -} - -func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide { - switch side { - case accountingv1.EntrySide_ENTRY_SIDE_DEBIT: - return paymenttypes.EntrySideDebit - case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: - return paymenttypes.EntrySideCredit - default: - return paymenttypes.EntrySideUnspecified - } -} - -func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide { - switch side { - case paymenttypes.EntrySideDebit: - return accountingv1.EntrySide_ENTRY_SIDE_DEBIT - case paymenttypes.EntrySideCredit: - return accountingv1.EntrySide_ENTRY_SIDE_CREDIT - default: - return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED - } -} - -func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode { - switch mode { - case paymenttypes.RoundingModeHalfEven: - return moneyv1.RoundingMode_ROUND_HALF_EVEN - case paymenttypes.RoundingModeHalfUp: - return moneyv1.RoundingMode_ROUND_HALF_UP - case paymenttypes.RoundingModeDown: - return moneyv1.RoundingMode_ROUND_DOWN - default: - return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED - } -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/plan_builder.go b/api/payments/orchestrator/internal/service/plan_builder/plan_builder.go deleted file mode 100644 index dfec9fed..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/plan_builder.go +++ /dev/null @@ -1,22 +0,0 @@ -package plan_builder - -import ( - "context" - - "github.com/tech/sendico/payments/storage/model" -) - -// RouteStore exposes routing definitions for plan construction. -type RouteStore interface { - List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) -} - -// PlanTemplateStore exposes orchestration plan templates for plan construction. -type PlanTemplateStore interface { - List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) -} - -// GatewayRegistry exposes gateway instances for capability-based selection. -type GatewayRegistry interface { - List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/plans.go b/api/payments/orchestrator/internal/service/plan_builder/plans.go deleted file mode 100644 index 61e41b5f..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/plans.go +++ /dev/null @@ -1,78 +0,0 @@ -package plan_builder - -import ( - "time" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) { - if payment == nil { - return nil, merrors.InvalidArgument("plan builder: payment is required") - } - step := &model.PaymentStep{ - StepID: "fx_convert", - Rail: model.RailLedger, - Action: model.RailOperationFXConvert, - ReportVisibility: model.ReportVisibilityUser, - CommitPolicy: model.CommitPolicyImmediate, - Amount: cloneMoney(payment.Intent.Amount), - } - return &model.PaymentPlan{ - ID: payment.PaymentRef, - Steps: []*model.PaymentStep{step}, - IdempotencyKey: payment.IdempotencyKey, - CreatedAt: planTimestamp(payment), - }, nil -} - -func resolveSettlementAmount(payment *model.Payment, quote *sharedv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money { - if quote != nil && quote.GetExpectedSettlementAmount() != nil { - return moneyFromProto(quote.GetExpectedSettlementAmount()) - } - if payment != nil && payment.LastQuote != nil && payment.LastQuote.ExpectedSettlementAmount != nil { - return cloneMoney(payment.LastQuote.ExpectedSettlementAmount) - } - return cloneMoney(fallback) -} - -func resolveDebitAmount(payment *model.Payment, quote *sharedv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money { - if quote != nil && quote.GetDebitAmount() != nil { - return moneyFromProto(quote.GetDebitAmount()) - } - if payment != nil && payment.LastQuote != nil && payment.LastQuote.DebitAmount != nil { - return cloneMoney(payment.LastQuote.DebitAmount) - } - return cloneMoney(fallback) -} - -func resolveFeeAmount(payment *model.Payment, quote *sharedv1.PaymentQuote) *paymenttypes.Money { - if quote != nil && quote.GetExpectedFeeTotal() != nil { - return moneyFromProto(quote.GetExpectedFeeTotal()) - } - if payment != nil && payment.LastQuote != nil { - return cloneMoney(payment.LastQuote.ExpectedFeeTotal) - } - return nil -} - -func isPositiveMoney(amount *paymenttypes.Money) bool { - if amount == nil { - return false - } - val, err := decimalFromMoney(amount) - if err != nil { - return false - } - return val.IsPositive() -} - -func planTimestamp(payment *model.Payment) time.Time { - if payment != nil && !payment.CreatedAt.IsZero() { - return payment.CreatedAt.UTC() - } - return time.Now().UTC() -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/routes.go b/api/payments/orchestrator/internal/service/plan_builder/routes.go deleted file mode 100644 index 8b0bf2d5..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/routes.go +++ /dev/null @@ -1,123 +0,0 @@ -package plan_builder - -import ( - "context" - "sort" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" -) - -// ResolveRouteNetwork determines the network for route selection from source/destination -// networks and attribute overrides. -func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { - src := strings.ToUpper(strings.TrimSpace(sourceNetwork)) - dst := strings.ToUpper(strings.TrimSpace(destNetwork)) - if src != "" && dst != "" && !strings.EqualFold(src, dst) { - return "", merrors.InvalidArgument("plan builder: source and destination networks mismatch") - } - - override := strings.ToUpper(strings.TrimSpace(attributeLookup(attrs, - "network", - "route_network", - "routeNetwork", - "source_network", - "sourceNetwork", - "destination_network", - "destinationNetwork", - ))) - if override != "" { - if src != "" && !strings.EqualFold(src, override) { - return "", merrors.InvalidArgument("plan builder: source network does not match override") - } - if dst != "" && !strings.EqualFold(dst, override) { - return "", merrors.InvalidArgument("plan builder: destination network does not match override") - } - return override, nil - } - if src != "" { - return src, nil - } - if dst != "" { - return dst, nil - } - return "", nil -} - -func selectRoute(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, network string) (*model.PaymentRoute, error) { - if routes == nil { - return nil, merrors.InvalidArgument("plan builder: routes store is required") - } - enabled := true - result, err := routes.List(ctx, &model.PaymentRouteFilter{ - FromRail: sourceRail, - ToRail: destRail, - Network: "", - IsEnabled: &enabled, - }) - if err != nil { - return nil, err - } - if result == nil || len(result.Items) == 0 { - return nil, merrors.InvalidArgument("plan builder: route not allowed") - } - candidates := make([]*model.PaymentRoute, 0, len(result.Items)) - for _, route := range result.Items { - if route == nil || !route.IsEnabled { - continue - } - if route.FromRail != sourceRail || route.ToRail != destRail { - continue - } - if !routeMatchesNetwork(route, network) { - continue - } - candidates = append(candidates, route) - } - if len(candidates) == 0 { - return nil, merrors.InvalidArgument("plan builder: route not allowed") - } - sort.Slice(candidates, func(i, j int) bool { - pi := routePriority(candidates[i], network) - pj := routePriority(candidates[j], network) - if pi != pj { - return pi < pj - } - if candidates[i].Network != candidates[j].Network { - return candidates[i].Network < candidates[j].Network - } - return candidates[i].ID.Hex() < candidates[j].ID.Hex() - }) - return candidates[0], nil -} - -func routeMatchesNetwork(route *model.PaymentRoute, network string) bool { - if route == nil { - return false - } - routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) - net := strings.ToUpper(strings.TrimSpace(network)) - if routeNetwork == "" { - return true - } - if net == "" { - return false - } - return strings.EqualFold(routeNetwork, net) -} - -func routePriority(route *model.PaymentRoute, network string) int { - if route == nil { - return 2 - } - routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) - net := strings.ToUpper(strings.TrimSpace(network)) - if net != "" && strings.EqualFold(routeNetwork, net) { - return 0 - } - if routeNetwork == "" { - return 1 - } - return 2 -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/steps.go b/api/payments/orchestrator/internal/service/plan_builder/steps.go deleted file mode 100644 index f1d0bbad..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/steps.go +++ /dev/null @@ -1,446 +0,0 @@ -package plan_builder - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mutil/mzap" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) { - if template == nil { - return nil, merrors.InvalidArgument("plan builder: plan template is required") - } - - logger := b.logger.With( - zap.String("payment_ref", payment.PaymentRef), - mzap.ObjRef("template_id", template.ID), - zap.String("source_rail", string(sourceRail)), - zap.String("dest_rail", string(destRail)), - ) - logger.Debug("Building plan from template", zap.Int("template_steps", len(template.Steps))) - - intentAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount") - if err != nil { - logger.Warn("Invalid intent amount", zap.Error(err)) - return nil, err - } - sourceAmount, err := requireMoney(resolveDebitAmount(payment, quote, intentAmount), "debit amount") - if err != nil { - logger.Warn("Failed to resolve debit amount", zap.Error(err)) - return nil, err - } - settlementAmount, err := requireMoney(resolveSettlementAmount(payment, quote, sourceAmount), "settlement amount") - if err != nil { - logger.Warn("Failed to resolve settlement amount", zap.Error(err)) - return nil, err - } - feeAmount := resolveFeeAmount(payment, quote) - feeRequired := isPositiveMoney(feeAmount) - sourceSendAmount, err := netSourceAmount(sourceAmount, feeAmount, quote) - if err != nil { - logger.Warn("Failed to calculate net source amount", zap.Error(err)) - return nil, err - } - providerSettlementAmount := settlementAmount - if payment.Intent.SettlementMode == model.SettlementModeFixReceived && feeRequired { - providerSettlementAmount, err = netSettlementAmount(settlementAmount, feeAmount, quote) - if err != nil { - logger.Warn("Failed to calculate net settlement amount", zap.Error(err)) - return nil, err - } - } - - logger.Debug("Amounts calculated", - zap.String("intent_amount", moneyString(intentAmount)), - zap.String("source_amount", moneyString(sourceAmount)), - zap.String("settlement_amount", moneyString(settlementAmount)), - zap.String("fee_amount", moneyString(feeAmount)), - zap.Bool("fee_required", feeRequired), - ) - - payoutAmount := settlementAmount - if destRail == model.RailCardPayout { - payoutAmount, err = cardPayoutAmount(payment) - if err != nil { - logger.Warn("Failed to calculate card payout amount", zap.Error(err)) - return nil, err - } - } - - ledgerCreditAmount := settlementAmount - ledgerDebitAmount := settlementAmount - if destRail == model.RailCardPayout && payoutAmount != nil { - ledgerDebitAmount = payoutAmount - } - - steps := make([]*model.PaymentStep, 0, len(template.Steps)) - gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{} - stepIDs := map[string]bool{} - sourceManagedWalletNetwork := "" - destManagedWalletNetwork := "" - if payment.Intent.Source.Type == model.EndpointTypeManagedWallet { - sourceManagedWalletNetwork = networkFromEndpoint(payment.Intent.Source) - } - if payment.Intent.Destination.Type == model.EndpointTypeManagedWallet { - destManagedWalletNetwork = networkFromEndpoint(payment.Intent.Destination) - } - - for _, tpl := range template.Steps { - stepID := strings.TrimSpace(tpl.StepID) - if stepID == "" { - return nil, merrors.InvalidArgument("plan builder: plan template step id is required") - } - if stepIDs[stepID] { - return nil, merrors.InvalidArgument("plan builder: plan template step id must be unique") - } - stepIDs[stepID] = true - - action, err := actionForOperation(tpl.Operation) - if err != nil { - b.logger.Warn("Plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err)) - return nil, err - } - - amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired) - if err != nil { - return nil, err - } - if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement { - amount = cloneMoney(providerSettlementAmount) - } - if amount == nil && - action != model.RailOperationObserveConfirm && - action != model.RailOperationFee { - logger.Warn("Plan template step has no amount for action, skipping", - zap.String("step_id", stepID), zap.String("action", string(action))) - continue - } - - policy := tpl.CommitPolicy - if strings.TrimSpace(string(policy)) == "" { - policy = model.CommitPolicyImmediate - } - step := &model.PaymentStep{ - StepID: stepID, - Rail: tpl.Rail, - Action: action, - ReportVisibility: tpl.ReportVisibility, - DependsOn: cloneStringList(tpl.DependsOn), - CommitPolicy: policy, - CommitAfter: cloneStringList(tpl.CommitAfter), - Amount: cloneMoney(amount), - FromRole: cloneAccountRole(tpl.FromRole), - ToRole: cloneAccountRole(tpl.ToRole), - } - - needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm - if (action == model.RailOperationBlock || action == model.RailOperationRelease) && tpl.Rail != model.RailLedger { - needsGateway = true - } - if needsGateway { - network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork) - managedWalletNetwork := "" - if tpl.Rail == sourceRail && sourceManagedWalletNetwork != "" { - managedWalletNetwork = sourceManagedWalletNetwork - } else if tpl.Rail == destRail && destManagedWalletNetwork != "" { - managedWalletNetwork = destManagedWalletNetwork - } - if managedWalletNetwork != "" { - logger.Debug("Managed wallet network resolved for gateway selection", - zap.String("step_id", stepID), - zap.String("rail", string(tpl.Rail)), - zap.String("managed_wallet_network", managedWalletNetwork), - zap.String("gateway_network", network), - ) - } - instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail) - checkAmount := amount - if action == model.RailOperationObserveConfirm { - checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount) - if tpl.Rail == model.RailProviderSettlement { - checkAmount = cloneMoney(providerSettlementAmount) - } - } - gw, err := ensureGatewayForAction(ctx, b.logger, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail)) - if err != nil { - logger.Warn("Failed to ensure gateway for plan step", zap.Error(err), - zap.String("step_id", stepID), zap.String("rail", string(tpl.Rail)), - zap.String("gateway_network", network), zap.String("managed_wallet_network", managedWalletNetwork), - zap.Int("gateways_by_rail_count", len(gatewaysByRail)), - ) - return nil, err - } - step.GatewayID = strings.TrimSpace(gw.ID) - step.InstanceID = strings.TrimSpace(gw.InstanceID) - step.GatewayInvokeURI = strings.TrimSpace(gw.InvokeURI) - } - - logger.Debug("Plan step added", - zap.String("step_id", step.StepID), - zap.String("rail", string(step.Rail)), - zap.String("action", string(step.Action)), - zap.String("commit_policy", string(step.CommitPolicy)), - zap.String("amount", moneyString(step.Amount)), - zap.Strings("depends_on", step.DependsOn), - ) - steps = append(steps, step) - } - - if len(steps) == 0 { - logger.Warn("Empty payment plan after processing template") - return nil, merrors.InvalidArgument("plan builder: empty payment plan") - } - - execQuote := executionQuote(payment, quote) - plan := &model.PaymentPlan{ - ID: payment.PaymentRef, - FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()), - Fees: feeLinesFromProto(execQuote.GetFeeLines()), - Steps: steps, - IdempotencyKey: payment.IdempotencyKey, - CreatedAt: planTimestamp(payment), - } - - logger.Info("Payment plan built", zap.Int("steps", len(plan.Steps)), - zap.Int("fees", len(plan.Fees)), zap.Bool("has_fx_quote", plan.FXQuote != nil), - ) - return plan, nil -} - -func moneyString(m *paymenttypes.Money) string { - if m == nil { - return "nil" - } - return m.Amount + " " + m.Currency -} - -func actionForOperation(operation string) (model.RailOperation, error) { - op := strings.ToLower(strings.TrimSpace(operation)) - if op == "ledger.block" || op == "ledger.release" { - return model.RailOperationUnspecified, merrors.InvalidArgument("unsupported legacy ledger operation, use ledger.move with roles") - } - switch op { - case "ledger.move": - return model.RailOperationMove, nil - case "external.debit": - return model.RailOperationExternalDebit, nil - case "external.credit": - return model.RailOperationExternalCredit, nil - case "debit", "wallet.debit": - return model.RailOperationExternalDebit, nil - case "credit", "wallet.credit": - return model.RailOperationExternalCredit, nil - case "fx.convert", "fx_conversion", "fx.converted": - return model.RailOperationFXConvert, nil - case "observe", "observe.confirm", "observe.confirmation", "observe.crypto", "observe.card": - return model.RailOperationObserveConfirm, nil - case "fee", "fee.send": - return model.RailOperationFee, nil - case "send", "payout.card", "payout.crypto", "payout.fiat", "payin.crypto", "payin.fiat", "fund.crypto", "fund.card": - return model.RailOperationSend, nil - case "block", "hold", "reserve": - return model.RailOperationBlock, nil - case "release", "unblock": - return model.RailOperationRelease, nil - } - - switch strings.ToUpper(strings.TrimSpace(operation)) { - case string(model.RailOperationExternalDebit), string(model.RailOperationDebit): - return model.RailOperationExternalDebit, nil - case string(model.RailOperationExternalCredit), string(model.RailOperationCredit): - return model.RailOperationExternalCredit, nil - case string(model.RailOperationMove): - return model.RailOperationMove, nil - case string(model.RailOperationSend): - return model.RailOperationSend, nil - case string(model.RailOperationFee): - return model.RailOperationFee, nil - case string(model.RailOperationObserveConfirm): - return model.RailOperationObserveConfirm, nil - case string(model.RailOperationFXConvert): - return model.RailOperationFXConvert, nil - case string(model.RailOperationBlock): - return model.RailOperationBlock, nil - case string(model.RailOperationRelease): - return model.RailOperationRelease, nil - } - - return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation") -} - -func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) { - switch action { - case model.RailOperationDebit, model.RailOperationExternalDebit: - if rail == model.RailLedger { - return cloneMoney(ledgerDebitAmount), nil - } - return cloneMoney(settlementAmount), nil - case model.RailOperationCredit, model.RailOperationExternalCredit: - if rail == model.RailLedger { - return cloneMoney(ledgerCreditAmount), nil - } - return cloneMoney(settlementAmount), nil - case model.RailOperationMove: - if rail == model.RailLedger { - return cloneMoney(ledgerDebitAmount), nil - } - return cloneMoney(settlementAmount), nil - case model.RailOperationSend: - switch rail { - case sourceRail: - return cloneMoney(sourceSendAmount), nil - case destRail: - return cloneMoney(payoutAmount), nil - default: - return cloneMoney(settlementAmount), nil - } - case model.RailOperationBlock, model.RailOperationRelease: - if rail == model.RailLedger { - return cloneMoney(ledgerDebitAmount), nil - } - return cloneMoney(settlementAmount), nil - case model.RailOperationFee: - if !feeRequired { - return nil, nil - } - return cloneMoney(feeAmount), nil - case model.RailOperationObserveConfirm: - return nil, nil - case model.RailOperationFXConvert: - return cloneMoney(settlementAmount), nil - default: - return nil, merrors.InvalidArgument("plan builder: unsupported action") - } -} - -func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string { - if rail == sourceRail { - return strings.TrimSpace(intent.Source.InstanceID) - } - if rail == destRail { - return strings.TrimSpace(intent.Destination.InstanceID) - } - return "" -} - -func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money { - switch rail { - case model.RailCrypto, model.RailFiatOnRamp: - if source != nil { - return source - } - if settlement != nil { - return settlement - } - case model.RailProviderSettlement: - if settlement != nil { - return settlement - } - case model.RailCardPayout: - if payout != nil { - return payout - } - } - if settlement != nil { - return settlement - } - return source -} - -func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) { - if sourceAmount == nil { - return nil, merrors.InvalidArgument("plan builder: source amount is required") - } - netAmount := cloneMoney(sourceAmount) - if !isPositiveMoney(feeAmount) { - return netAmount, nil - } - - currency := strings.TrimSpace(sourceAmount.GetCurrency()) - if currency == "" { - return netAmount, nil - } - var fxQuote *oraclev1.Quote - if quote != nil { - fxQuote = quote.GetFxQuote() - } - convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote) - if err != nil { - return nil, err - } - if convertedFee == nil { - return netAmount, nil - } - sourceValue, err := decimalFromMoney(sourceAmount) - if err != nil { - return nil, err - } - feeValue, err := decimalFromMoney(convertedFee) - if err != nil { - return nil, err - } - netValue := sourceValue.Sub(feeValue) - if netValue.IsNegative() { - return nil, merrors.InvalidArgument("plan builder: fee exceeds source amount") - } - return &paymenttypes.Money{ - Currency: currency, - Amount: netValue.String(), - }, nil -} - -func netSettlementAmount(settlementAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) { - if settlementAmount == nil { - return nil, merrors.InvalidArgument("plan builder: settlement amount is required") - } - netAmount := cloneMoney(settlementAmount) - if !isPositiveMoney(feeAmount) { - return netAmount, nil - } - - currency := strings.TrimSpace(settlementAmount.GetCurrency()) - if currency == "" { - return netAmount, nil - } - var fxQuote *oraclev1.Quote - if quote != nil { - fxQuote = quote.GetFxQuote() - } - convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote) - if err != nil { - return nil, err - } - if convertedFee == nil { - return netAmount, nil - } - settlementValue, err := decimalFromMoney(settlementAmount) - if err != nil { - return nil, err - } - feeValue, err := decimalFromMoney(convertedFee) - if err != nil { - return nil, err - } - netValue := settlementValue.Sub(feeValue) - if netValue.IsNegative() { - return nil, merrors.InvalidArgument("plan builder: fee exceeds settlement amount") - } - return &paymenttypes.Money{ - Currency: currency, - Amount: netValue.String(), - }, nil -} - -func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) { - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("plan builder: " + label + " is required") - } - return amount, nil -} diff --git a/api/payments/orchestrator/internal/service/plan_builder/templates.go b/api/payments/orchestrator/internal/service/plan_builder/templates.go deleted file mode 100644 index 3ec399cc..00000000 --- a/api/payments/orchestrator/internal/service/plan_builder/templates.go +++ /dev/null @@ -1,212 +0,0 @@ -package plan_builder - -import ( - "context" - "sort" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mutil/mzap" - "go.uber.org/zap" -) - -// SelectPlanTemplate selects the best plan template matching the given rails and network. -func SelectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { - if templates == nil { - return nil, merrors.InvalidArgument("plan builder: plan templates store is required") - } - - logger = logger.With( - zap.String("source_rail", string(sourceRail)), - zap.String("dest_rail", string(destRail)), - zap.String("network", network), - ) - logger.Debug("Selecting plan template") - - enabled := true - result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{ - FromRail: sourceRail, - ToRail: destRail, - IsEnabled: &enabled, - }) - if err != nil { - logger.Warn("Failed to list plan templates", zap.Error(err)) - return nil, err - } - if result == nil || len(result.Items) == 0 { - logger.Warn("No plan templates found for route") - return nil, merrors.InvalidArgument("plan builder: plan template missing") - } - - logger.Debug("Fetched plan templates", zap.Int("total", len(result.Items))) - - candidates := make([]*model.PaymentPlanTemplate, 0, len(result.Items)) - for _, tpl := range result.Items { - if tpl == nil || !tpl.IsEnabled { - continue - } - if tpl.FromRail != sourceRail || tpl.ToRail != destRail { - continue - } - if !templateMatchesNetwork(tpl, network) { - logger.Debug("Template network mismatch, skipping", - mzap.StorableRef(tpl), - zap.String("template_network", tpl.Network)) - continue - } - if err := validatePlanTemplate(logger, tpl); err != nil { - return nil, err - } - candidates = append(candidates, tpl) - } - if len(candidates) == 0 { - logger.Warn("No valid plan template candidates after filtering") - return nil, merrors.InvalidArgument("plan builder: plan template missing") - } - - logger.Debug("Plan template candidates filtered", zap.Int("candidates", len(candidates))) - - sort.Slice(candidates, func(i, j int) bool { - pi := templatePriority(candidates[i], network) - pj := templatePriority(candidates[j], network) - if pi != pj { - return pi < pj - } - if candidates[i].Network != candidates[j].Network { - return candidates[i].Network < candidates[j].Network - } - return candidates[i].ID.Hex() < candidates[j].ID.Hex() - }) - - selected := candidates[0] - logger.Debug("Plan template selected", - mzap.StorableRef(selected), - zap.String("template_network", selected.Network), - zap.Int("steps", len(selected.Steps)), - zap.Int("priority", templatePriority(selected, network))) - - return selected, nil -} - -func templateMatchesNetwork(template *model.PaymentPlanTemplate, network string) bool { - if template == nil { - return false - } - templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) - net := strings.ToUpper(strings.TrimSpace(network)) - if templateNetwork == "" { - return true - } - if net == "" { - return false - } - return strings.EqualFold(templateNetwork, net) -} - -func templatePriority(template *model.PaymentPlanTemplate, network string) int { - if template == nil { - return 2 - } - templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) - net := strings.ToUpper(strings.TrimSpace(network)) - if net != "" && strings.EqualFold(templateNetwork, net) { - return 0 - } - if templateNetwork == "" { - return 1 - } - return 2 -} - -func validatePlanTemplate(logger mlogger.Logger, template *model.PaymentPlanTemplate) error { - if template == nil { - return merrors.InvalidArgument("plan builder: plan template is required") - } - - logger = logger.With( - mzap.StorableRef(template), - zap.String("from_rail", string(template.FromRail)), - zap.String("to_rail", string(template.ToRail)), - zap.String("network", template.Network), - ) - logger.Debug("Validating plan template") - - if len(template.Steps) == 0 { - logger.Warn("Plan template has no steps") - return merrors.InvalidArgument("plan builder: plan template steps are required") - } - - seen := map[string]struct{}{} - for idx, step := range template.Steps { - id := strings.TrimSpace(step.StepID) - if id == "" { - logger.Warn("Plan template step missing ID", zap.Int("step_index", idx)) - return merrors.InvalidArgument("plan builder: plan template step id is required") - } - if _, exists := seen[id]; exists { - logger.Warn("Duplicate plan template step ID", zap.String("step_id", id)) - return merrors.InvalidArgument("plan builder: plan template step id must be unique") - } - seen[id] = struct{}{} - if strings.TrimSpace(step.Operation) == "" { - logger.Warn("Plan template step missing operation", zap.String("step_id", id), - zap.Int("step_index", idx)) - return merrors.InvalidArgument("plan builder: plan template operation is required") - } - if !model.IsValidReportVisibility(step.ReportVisibility) { - logger.Warn("Plan template step has invalid report visibility", - zap.String("step_id", id), - zap.String("report_visibility", string(step.ReportVisibility))) - return merrors.InvalidArgument("plan builder: plan template report visibility is invalid") - } - action, err := actionForOperation(step.Operation) - if err != nil { - logger.Warn("Plan template step has invalid operation", zap.String("step_id", id), - zap.String("operation", step.Operation), zap.Error(err)) - return err - } - if step.Rail == model.RailLedger && action == model.RailOperationMove { - if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" { - logger.Warn("Ledger move step missing fromRole", zap.String("step_id", id), - zap.String("operation", step.Operation)) - return merrors.InvalidArgument("plan builder: ledger.move fromRole is required") - } - if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" { - logger.Warn("Ledger move step missing toRole", zap.String("step_id", id), - zap.String("operation", step.Operation)) - return merrors.InvalidArgument("plan builder: ledger.move toRole is required") - } - from := strings.ToLower(strings.TrimSpace(string(*step.FromRole))) - to := strings.ToLower(strings.TrimSpace(string(*step.ToRole))) - if from == "" || to == "" || strings.EqualFold(from, to) { - logger.Warn("Ledger move step has invalid roles", zap.String("step_id", id), - zap.String("from_role", from), zap.String("to_role", to)) - return merrors.InvalidArgument("plan builder: ledger.move fromRole and toRole must differ") - } - } - } - - for _, step := range template.Steps { - for _, dep := range step.DependsOn { - depID := strings.TrimSpace(dep) - if _, ok := seen[depID]; !ok { - logger.Warn("Plan template step has missing dependency", zap.String("step_id", step.StepID), - zap.String("missing_dependency", depID)) - return merrors.InvalidArgument("plan builder: plan template dependency missing") - } - } - for _, dep := range step.CommitAfter { - depID := strings.TrimSpace(dep) - if _, ok := seen[depID]; !ok { - logger.Warn("Plan template step has missing commit dependency", zap.String("step_id", step.StepID), - zap.String("missing_commit_dependency", depID)) - return merrors.InvalidArgument("plan builder: plan template commit dependency missing") - } - } - } - - logger.Debug("Plan template validation successful", zap.Int("steps", len(template.Steps))) - return nil -} diff --git a/api/payments/orchestrator/main.go b/api/payments/orchestrator/main.go index 655b1b7d..cb1f3f89 100644 --- a/api/payments/orchestrator/main.go +++ b/api/payments/orchestrator/main.go @@ -32,7 +32,7 @@ Create initial aggregate: state=CREATED, version=1, immutable snapshots, and ini execution_plan_compiler_v2 Compile runtime step graph from quote route + execution conditions + intent. -Important: do not reuse old route/template lookup path as primary (plan_builder route/template selection) because v2 quote already selected route. +Important: do not reuse old route lookup path as primary because v2 quote already selected route. orchestration_state_machine Single source of truth for aggregate transitions (CREATED/EXECUTING/NEEDS_ATTENTION/SETTLED/FAILED) and step transitions (PENDING/RUNNING/COMPLETED/...). diff --git a/api/payments/quotation/internal/service/plan/builder.go b/api/payments/quotation/internal/service/plan/builder.go index b8b6a1d5..9ad317a8 100644 --- a/api/payments/quotation/internal/service/plan/builder.go +++ b/api/payments/quotation/internal/service/plan/builder.go @@ -5,8 +5,6 @@ import ( "github.com/shopspring/decimal" "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) // RouteStore exposes routing definitions for plan construction. @@ -14,21 +12,11 @@ type RouteStore interface { List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) } -// PlanTemplateStore exposes plan templates for plan construction. -type PlanTemplateStore interface { - List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) -} - // GatewayRegistry exposes gateway instances for capability-based selection. type GatewayRegistry interface { List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) } -// Builder constructs ordered payment plans from intents, quotes, and routing policy. -type Builder interface { - Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) -} - type SendDirection = sendDirection const ( @@ -37,10 +25,6 @@ const ( SendDirectionIn SendDirection = sendDirectionIn ) -func NewDefaultBuilder(logger mlogger.Logger) Builder { - return newDefaultBuilder(logger) -} - func RailFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { return railFromEndpoint(endpoint, attrs, isSource) } @@ -49,10 +33,6 @@ func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork str return resolveRouteNetwork(attrs, sourceNetwork, destNetwork) } -func SelectTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { - return selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network) -} - func SendDirectionForRail(rail model.Rail) SendDirection { return sendDirectionForRail(rail) } diff --git a/api/payments/quotation/internal/service/plan/plan_builder_default.go b/api/payments/quotation/internal/service/plan/plan_builder_default.go deleted file mode 100644 index ac28fe1a..00000000 --- a/api/payments/quotation/internal/service/plan/plan_builder_default.go +++ /dev/null @@ -1,102 +0,0 @@ -package plan - -import ( - "context" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mutil/mzap" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -type defaultBuilder struct { - logger mlogger.Logger -} - -func newDefaultBuilder(logger mlogger.Logger) *defaultBuilder { - return &defaultBuilder{ - logger: logger.Named("plan_builder"), - } -} - -func (b *defaultBuilder) Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { - if payment == nil { - return nil, merrors.InvalidArgument("plan builder: payment is required") - } - if routes == nil { - return nil, merrors.InvalidArgument("plan builder: routes store is required") - } - if templates == nil { - return nil, merrors.InvalidArgument("plan builder: plan templates store is required") - } - - logger := b.logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("payment_kind", string(payment.Intent.Kind)), - ) - logger.Debug("Building payment plan") - - intent := payment.Intent - if intent.Kind == model.PaymentKindFXConversion { - logger.Debug("Building fx conversion plan") - plan, err := buildFXConversionPlan(payment) - if err != nil { - logger.Warn("Failed to build fx conversion plan", zap.Error(err)) - return nil, err - } - logger.Info("Fx conversion plan built", zap.Int("steps", len(plan.Steps))) - return plan, nil - } - - sourceRail, sourceNetwork, err := railFromEndpoint(intent.Source, intent.Attributes, true) - if err != nil { - logger.Warn("Failed to resolve source rail", zap.Error(err)) - return nil, err - } - destRail, destNetwork, err := railFromEndpoint(intent.Destination, intent.Attributes, false) - if err != nil { - logger.Warn("Failed to resolve destination rail", zap.Error(err)) - return nil, err - } - - logger = logger.With( - zap.String("source_rail", string(sourceRail)), - zap.String("dest_rail", string(destRail)), - zap.String("source_network", sourceNetwork), - zap.String("dest_network", destNetwork), - ) - - if sourceRail == model.RailUnspecified || destRail == model.RailUnspecified { - logger.Warn("Source and destination rails are required") - return nil, merrors.InvalidArgument("plan builder: source and destination rails are required") - } - if sourceRail == destRail && sourceRail != model.RailLedger { - logger.Warn("Unsupported same-rail payment") - return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment") - } - - network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) - if err != nil { - logger.Warn("Failed to resolve route network", zap.Error(err)) - return nil, err - } - logger = logger.With(zap.String("network", network)) - - route, err := selectRoute(ctx, routes, sourceRail, destRail, network) - if err != nil { - logger.Warn("Failed to select route", zap.Error(err)) - return nil, err - } - logger.Debug("Route selected", mzap.StorableRef(route)) - - template, err := selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network) - if err != nil { - logger.Warn("Failed to select plan template", zap.Error(err)) - return nil, err - } - logger.Debug("Plan template selected", mzap.StorableRef(template)) - - return b.buildPlanFromTemplate(ctx, payment, quote, template, sourceRail, destRail, sourceNetwork, destNetwork, gateways) -} diff --git a/api/payments/quotation/internal/service/plan/plan_builder_steps.go b/api/payments/quotation/internal/service/plan/plan_builder_steps.go deleted file mode 100644 index 8e7396fe..00000000 --- a/api/payments/quotation/internal/service/plan/plan_builder_steps.go +++ /dev/null @@ -1,447 +0,0 @@ -package plan - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/quotation/internal/shared" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mutil/mzap" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) { - if template == nil { - return nil, merrors.InvalidArgument("plan builder: plan template is required") - } - - logger := b.logger.With( - zap.String("payment_ref", payment.PaymentRef), - mzap.ObjRef("template_id", template.ID), - zap.String("source_rail", string(sourceRail)), - zap.String("dest_rail", string(destRail)), - ) - logger.Debug("Building plan from template", zap.Int("template_steps", len(template.Steps))) - - intentAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount") - if err != nil { - logger.Warn("Invalid intent amount", zap.Error(err)) - return nil, err - } - sourceAmount, err := requireMoney(resolveDebitAmount(payment, quote, intentAmount), "debit amount") - if err != nil { - logger.Warn("Failed to resolve debit amount", zap.Error(err)) - return nil, err - } - settlementAmount, err := requireMoney(resolveSettlementAmount(payment, quote, sourceAmount), "settlement amount") - if err != nil { - logger.Warn("Failed to resolve settlement amount", zap.Error(err)) - return nil, err - } - feeAmount := resolveFeeAmount(payment, quote) - feeRequired := isPositiveMoney(feeAmount) - sourceSendAmount, err := netSourceAmount(sourceAmount, feeAmount, quote) - if err != nil { - logger.Warn("Failed to calculate net source amount", zap.Error(err)) - return nil, err - } - providerSettlementAmount := settlementAmount - if payment.Intent.SettlementMode == model.SettlementModeFixReceived && feeRequired { - providerSettlementAmount, err = netSettlementAmount(settlementAmount, feeAmount, quote) - if err != nil { - logger.Warn("Failed to calculate net settlement amount", zap.Error(err)) - return nil, err - } - } - - logger.Debug("Amounts calculated", - zap.String("intent_amount", moneyString(intentAmount)), - zap.String("source_amount", moneyString(sourceAmount)), - zap.String("settlement_amount", moneyString(settlementAmount)), - zap.String("fee_amount", moneyString(feeAmount)), - zap.Bool("fee_required", feeRequired), - ) - - payoutAmount := settlementAmount - if destRail == model.RailCardPayout { - payoutAmount, err = cardPayoutAmount(payment) - if err != nil { - logger.Warn("Failed to calculate card payout amount", zap.Error(err)) - return nil, err - } - } - - ledgerCreditAmount := settlementAmount - ledgerDebitAmount := settlementAmount - if destRail == model.RailCardPayout && payoutAmount != nil { - ledgerDebitAmount = payoutAmount - } - - steps := make([]*model.PaymentStep, 0, len(template.Steps)) - gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{} - stepIDs := map[string]bool{} - sourceManagedWalletNetwork := "" - destManagedWalletNetwork := "" - if payment.Intent.Source.Type == model.EndpointTypeManagedWallet { - sourceManagedWalletNetwork = networkFromEndpoint(payment.Intent.Source) - } - if payment.Intent.Destination.Type == model.EndpointTypeManagedWallet { - destManagedWalletNetwork = networkFromEndpoint(payment.Intent.Destination) - } - - for _, tpl := range template.Steps { - stepID := strings.TrimSpace(tpl.StepID) - if stepID == "" { - return nil, merrors.InvalidArgument("plan builder: plan template step id is required") - } - if stepIDs[stepID] { - return nil, merrors.InvalidArgument("plan builder: plan template step id must be unique") - } - stepIDs[stepID] = true - - action, err := actionForOperation(tpl.Operation) - if err != nil { - b.logger.Warn("Plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err)) - return nil, err - } - - amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired) - if err != nil { - return nil, err - } - if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement { - amount = cloneMoney(providerSettlementAmount) - } - if amount == nil && - action != model.RailOperationObserveConfirm && - action != model.RailOperationFee { - logger.Warn("Plan template step has no amount for action, skipping", - zap.String("step_id", stepID), zap.String("action", string(action))) - continue - } - - policy := tpl.CommitPolicy - if strings.TrimSpace(string(policy)) == "" { - policy = model.CommitPolicyImmediate - } - step := &model.PaymentStep{ - StepID: stepID, - Rail: tpl.Rail, - Action: action, - ReportVisibility: tpl.ReportVisibility, - DependsOn: cloneStringList(tpl.DependsOn), - CommitPolicy: policy, - CommitAfter: cloneStringList(tpl.CommitAfter), - Amount: cloneMoney(amount), - FromRole: shared.CloneAccountRole(tpl.FromRole), - ToRole: shared.CloneAccountRole(tpl.ToRole), - } - - needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm - if (action == model.RailOperationBlock || action == model.RailOperationRelease) && tpl.Rail != model.RailLedger { - needsGateway = true - } - if needsGateway { - network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork) - managedWalletNetwork := "" - if tpl.Rail == sourceRail && sourceManagedWalletNetwork != "" { - managedWalletNetwork = sourceManagedWalletNetwork - } else if tpl.Rail == destRail && destManagedWalletNetwork != "" { - managedWalletNetwork = destManagedWalletNetwork - } - if managedWalletNetwork != "" { - logger.Debug("Managed wallet network resolved for gateway selection", - zap.String("step_id", stepID), - zap.String("rail", string(tpl.Rail)), - zap.String("managed_wallet_network", managedWalletNetwork), - zap.String("gateway_network", network), - ) - } - instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail) - checkAmount := amount - if action == model.RailOperationObserveConfirm { - checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount) - if tpl.Rail == model.RailProviderSettlement { - checkAmount = cloneMoney(providerSettlementAmount) - } - } - gw, err := ensureGatewayForAction(ctx, b.logger, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail)) - if err != nil { - logger.Warn("Failed to ensure gateway for plan step", zap.Error(err), - zap.String("step_id", stepID), zap.String("rail", string(tpl.Rail)), - zap.String("gateway_network", network), zap.String("managed_wallet_network", managedWalletNetwork), - zap.Int("gateways_by_rail_count", len(gatewaysByRail)), - ) - return nil, err - } - step.GatewayID = strings.TrimSpace(gw.ID) - step.InstanceID = strings.TrimSpace(gw.InstanceID) - step.GatewayInvokeURI = strings.TrimSpace(gw.InvokeURI) - } - - logger.Debug("Plan step added", - zap.String("step_id", step.StepID), - zap.String("rail", string(step.Rail)), - zap.String("action", string(step.Action)), - zap.String("commit_policy", string(step.CommitPolicy)), - zap.String("amount", moneyString(step.Amount)), - zap.Strings("depends_on", step.DependsOn), - ) - steps = append(steps, step) - } - - if len(steps) == 0 { - logger.Warn("Empty payment plan after processing template") - return nil, merrors.InvalidArgument("plan builder: empty payment plan") - } - - execQuote := executionQuote(payment, quote) - plan := &model.PaymentPlan{ - ID: payment.PaymentRef, - FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()), - Fees: feeLinesFromProto(execQuote.GetFeeLines()), - Steps: steps, - IdempotencyKey: payment.IdempotencyKey, - CreatedAt: planTimestamp(payment), - } - - logger.Info("Payment plan built", zap.Int("steps", len(plan.Steps)), - zap.Int("fees", len(plan.Fees)), zap.Bool("has_fx_quote", plan.FXQuote != nil), - ) - return plan, nil -} - -func moneyString(m *paymenttypes.Money) string { - if m == nil { - return "nil" - } - return m.Amount + " " + m.Currency -} - -func actionForOperation(operation string) (model.RailOperation, error) { - op := strings.ToLower(strings.TrimSpace(operation)) - if op == "ledger.block" || op == "ledger.release" { - return model.RailOperationUnspecified, merrors.InvalidArgument("unsupported legacy ledger operation, use ledger.move with roles") - } - switch op { - case "ledger.move": - return model.RailOperationMove, nil - case "external.debit": - return model.RailOperationExternalDebit, nil - case "external.credit": - return model.RailOperationExternalCredit, nil - case "debit", "wallet.debit": - return model.RailOperationExternalDebit, nil - case "credit", "wallet.credit": - return model.RailOperationExternalCredit, nil - case "fx.convert", "fx_conversion", "fx.converted": - return model.RailOperationFXConvert, nil - case "observe", "observe.confirm", "observe.confirmation", "observe.crypto", "observe.card": - return model.RailOperationObserveConfirm, nil - case "fee", "fee.send": - return model.RailOperationFee, nil - case "send", "payout.card", "payout.crypto", "payout.fiat", "payin.crypto", "payin.fiat", "fund.crypto", "fund.card": - return model.RailOperationSend, nil - case "block", "hold", "reserve": - return model.RailOperationBlock, nil - case "release", "unblock": - return model.RailOperationRelease, nil - } - - switch strings.ToUpper(strings.TrimSpace(operation)) { - case string(model.RailOperationExternalDebit), string(model.RailOperationDebit): - return model.RailOperationExternalDebit, nil - case string(model.RailOperationExternalCredit), string(model.RailOperationCredit): - return model.RailOperationExternalCredit, nil - case string(model.RailOperationMove): - return model.RailOperationMove, nil - case string(model.RailOperationSend): - return model.RailOperationSend, nil - case string(model.RailOperationFee): - return model.RailOperationFee, nil - case string(model.RailOperationObserveConfirm): - return model.RailOperationObserveConfirm, nil - case string(model.RailOperationFXConvert): - return model.RailOperationFXConvert, nil - case string(model.RailOperationBlock): - return model.RailOperationBlock, nil - case string(model.RailOperationRelease): - return model.RailOperationRelease, nil - } - - return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation") -} - -func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceSendAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) { - switch action { - case model.RailOperationDebit, model.RailOperationExternalDebit: - if rail == model.RailLedger { - return cloneMoney(ledgerDebitAmount), nil - } - return cloneMoney(settlementAmount), nil - case model.RailOperationCredit, model.RailOperationExternalCredit: - if rail == model.RailLedger { - return cloneMoney(ledgerCreditAmount), nil - } - return cloneMoney(settlementAmount), nil - case model.RailOperationMove: - if rail == model.RailLedger { - return cloneMoney(ledgerDebitAmount), nil - } - return cloneMoney(settlementAmount), nil - case model.RailOperationSend: - switch rail { - case sourceRail: - return cloneMoney(sourceSendAmount), nil - case destRail: - return cloneMoney(payoutAmount), nil - default: - return cloneMoney(settlementAmount), nil - } - case model.RailOperationBlock, model.RailOperationRelease: - if rail == model.RailLedger { - return cloneMoney(ledgerDebitAmount), nil - } - return cloneMoney(settlementAmount), nil - case model.RailOperationFee: - if !feeRequired { - return nil, nil - } - return cloneMoney(feeAmount), nil - case model.RailOperationObserveConfirm: - return nil, nil - case model.RailOperationFXConvert: - return cloneMoney(settlementAmount), nil - default: - return nil, merrors.InvalidArgument("plan builder: unsupported action") - } -} - -func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string { - if rail == sourceRail { - return strings.TrimSpace(intent.Source.InstanceID) - } - if rail == destRail { - return strings.TrimSpace(intent.Destination.InstanceID) - } - return "" -} - -func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money { - switch rail { - case model.RailCrypto, model.RailFiatOnRamp: - if source != nil { - return source - } - if settlement != nil { - return settlement - } - case model.RailProviderSettlement: - if settlement != nil { - return settlement - } - case model.RailCardPayout: - if payout != nil { - return payout - } - } - if settlement != nil { - return settlement - } - return source -} - -func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) { - if sourceAmount == nil { - return nil, merrors.InvalidArgument("plan builder: source amount is required") - } - netAmount := cloneMoney(sourceAmount) - if !isPositiveMoney(feeAmount) { - return netAmount, nil - } - - currency := strings.TrimSpace(sourceAmount.GetCurrency()) - if currency == "" { - return netAmount, nil - } - var fxQuote *oraclev1.Quote - if quote != nil { - fxQuote = quote.GetFxQuote() - } - convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote) - if err != nil { - return nil, err - } - if convertedFee == nil { - return netAmount, nil - } - sourceValue, err := decimalFromMoney(sourceAmount) - if err != nil { - return nil, err - } - feeValue, err := decimalFromMoney(convertedFee) - if err != nil { - return nil, err - } - netValue := sourceValue.Sub(feeValue) - if netValue.IsNegative() { - return nil, merrors.InvalidArgument("plan builder: fee exceeds source amount") - } - return &paymenttypes.Money{ - Currency: currency, - Amount: netValue.String(), - }, nil -} - -func netSettlementAmount(settlementAmount, feeAmount *paymenttypes.Money, quote *sharedv1.PaymentQuote) (*paymenttypes.Money, error) { - if settlementAmount == nil { - return nil, merrors.InvalidArgument("plan builder: settlement amount is required") - } - netAmount := cloneMoney(settlementAmount) - if !isPositiveMoney(feeAmount) { - return netAmount, nil - } - - currency := strings.TrimSpace(settlementAmount.GetCurrency()) - if currency == "" { - return netAmount, nil - } - var fxQuote *oraclev1.Quote - if quote != nil { - fxQuote = quote.GetFxQuote() - } - convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote) - if err != nil { - return nil, err - } - if convertedFee == nil { - return netAmount, nil - } - settlementValue, err := decimalFromMoney(settlementAmount) - if err != nil { - return nil, err - } - feeValue, err := decimalFromMoney(convertedFee) - if err != nil { - return nil, err - } - netValue := settlementValue.Sub(feeValue) - if netValue.IsNegative() { - return nil, merrors.InvalidArgument("plan builder: fee exceeds settlement amount") - } - return &paymenttypes.Money{ - Currency: currency, - Amount: netValue.String(), - }, nil -} - -func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) { - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("plan builder: " + label + " is required") - } - return amount, nil -} diff --git a/api/payments/quotation/internal/service/plan/plan_builder_templates.go b/api/payments/quotation/internal/service/plan/plan_builder_templates.go deleted file mode 100644 index cb4fee24..00000000 --- a/api/payments/quotation/internal/service/plan/plan_builder_templates.go +++ /dev/null @@ -1,211 +0,0 @@ -package plan - -import ( - "context" - "sort" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mutil/mzap" - "go.uber.org/zap" -) - -func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { - if templates == nil { - return nil, merrors.InvalidArgument("plan builder: plan templates store is required") - } - - logger = logger.With( - zap.String("source_rail", string(sourceRail)), - zap.String("dest_rail", string(destRail)), - zap.String("network", network), - ) - logger.Debug("Selecting plan template") - - enabled := true - result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{ - FromRail: sourceRail, - ToRail: destRail, - IsEnabled: &enabled, - }) - if err != nil { - logger.Warn("Failed to list plan templates", zap.Error(err)) - return nil, err - } - if result == nil || len(result.Items) == 0 { - logger.Warn("No plan templates found for route") - return nil, merrors.InvalidArgument("plan builder: plan template missing") - } - - logger.Debug("Fetched plan templates", zap.Int("total", len(result.Items))) - - candidates := make([]*model.PaymentPlanTemplate, 0, len(result.Items)) - for _, tpl := range result.Items { - if tpl == nil || !tpl.IsEnabled { - continue - } - if tpl.FromRail != sourceRail || tpl.ToRail != destRail { - continue - } - if !templateMatchesNetwork(tpl, network) { - logger.Debug("Template network mismatch, skipping", - mzap.StorableRef(tpl), - zap.String("template_network", tpl.Network)) - continue - } - if err := validatePlanTemplate(logger, tpl); err != nil { - return nil, err - } - candidates = append(candidates, tpl) - } - if len(candidates) == 0 { - logger.Warn("No valid plan template candidates after filtering") - return nil, merrors.InvalidArgument("plan builder: plan template missing") - } - - logger.Debug("Plan template candidates filtered", zap.Int("candidates", len(candidates))) - - sort.Slice(candidates, func(i, j int) bool { - pi := templatePriority(candidates[i], network) - pj := templatePriority(candidates[j], network) - if pi != pj { - return pi < pj - } - if candidates[i].Network != candidates[j].Network { - return candidates[i].Network < candidates[j].Network - } - return candidates[i].ID.Hex() < candidates[j].ID.Hex() - }) - - selected := candidates[0] - logger.Debug("Plan template selected", - mzap.StorableRef(selected), - zap.String("template_network", selected.Network), - zap.Int("steps", len(selected.Steps)), - zap.Int("priority", templatePriority(selected, network))) - - return selected, nil -} - -func templateMatchesNetwork(template *model.PaymentPlanTemplate, network string) bool { - if template == nil { - return false - } - templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) - net := strings.ToUpper(strings.TrimSpace(network)) - if templateNetwork == "" { - return true - } - if net == "" { - return false - } - return strings.EqualFold(templateNetwork, net) -} - -func templatePriority(template *model.PaymentPlanTemplate, network string) int { - if template == nil { - return 2 - } - templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) - net := strings.ToUpper(strings.TrimSpace(network)) - if net != "" && strings.EqualFold(templateNetwork, net) { - return 0 - } - if templateNetwork == "" { - return 1 - } - return 2 -} - -func validatePlanTemplate(logger mlogger.Logger, template *model.PaymentPlanTemplate) error { - if template == nil { - return merrors.InvalidArgument("plan builder: plan template is required") - } - - logger = logger.With( - mzap.StorableRef(template), - zap.String("from_rail", string(template.FromRail)), - zap.String("to_rail", string(template.ToRail)), - zap.String("network", template.Network), - ) - logger.Debug("Validating plan template") - - if len(template.Steps) == 0 { - logger.Warn("Plan template has no steps") - return merrors.InvalidArgument("plan builder: plan template steps are required") - } - - seen := map[string]struct{}{} - for idx, step := range template.Steps { - id := strings.TrimSpace(step.StepID) - if id == "" { - logger.Warn("Plan template step missing ID", zap.Int("step_index", idx)) - return merrors.InvalidArgument("plan builder: plan template step id is required") - } - if _, exists := seen[id]; exists { - logger.Warn("Duplicate plan template step ID", zap.String("step_id", id)) - return merrors.InvalidArgument("plan builder: plan template step id must be unique") - } - seen[id] = struct{}{} - if strings.TrimSpace(step.Operation) == "" { - logger.Warn("Plan template step missing operation", zap.String("step_id", id), - zap.Int("step_index", idx)) - return merrors.InvalidArgument("plan builder: plan template operation is required") - } - if !model.IsValidReportVisibility(step.ReportVisibility) { - logger.Warn("Plan template step has invalid report visibility", - zap.String("step_id", id), - zap.String("report_visibility", string(step.ReportVisibility))) - return merrors.InvalidArgument("plan builder: plan template report visibility is invalid") - } - action, err := actionForOperation(step.Operation) - if err != nil { - logger.Warn("Plan template step has invalid operation", zap.String("step_id", id), - zap.String("operation", step.Operation), zap.Error(err)) - return err - } - if step.Rail == model.RailLedger && action == model.RailOperationMove { - if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" { - logger.Warn("Ledger move step missing fromRole", zap.String("step_id", id), - zap.String("operation", step.Operation)) - return merrors.InvalidArgument("plan builder: ledger.move fromRole is required") - } - if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" { - logger.Warn("Ledger move step missing toRole", zap.String("step_id", id), - zap.String("operation", step.Operation)) - return merrors.InvalidArgument("plan builder: ledger.move toRole is required") - } - from := strings.ToLower(strings.TrimSpace(string(*step.FromRole))) - to := strings.ToLower(strings.TrimSpace(string(*step.ToRole))) - if from == "" || to == "" || strings.EqualFold(from, to) { - logger.Warn("Ledger move step has invalid roles", zap.String("step_id", id), - zap.String("from_role", from), zap.String("to_role", to)) - return merrors.InvalidArgument("plan builder: ledger.move fromRole and toRole must differ") - } - } - } - - for _, step := range template.Steps { - for _, dep := range step.DependsOn { - depID := strings.TrimSpace(dep) - if _, ok := seen[depID]; !ok { - logger.Warn("Plan template step has missing dependency", zap.String("step_id", step.StepID), - zap.String("missing_dependency", depID)) - return merrors.InvalidArgument("plan builder: plan template dependency missing") - } - } - for _, dep := range step.CommitAfter { - depID := strings.TrimSpace(dep) - if _, ok := seen[depID]; !ok { - logger.Warn("Plan template step has missing commit dependency", zap.String("step_id", step.StepID), - zap.String("missing_commit_dependency", depID)) - return merrors.InvalidArgument("plan builder: plan template commit dependency missing") - } - } - } - - logger.Debug("Plan template validation successful", zap.Int("steps", len(template.Steps))) - return nil -} diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go index 30217ced..f7f16fd5 100644 --- a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go @@ -1,11 +1,8 @@ package quotation import ( - "context" - "github.com/tech/sendico/payments/quotation/internal/service/plan" "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" ) func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { @@ -15,14 +12,3 @@ func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, i func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork) } - -func selectPlanTemplate( - ctx context.Context, - logger mlogger.Logger, - templates plan.PlanTemplateStore, - sourceRail model.Rail, - destRail model.Rail, - network string, -) (*model.PaymentPlanTemplate, error) { - return plan.SelectTemplate(ctx, logger, templates, sourceRail, destRail, network) -} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index 08c15aa6..192823d6 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -91,7 +91,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo } } conversionFeeQuote := &feesv1.PrecomputeFeesResponse{} - if s.shouldQuoteConversionFee(ctx, req.GetIntent()) { + if s.shouldQuoteConversionFee(req.GetIntent()) { conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount) if err != nil { return nil, time.Time{}, err @@ -230,7 +230,7 @@ func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *q return resp, nil } -func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1.PaymentIntent) bool { +func (s *Service) shouldQuoteConversionFee(intent *sharedv1.PaymentIntent) bool { if intent == nil { return false } @@ -240,48 +240,7 @@ func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *sharedv1 if isLedgerEndpoint(intent.GetDestination()) { return false } - if s.storage == nil { - return false - } - templates := s.storage.PlanTemplates() - if templates == nil { - return false - } - - intentModel := intentFromProto(intent) - sourceRail, sourceNetwork, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true) - if err != nil { - return false - } - destRail, destNetwork, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false) - if err != nil { - return false - } - network, err := resolveRouteNetwork(intentModel.Attributes, sourceNetwork, destNetwork) - if err != nil { - return false - } - - template, err := selectPlanTemplate(ctx, s.logger.Named("quote_payment"), templates, sourceRail, destRail, network) - if err != nil { - return false - } - return templateHasLedgerMove(template) -} - -func templateHasLedgerMove(template *model.PaymentPlanTemplate) bool { - if template == nil { - return false - } - for _, step := range template.Steps { - if step.Rail != model.RailLedger { - continue - } - if strings.EqualFold(strings.TrimSpace(step.Operation), "ledger.move") { - return true - } - } - return false + return true } func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule { diff --git a/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go b/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go new file mode 100644 index 00000000..020939af --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go @@ -0,0 +1,264 @@ +package quotation + +import ( + "context" + "strings" + "testing" + + "github.com/tech/sendico/pkg/merrors" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +func TestBuildPaymentQuote_RequestsConversionFeesWithoutPlanTemplates(t *testing.T) { + feeClient := &stubFeeEngineClient{ + precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{ + "payments.orchestrator.quote": { + Lines: []*feesv1.DerivedPostingLine{ + testFeeLine("1.00", "USDT"), + }, + Applied: []*feesv1.AppliedRule{{RuleId: "rule.base"}}, + }, + "payments.orchestrator.conversion_quote": { + Lines: []*feesv1.DerivedPostingLine{ + testFeeLine("2.00", "USDT"), + }, + Applied: []*feesv1.AppliedRule{{RuleId: "rule.conversion"}}, + }, + }, + } + + svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0)) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToCardIntent(), + } + + quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if quote == nil { + t.Fatalf("expected quote") + } + + if got, want := len(feeClient.precomputeReqs), 2; got != want { + t.Fatalf("unexpected precompute call count: got=%d want=%d", got, want) + } + if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.quote"), 1; got != want { + t.Fatalf("unexpected base precompute calls: got=%d want=%d", got, want) + } + if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 1; got != want { + t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want) + } + if got, want := len(quote.GetFeeLines()), 2; got != want { + t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want) + } + if quote.GetExpectedFeeTotal() == nil { + t.Fatalf("expected fee total") + } + if got, want := quote.GetExpectedFeeTotal().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected fee total currency: got=%q want=%q", got, want) + } + if got, want := quote.GetExpectedFeeTotal().GetAmount(), "3"; got != want { + t.Fatalf("unexpected fee total amount: got=%q want=%q", got, want) + } + if got := quote.GetFeeLines()[1].GetMeta()[feeLineMetaTarget]; got != feeLineTargetWallet { + t.Fatalf("expected conversion line target %q, got %q", feeLineTargetWallet, got) + } + if got, want := quote.GetFeeLines()[1].GetMeta()[feeLineMetaWalletRef], "mw_src_1"; got != want { + t.Fatalf("unexpected conversion fee wallet_ref: got=%q want=%q", got, want) + } + if got, want := len(quote.GetFeeRules()), 2; got != want { + t.Fatalf("unexpected fee rules count: got=%d want=%d", got, want) + } +} + +func TestBuildPaymentQuote_ConversionFeeReturnedEmptyDoesNotAddLines(t *testing.T) { + feeClient := &stubFeeEngineClient{ + precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{ + "payments.orchestrator.quote": { + Lines: []*feesv1.DerivedPostingLine{ + testFeeLine("1.00", "USDT"), + }, + }, + "payments.orchestrator.conversion_quote": {}, + }, + } + + svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0)) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToCardIntent(), + } + + quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if quote == nil { + t.Fatalf("expected quote") + } + + if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 1; got != want { + t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want) + } + if got, want := len(quote.GetFeeLines()), 1; got != want { + t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want) + } + if quote.GetFeeLines()[0].GetMeta()[feeLineMetaTarget] == feeLineTargetWallet { + t.Fatalf("base fee line should not be tagged as wallet conversion line") + } +} + +func TestBuildPaymentQuote_DoesNotRequestConversionFeesForManagedWalletToLedger(t *testing.T) { + feeClient := &stubFeeEngineClient{ + precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{ + "payments.orchestrator.quote": { + Lines: []*feesv1.DerivedPostingLine{ + testFeeLine("1.00", "USDT"), + }, + }, + }, + } + + svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0)) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToLedgerIntent(), + } + + quote, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if quote == nil { + t.Fatalf("expected quote") + } + + if got, want := len(feeClient.precomputeReqs), 1; got != want { + t.Fatalf("unexpected precompute call count: got=%d want=%d", got, want) + } + if got, want := precomputeCallsByOrigin(feeClient.precomputeReqs, "payments.orchestrator.conversion_quote"), 0; got != want { + t.Fatalf("unexpected conversion precompute calls: got=%d want=%d", got, want) + } +} + +type stubFeeEngineClient struct { + precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse + precomputeReqs []*feesv1.PrecomputeFeesRequest +} + +func (s *stubFeeEngineClient) QuoteFees(context.Context, *feesv1.QuoteFeesRequest, ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) { + return nil, merrors.InvalidArgument("unexpected QuoteFees call") +} + +func (s *stubFeeEngineClient) PrecomputeFees(_ context.Context, in *feesv1.PrecomputeFeesRequest, _ ...grpc.CallOption) (*feesv1.PrecomputeFeesResponse, error) { + if s == nil { + return &feesv1.PrecomputeFeesResponse{}, nil + } + if in != nil { + if cloned, ok := proto.Clone(in).(*feesv1.PrecomputeFeesRequest); ok { + s.precomputeReqs = append(s.precomputeReqs, cloned) + } else { + s.precomputeReqs = append(s.precomputeReqs, in) + } + } + if in == nil || in.GetIntent() == nil { + return &feesv1.PrecomputeFeesResponse{}, nil + } + + originType := strings.TrimSpace(in.GetIntent().GetOriginType()) + resp, ok := s.precomputeByOrigin[originType] + if !ok || resp == nil { + return &feesv1.PrecomputeFeesResponse{}, nil + } + if cloned, ok := proto.Clone(resp).(*feesv1.PrecomputeFeesResponse); ok { + return cloned, nil + } + return resp, nil +} + +func (s *stubFeeEngineClient) ValidateFeeToken(context.Context, *feesv1.ValidateFeeTokenRequest, ...grpc.CallOption) (*feesv1.ValidateFeeTokenResponse, error) { + return nil, merrors.InvalidArgument("unexpected ValidateFeeToken call") +} + +func precomputeCallsByOrigin(reqs []*feesv1.PrecomputeFeesRequest, originType string) int { + count := 0 + for _, req := range reqs { + if req == nil || req.GetIntent() == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(req.GetIntent().GetOriginType()), strings.TrimSpace(originType)) { + count++ + } + } + return count +} + +func testFeeLine(amount, currency string) *feesv1.DerivedPostingLine { + return &feesv1.DerivedPostingLine{ + Money: &moneyv1.Money{ + Amount: amount, + Currency: currency, + }, + LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, + Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, + } +} + +func testManagedWalletToCardIntent() *sharedv1.PaymentIntent { + return &sharedv1.PaymentIntent{ + Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, + Source: &sharedv1.PaymentEndpoint{ + Endpoint: &sharedv1.PaymentEndpoint_ManagedWallet{ + ManagedWallet: &sharedv1.ManagedWalletEndpoint{ + ManagedWalletRef: "mw_src_1", + }, + }, + }, + Destination: &sharedv1.PaymentEndpoint{ + Endpoint: &sharedv1.PaymentEndpoint_Card{ + Card: &sharedv1.CardEndpoint{ + Card: &sharedv1.CardEndpoint_Pan{Pan: "4111111111111111"}, + }, + }, + }, + Amount: &moneyv1.Money{ + Amount: "100", + Currency: "USDT", + }, + } +} + +func testManagedWalletToLedgerIntent() *sharedv1.PaymentIntent { + return &sharedv1.PaymentIntent{ + Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, + Source: &sharedv1.PaymentEndpoint{ + Endpoint: &sharedv1.PaymentEndpoint_ManagedWallet{ + ManagedWallet: &sharedv1.ManagedWalletEndpoint{ + ManagedWalletRef: "mw_src_1", + }, + }, + }, + Destination: &sharedv1.PaymentEndpoint{ + Endpoint: &sharedv1.PaymentEndpoint_Ledger{ + Ledger: &sharedv1.LedgerEndpoint{ + LedgerAccountRef: "ledger_dst_1", + }, + }, + }, + Amount: &moneyv1.Money{ + Amount: "100", + Currency: "USDT", + }, + } +} diff --git a/api/payments/storage/model/plan_template.go b/api/payments/storage/model/plan_template.go deleted file mode 100644 index b106ebd4..00000000 --- a/api/payments/storage/model/plan_template.go +++ /dev/null @@ -1,95 +0,0 @@ -package model - -import ( - "strings" - - "github.com/tech/sendico/pkg/db/storable" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/mservice" -) - -// OrchestrationStep defines a template step for execution planning. -type OrchestrationStep struct { - StepID string `bson:"stepId" json:"stepId"` - Rail Rail `bson:"rail" json:"rail"` - Operation string `bson:"operation" json:"operation"` - ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"` - DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` - CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` - CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` - FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"` - ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"` -} - -// PaymentPlanTemplate stores reusable orchestration templates. -type PaymentPlanTemplate struct { - storable.Base `bson:",inline" json:",inline"` - - FromRail Rail `bson:"fromRail" json:"fromRail"` - ToRail Rail `bson:"toRail" json:"toRail"` - Network string `bson:"network,omitempty" json:"network,omitempty"` - Steps []OrchestrationStep `bson:"steps,omitempty" json:"steps,omitempty"` - IsEnabled bool `bson:"isEnabled" json:"isEnabled"` -} - -// Collection implements storable.Storable. -func (*PaymentPlanTemplate) Collection() string { - return mservice.PaymentPlanTemplates -} - -// Normalize standardizes template fields for matching and indexing. -func (t *PaymentPlanTemplate) Normalize() { - if t == nil { - return - } - t.FromRail = normalizeRail(t.FromRail) - t.ToRail = normalizeRail(t.ToRail) - t.Network = strings.ToUpper(strings.TrimSpace(t.Network)) - if len(t.Steps) == 0 { - return - } - for i := range t.Steps { - step := &t.Steps[i] - step.StepID = strings.TrimSpace(step.StepID) - step.Rail = normalizeRail(step.Rail) - step.Operation = strings.ToLower(strings.TrimSpace(step.Operation)) - step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility) - step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) - step.DependsOn = normalizeStringList(step.DependsOn) - step.CommitAfter = normalizeStringList(step.CommitAfter) - step.FromRole = normalizeAccountRole(step.FromRole) - step.ToRole = normalizeAccountRole(step.ToRole) - } -} - -func normalizeAccountRole(role *account_role.AccountRole) *account_role.AccountRole { - if role == nil { - return nil - } - trimmed := strings.TrimSpace(string(*role)) - if trimmed == "" { - return nil - } - if parsed, ok := account_role.Parse(trimmed); ok { - if parsed == "" { - return nil - } - normalized := parsed - return &normalized - } - normalized := account_role.AccountRole(strings.ToLower(trimmed)) - return &normalized -} - -// PaymentPlanTemplateFilter selects templates for lookup. -type PaymentPlanTemplateFilter struct { - FromRail Rail - ToRail Rail - Network string - IsEnabled *bool -} - -// PaymentPlanTemplateList holds template results. -type PaymentPlanTemplateList struct { - Items []*PaymentPlanTemplate -} diff --git a/api/payments/storage/mongo/repository.go b/api/payments/storage/mongo/repository.go index d9c1cd74..7d048d7f 100644 --- a/api/payments/storage/mongo/repository.go +++ b/api/payments/storage/mongo/repository.go @@ -29,7 +29,6 @@ type Store struct { methods storage.PaymentMethodsStore quotes quotestorage.QuotesStore routes storage.RoutesStore - plans storage.PlanTemplatesStore } type paymentMethodsConfig struct { @@ -70,22 +69,21 @@ func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Stor paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection()) quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection()) routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection()) - plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection()) methodsRepo := repository.CreateMongoRepository(conn.Database(), mservice.PaymentMethods) - return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...) + return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, opts...) } // NewWithRepository constructs a payments repository using the provided primitives. -func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, plansRepo repository.Repository, opts ...Option) (*Store, error) { - return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...) +func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, opts ...Option) (*Store, error) { + return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, opts...) } func newWithRepository( logger mlogger.Logger, ping func(context.Context) error, database *mongo.Database, - paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo repository.Repository, + paymentsRepo, methodsRepo, quotesRepo, routesRepo repository.Repository, opts ...Option, ) (*Store, error) { if ping == nil { @@ -100,9 +98,6 @@ func newWithRepository( if routesRepo == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil") } - if plansRepo == nil { - return nil, merrors.InvalidArgument("payments.storage.mongo: plan templates repository is nil") - } cfg := options{} for _, opt := range opts { @@ -124,10 +119,6 @@ func newWithRepository( if err != nil { return nil, err } - plansStore, err := store.NewPlanTemplates(childLogger, plansRepo) - if err != nil { - return nil, err - } var methodsStore storage.PaymentMethodsStore if cfg.paymentMethodsAuth != nil { @@ -155,7 +146,6 @@ func newWithRepository( methods: methodsStore, quotes: quotesRepoStore.Quotes(), routes: routesStore, - plans: plansStore, } return result, nil @@ -189,11 +179,6 @@ func (s *Store) Routes() storage.RoutesStore { return s.routes } -// PlanTemplates returns the plan templates store. -func (s *Store) PlanTemplates() storage.PlanTemplatesStore { - return s.plans -} - // MongoDatabase returns underlying Mongo database when available. func (s *Store) MongoDatabase() *mongo.Database { if s == nil { diff --git a/api/payments/storage/mongo/store/plan_templates.go b/api/payments/storage/mongo/store/plan_templates.go deleted file mode 100644 index 8b5f7682..00000000 --- a/api/payments/storage/mongo/store/plan_templates.go +++ /dev/null @@ -1,174 +0,0 @@ -package store - -import ( - "context" - "errors" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/db/repository" - ri "github.com/tech/sendico/pkg/db/repository/index" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type PlanTemplates struct { - logger mlogger.Logger - repo repository.Repository -} - -// NewPlanTemplates constructs a Mongo-backed plan template store. -func NewPlanTemplates(logger mlogger.Logger, repo repository.Repository) (*PlanTemplates, error) { - if repo == nil { - return nil, merrors.InvalidArgument("planTemplatesStore: repository is nil") - } - - indexes := []*ri.Definition{ - { - Keys: []ri.Key{ - {Field: "fromRail", Sort: ri.Asc}, - {Field: "toRail", Sort: ri.Asc}, - {Field: "network", Sort: ri.Asc}, - }, - Unique: true, - }, - { - Keys: []ri.Key{{Field: "fromRail", Sort: ri.Asc}}, - }, - { - Keys: []ri.Key{{Field: "toRail", Sort: ri.Asc}}, - }, - { - Keys: []ri.Key{{Field: "isEnabled", Sort: ri.Asc}}, - }, - } - - for _, def := range indexes { - if err := repo.CreateIndex(def); err != nil { - logger.Error("Failed to ensure plan templates index", zap.Error(err), zap.String("collection", repo.Collection())) - return nil, err - } - } - - return &PlanTemplates{ - logger: logger.Named("plan_templates"), - repo: repo, - }, nil -} - -func (p *PlanTemplates) Create(ctx context.Context, template *model.PaymentPlanTemplate) error { - if template == nil { - return merrors.InvalidArgument("planTemplatesStore: nil template") - } - template.Normalize() - if template.FromRail == "" || template.FromRail == model.RailUnspecified { - return merrors.InvalidArgument("planTemplatesStore: from_rail is required") - } - if template.ToRail == "" || template.ToRail == model.RailUnspecified { - return merrors.InvalidArgument("planTemplatesStore: to_rail is required") - } - if len(template.Steps) == 0 { - return merrors.InvalidArgument("planTemplatesStore: steps are required") - } - if template.ID.IsZero() { - template.SetID(bson.NewObjectID()) - } else { - template.Update() - } - - filter := repository.Filter("fromRail", template.FromRail).And( - repository.Filter("toRail", template.ToRail), - repository.Filter("network", template.Network), - ) - - if err := p.repo.Insert(ctx, template, filter); err != nil { - if errors.Is(err, merrors.ErrDataConflict) { - return storage.ErrDuplicatePlanTemplate - } - return err - } - return nil -} - -func (p *PlanTemplates) Update(ctx context.Context, template *model.PaymentPlanTemplate) error { - if template == nil { - return merrors.InvalidArgument("planTemplatesStore: nil template") - } - if template.ID.IsZero() { - return merrors.InvalidArgument("planTemplatesStore: missing template id") - } - template.Normalize() - template.Update() - if err := p.repo.Update(ctx, template); err != nil { - if errors.Is(err, merrors.ErrNoData) { - return storage.ErrPlanTemplateNotFound - } - return err - } - return nil -} - -func (p *PlanTemplates) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentPlanTemplate, error) { - if id == bson.NilObjectID { - return nil, merrors.InvalidArgument("planTemplatesStore: template id is required") - } - entity := &model.PaymentPlanTemplate{} - if err := p.repo.Get(ctx, id, entity); err != nil { - if errors.Is(err, merrors.ErrNoData) { - return nil, storage.ErrPlanTemplateNotFound - } - return nil, err - } - entity.Normalize() - return entity, nil -} - -func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { - if filter == nil { - filter = &model.PaymentPlanTemplateFilter{} - } - - query := repository.Query() - - if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 { - query = query.Filter(repository.Field("fromRail"), from[0]) - } else if len(from) > 1 { - query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...) - } - if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 { - query = query.Filter(repository.Field("toRail"), to[0]) - } else if len(to) > 1 { - query = query.In(repository.Field("toRail"), stringSliceToAny(to)...) - } - if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" { - query = query.Filter(repository.Field("network"), network) - } - if filter.IsEnabled != nil { - query = query.Filter(repository.Field("isEnabled"), *filter.IsEnabled) - } - - templates := make([]*model.PaymentPlanTemplate, 0) - decoder := func(cur *mongo.Cursor) error { - item := &model.PaymentPlanTemplate{} - if err := cur.Decode(item); err != nil { - return err - } - item.Normalize() - templates = append(templates, item) - return nil - } - - if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) { - return nil, err - } - - return &model.PaymentPlanTemplateList{ - Items: templates, - }, nil -} - -var _ storage.PlanTemplatesStore = (*PlanTemplates)(nil) diff --git a/api/payments/storage/storage.go b/api/payments/storage/storage.go index c40d683e..30cbb710 100644 --- a/api/payments/storage/storage.go +++ b/api/payments/storage/storage.go @@ -19,10 +19,6 @@ var ( ErrRouteNotFound = errors.New("payments.storage: route not found") // ErrDuplicateRoute signals that a route already exists for the same transition. ErrDuplicateRoute = errors.New("payments.storage: duplicate route") - // ErrPlanTemplateNotFound signals that a plan template record does not exist. - ErrPlanTemplateNotFound = errors.New("payments.storage: plan template not found") - // ErrDuplicatePlanTemplate signals that a plan template already exists for the same transition. - ErrDuplicatePlanTemplate = errors.New("payments.storage: duplicate plan template") ) // Repository exposes persistence primitives for the payments domain. @@ -32,7 +28,6 @@ type Repository interface { PaymentMethods() PaymentMethodsStore Quotes() quotestorage.QuotesStore Routes() RoutesStore - PlanTemplates() PlanTemplatesStore } // PaymentsStore manages payment lifecycle state. @@ -68,11 +63,3 @@ type RoutesStore interface { GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentRoute, error) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) } - -// PlanTemplatesStore manages orchestration plan templates. -type PlanTemplatesStore interface { - Create(ctx context.Context, template *model.PaymentPlanTemplate) error - Update(ctx context.Context, template *model.PaymentPlanTemplate) error - GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentPlanTemplate, error) - List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) -} diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index 5ac96793..68d1f42a 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -5,55 +5,54 @@ import "github.com/tech/sendico/pkg/merrors" type Type = string const ( - Accounts Type = "accounts" // Represents user accounts in the system - Verification Type = "verification" // Represents verification code flows - Amplitude Type = "amplitude" // Represents analytics integration with Amplitude - Discovery Type = "discovery" // Represents service discovery registry - Site Type = "site" // Represents public site endpoints - Changes Type = "changes" // Tracks changes made to resources - Clients Type = "clients" // Represents client information - ChainGateway Type = "chain_gateway" // Represents chain gateway microservice - MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice - PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice - FXOracle Type = "fx_oracle" // Represents FX oracle microservice - FeePlans Type = "fee_plans" // Represents fee plans microservice - BillingDocuments Type = "billing_documents" // Represents billing documents microservice - FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources - Invitations Type = "invitations" // Represents invitations sent to users - Invoices Type = "invoices" // Represents invoices - Logo Type = "logo" // Represents logos for organizations or projects - Ledger Type = "ledger" // Represents ledger microservice - LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice - LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice - LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice - LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice - LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice - LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice - PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice - ChainAssets Type = "chain_assets" // Represents managed chain assets - ChainWallets Type = "chain_wallets" // Represents managed chain wallets - ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances - ChainTransfers Type = "chain_transfers" // Represents chain transfers - ChainDeposits Type = "chain_deposits" // Represents chain deposits - Notifications Type = "notifications" // Represents notifications sent to users - Organizations Type = "organizations" // Represents organizations in the system - Payments Type = "payments" // Represents payments service - PaymentRoutes Type = "payment_routes" // Represents payment routing definitions - PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates - PaymentMethods Type = "payment_methods" // Represents payment methods service - Permissions Type = "permissions" // Represents permissiosns service - Policies Type = "policies" // Represents access control policies - PolicyAssignements Type = "policy_assignments" // Represents policy assignments database - Recipients Type = "recipients" // Represents payment recipients - RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication - Roles Type = "roles" // Represents roles in access control - Storage Type = "storage" // Represents statuses of tasks or projects - TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway - Tenants Type = "tenants" // Represents tenants managed in the system - VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system - Wallets Type = "wallets" // Represents workflows for tasks or projects - WalletRoutes Type = "wallet_routes" // Represents authoritative chain wallet gateway routing - Workflows Type = "workflows" // Represents workflows for tasks or projects + Accounts Type = "accounts" // Represents user accounts in the system + Verification Type = "verification" // Represents verification code flows + Amplitude Type = "amplitude" // Represents analytics integration with Amplitude + Discovery Type = "discovery" // Represents service discovery registry + Site Type = "site" // Represents public site endpoints + Changes Type = "changes" // Tracks changes made to resources + Clients Type = "clients" // Represents client information + ChainGateway Type = "chain_gateway" // Represents chain gateway microservice + MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice + PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice + FXOracle Type = "fx_oracle" // Represents FX oracle microservice + FeePlans Type = "fee_plans" // Represents fee plans microservice + BillingDocuments Type = "billing_documents" // Represents billing documents microservice + FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources + Invitations Type = "invitations" // Represents invitations sent to users + Invoices Type = "invoices" // Represents invoices + Logo Type = "logo" // Represents logos for organizations or projects + Ledger Type = "ledger" // Represents ledger microservice + LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice + LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice + LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice + LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice + LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice + LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice + PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice + ChainAssets Type = "chain_assets" // Represents managed chain assets + ChainWallets Type = "chain_wallets" // Represents managed chain wallets + ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances + ChainTransfers Type = "chain_transfers" // Represents chain transfers + ChainDeposits Type = "chain_deposits" // Represents chain deposits + Notifications Type = "notifications" // Represents notifications sent to users + Organizations Type = "organizations" // Represents organizations in the system + Payments Type = "payments" // Represents payments service + PaymentRoutes Type = "payment_routes" // Represents payment routing definitions + PaymentMethods Type = "payment_methods" // Represents payment methods service + Permissions Type = "permissions" // Represents permissiosns service + Policies Type = "policies" // Represents access control policies + PolicyAssignements Type = "policy_assignments" // Represents policy assignments database + Recipients Type = "recipients" // Represents payment recipients + RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication + Roles Type = "roles" // Represents roles in access control + Storage Type = "storage" // Represents statuses of tasks or projects + TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway + Tenants Type = "tenants" // Represents tenants managed in the system + VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system + Wallets Type = "wallets" // Represents workflows for tasks or projects + WalletRoutes Type = "wallet_routes" // Represents authoritative chain wallet gateway routing + Workflows Type = "workflows" // Represents workflows for tasks or projects ) func StringToSType(s string) (Type, error) { @@ -61,7 +60,7 @@ func StringToSType(s string) (Type, error) { case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances, ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger, LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, - Organizations, Payments, PaymentRoutes, PaymentPlanTemplates, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements, + Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements, Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery: return Type(s), nil default: -- 2.49.1 From 70b1c2a9cc069a4e5f5db046ef4294ade56b9395 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 02:39:48 +0100 Subject: [PATCH 12/23] +source currency pick fix +fx side propagation --- api/gateway/mntx/config.dev.yml | 4 +- api/gateway/mntx/config.yml | 4 +- .../internal/server/internal/serverimp.go | 11 +- .../service/gateway/card_processor.go | 2 +- api/payments/orchestrator/config.dev.yml | 4 +- api/payments/orchestrator/config.yml | 4 +- .../service/orchestrationv2/agg/module.go | 26 ++- .../service/orchestrationv2/agg/service.go | 15 +- .../orchestrationv2/agg/service_test.go | 26 ++- .../orchestrationv2/opagg/fixtures_test.go | 6 +- .../service/orchestrationv2/prepo/document.go | 5 + .../orchestrationv2/prmap/service_test.go | 34 ++- .../orchestrationv2/prmap/state_mapping.go | 16 ++ .../orchestrationv2/prmap/step_mapping.go | 18 +- .../service/orchestrationv2/psvc/execute.go | 6 +- .../orchestrationv2/psvc/service_e2e_test.go | 33 +-- .../qsnap/resolve_errors_test.go | 71 +++--- .../qsnap/resolve_shapes_test.go | 123 +++++----- .../service/orchestrationv2/qsnap/service.go | 172 +++++++------- .../service/orchestrationv2/ssched/input.go | 27 ++- .../orchestrationv2/ssched/service_test.go | 78 +++++++ .../xplan/compile_flow_test.go | 37 ++- .../xplan/service_boundaries.go | 32 +++ .../orchestrator/card_payout_executor_test.go | 4 +- .../orchestrator/crypto_executor_test.go | 6 +- .../orchestrator/external_runtime_test.go | 8 +- .../orchestrator/guard_executor_test.go | 4 +- .../orchestrator/ledger_executor_test.go | 102 ++++++++ .../quotation/card_payout_constants.go | 4 +- .../funding_profile_resolver_static.go | 2 +- .../funding_profile_resolver_static_test.go | 16 +- .../service/quotation/internal_helpers.go | 16 +- .../quotation/internal_helpers_test.go | 27 +++ .../managed_wallet_network_resolver.go | 164 ++++++++----- .../quotation_service_v2/process_batch.go | 19 +- .../quotation_service_v2/process_single.go | 11 +- .../quotation/quotation_service_v2/reuse.go | 49 ++-- .../quotation_service_v2/service_e2e_test.go | 219 ++++++++++++++++-- .../quote_computation_service/compute_test.go | 71 +++++- .../intent_adapters.go | 68 ++++++ .../intent_adapters_test.go | 71 ++++++ .../managed_wallet_network.go | 34 ++- .../managed_wallet_network_test.go | 116 +++++++++- .../quote_computation_service/planner.go | 1 + .../planner_steps.go | 83 ++++++- .../planner_steps_test.go | 93 ++++++++ .../quote_idempotency_service/reuse.go | 6 +- .../quote_idempotency_service/reuse_test.go | 92 ++++---- .../quote_persistence_service/input.go | 17 +- .../quote_persistence_service/service.go | 59 +++-- .../quote_persistence_service/service_test.go | 107 +++++---- .../status_mapper.go | 16 -- .../quote_response_mapper_v2/service_test.go | 5 +- .../endpoint_resolver.go | 1 - .../transfer_intent_hydrator/hydrator.go | 13 ++ .../transfer_intent_hydrator/quote_intent.go | 1 + .../transfer_intent_hydrator_test.go | 55 ++++- api/payments/storage/model/quote.go | 21 +- api/payments/storage/model/quote_v2.go | 16 ++ .../storage/quote/mongo/store/quotes.go | 39 +++- api/pkg/payments/types/gateway_ids.go | 6 + .../orchestration/v2/orchestration.proto | 18 ++ .../payments/quotation/v2/quotation.proto | 2 + api/server/interface/api/sresponse/money.go | 16 -- api/server/interface/api/sresponse/payment.go | 94 ++++++-- .../interface/api/sresponse/payment_test.go | 119 ++++++++++ .../internal/server/paymentapiimp/mapper.go | 16 ++ .../mapper_fee_treatment_test.go | 51 ++++ .../lib/data/dto/payment/intent/payment.dart | 3 +- .../lib/data/mapper/payment/intent/fx.dart | 28 +-- frontend/pshared/pubspec.yaml | 2 +- .../test/payment/request_dto_format_test.dart | 32 +++ frontend/pweb/pubspec.yaml | 2 +- 73 files changed, 2123 insertions(+), 656 deletions(-) create mode 100644 api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters_test.go create mode 100644 api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps_test.go create mode 100644 api/pkg/payments/types/gateway_ids.go create mode 100644 api/server/interface/api/sresponse/payment_test.go diff --git a/api/gateway/mntx/config.dev.yml b/api/gateway/mntx/config.dev.yml index a9b5261c..0bc18a93 100644 --- a/api/gateway/mntx/config.dev.yml +++ b/api/gateway/mntx/config.dev.yml @@ -35,7 +35,7 @@ messaging: reconnect_wait: 5 buffer_size: 1024 -monetix: +mcards: base_url_env: MONETIX_BASE_URL project_id_env: MONETIX_PROJECT_ID secret_key_env: MONETIX_SECRET_KEY @@ -46,7 +46,7 @@ monetix: status_processing: "processing" gateway: - id: "monetix" + id: "mcards" is_enabled: true network: "MIR" currencies: ["RUB"] diff --git a/api/gateway/mntx/config.yml b/api/gateway/mntx/config.yml index 48d98d51..31bf0022 100644 --- a/api/gateway/mntx/config.yml +++ b/api/gateway/mntx/config.yml @@ -35,7 +35,7 @@ messaging: reconnect_wait: 5 buffer_size: 1024 -monetix: +mcards: base_url_env: MONETIX_BASE_URL project_id_env: MONETIX_PROJECT_ID secret_key_env: MONETIX_SECRET_KEY @@ -46,7 +46,7 @@ monetix: status_processing: "processing" gateway: - id: "monetix" + id: "mcards" is_enabled: true network: "MIR" currencies: ["RUB"] diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go index 70f133d9..796cd24a 100644 --- a/api/gateway/mntx/internal/server/internal/serverimp.go +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -22,6 +22,7 @@ import ( "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + paymenttypes "github.com/tech/sendico/pkg/payments/types" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" "github.com/tech/sendico/pkg/server/grpcapp" "go.uber.org/zap" @@ -41,7 +42,7 @@ type Imp struct { type config struct { *grpcapp.Config `yaml:",inline"` - Monetix monetixConfig `yaml:"monetix"` + Monetix monetixConfig `yaml:"mcards"` Gateway gatewayConfig `yaml:"gateway"` HTTP httpConfig `yaml:"http"` } @@ -216,7 +217,7 @@ func (i *Imp) Start() error { return gatewaymongo.New(logger, conn) } - app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, repoFactory, serviceFactory) + app, err := grpcapp.NewApp(i.logger, paymenttypes.DefaultCardsGatewayID, cfg.Config, i.debug, repoFactory, serviceFactory) if err != nil { return err } @@ -275,7 +276,7 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) { if id, err := strconv.ParseInt(raw, 10, 64); err == nil { projectID = id } else { - return monetix.Config{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "monetix.project_id") + return monetix.Config{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "mcards.project_id") } } } @@ -310,7 +311,7 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) { func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gatewayv1.GatewayInstanceDescriptor { id := strings.TrimSpace(cfg.ID) if id == "" { - id = "monetix" + id = paymenttypes.DefaultCardsGatewayID } network := strings.ToUpper(strings.TrimSpace(cfg.Network)) @@ -444,7 +445,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig, } path := strings.TrimSpace(cfg.Path) if path == "" { - path = "/monetix/callback" + path = "/" + paymenttypes.DefaultCardsGatewayID + "/callback" } maxBody := cfg.MaxBodyBytes if maxBody <= 0 { diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index a134e217..dbf10cc1 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -78,7 +78,7 @@ func (p *cardPayoutProcessor) resolveProjectID(requestProjectID int64, logFieldK } if projectID == 0 { p.logger.Warn("Monetix project_id is not configured", zap.String(logFieldKey, logFieldValue)) - return 0, merrors.Internal("monetix project_id is not configured") + return 0, merrors.Internal("mcards project_id is not configured") } return projectID, nil } diff --git a/api/payments/orchestrator/config.dev.yml b/api/payments/orchestrator/config.dev.yml index e75ea97e..310f97e5 100644 --- a/api/payments/orchestrator/config.dev.yml +++ b/api/payments/orchestrator/config.dev.yml @@ -42,11 +42,11 @@ max_fx_quote_ttl_ms: 600000 # Service endpoints are sourced from discovery; no static overrides. card_gateways: - monetix: + mcards: funding_address: "TUaWaCkiXwYPKm5qjcB27Lhwv976vPvedE" fee_wallet_ref: "697a062a248dc785125ccb9e" fee_ledger_accounts: - monetix: "697a15cc72e95c92d4c5db01" + mcards: "697a15cc72e95c92d4c5db01" # Gateway instances and capabilities are sourced from service discovery. diff --git a/api/payments/orchestrator/config.yml b/api/payments/orchestrator/config.yml index e7fb1e14..0f5e3f07 100644 --- a/api/payments/orchestrator/config.yml +++ b/api/payments/orchestrator/config.yml @@ -42,11 +42,11 @@ max_fx_quote_ttl_ms: 600000 # Service endpoints are sourced from discovery; no static overrides. card_gateways: - monetix: + mcards: funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF" fee_wallet_ref: "694c124ed76f9f811ac57133" fee_ledger_accounts: - monetix: "ledger:fees:monetix" + mcards: "ledger:fees:monetix" # Gateway instances and capabilities are sourced from service discovery. diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go index cf06e307..2402ee11 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go @@ -43,21 +43,25 @@ const ( // StepShell defines one initial step telemetry item. type StepShell struct { - StepRef string `bson:"stepRef" json:"stepRef"` - StepCode string `bson:"stepCode" json:"stepCode"` + StepRef string `bson:"stepRef" json:"stepRef"` + StepCode string `bson:"stepCode" json:"stepCode"` + ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"` + UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"` } // StepExecution is runtime telemetry for one step. type StepExecution struct { - StepRef string `bson:"stepRef" json:"stepRef"` - StepCode string `bson:"stepCode" json:"stepCode"` - State StepState `bson:"state" json:"state"` - Attempt uint32 `bson:"attempt" json:"attempt"` - StartedAt *time.Time `bson:"startedAt,omitempty" json:"startedAt,omitempty"` - CompletedAt *time.Time `bson:"completedAt,omitempty" json:"completedAt,omitempty"` - FailureCode string `bson:"failureCode,omitempty" json:"failureCode,omitempty"` - FailureMsg string `bson:"failureMsg,omitempty" json:"failureMsg,omitempty"` - ExternalRefs []ExternalRef `bson:"externalRefs,omitempty" json:"externalRefs,omitempty"` + StepRef string `bson:"stepRef" json:"stepRef"` + StepCode string `bson:"stepCode" json:"stepCode"` + ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"` + UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"` + State StepState `bson:"state" json:"state"` + Attempt uint32 `bson:"attempt" json:"attempt"` + StartedAt *time.Time `bson:"startedAt,omitempty" json:"startedAt,omitempty"` + CompletedAt *time.Time `bson:"completedAt,omitempty" json:"completedAt,omitempty"` + FailureCode string `bson:"failureCode,omitempty" json:"failureCode,omitempty"` + FailureMsg string `bson:"failureMsg,omitempty" json:"failureMsg,omitempty"` + ExternalRefs []ExternalRef `bson:"externalRefs,omitempty" json:"externalRefs,omitempty"` } // ExternalRef links step execution to an external operation. diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go index 2011cab0..38d16240 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go @@ -137,12 +137,19 @@ func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) { if stepCode == "" { return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_code is required") } + visibility := model.NormalizeReportVisibility(shell[i].ReportVisibility) + if !model.IsValidReportVisibility(visibility) { + return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].report_visibility is invalid") + } + userLabel := strings.TrimSpace(shell[i].UserLabel) out = append(out, StepExecution{ - StepRef: stepRef, - StepCode: stepCode, - State: StepStatePending, - Attempt: 1, + StepRef: stepRef, + StepCode: stepCode, + ReportVisibility: visibility, + UserLabel: userLabel, + State: StepStatePending, + Attempt: 1, }) } return out, nil diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go index 7045157e..fd551e90 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go @@ -41,8 +41,8 @@ func TestCreate_OK(t *testing.T) { IntentSnapshot: intent, QuoteSnapshot: quote, Steps: []StepShell{ - {StepRef: " s1 ", StepCode: " reserve_funds "}, - {StepRef: "s2", StepCode: "submit_gateway"}, + {StepRef: " s1 ", StepCode: " reserve_funds ", ReportVisibility: model.ReportVisibilityHidden}, + {StepRef: "s2", StepCode: "submit_gateway", ReportVisibility: model.ReportVisibilityUser, UserLabel: " Card payout "}, }, }) if err != nil { @@ -102,6 +102,15 @@ func TestCreate_OK(t *testing.T) { if payment.StepExecutions[0].State != StepStatePending || payment.StepExecutions[0].Attempt != 1 { t.Fatalf("unexpected first step shell state: %+v", payment.StepExecutions[0]) } + if got, want := payment.StepExecutions[0].ReportVisibility, model.ReportVisibilityHidden; got != want { + t.Fatalf("unexpected first step visibility: got=%q want=%q", got, want) + } + if got, want := payment.StepExecutions[1].ReportVisibility, model.ReportVisibilityUser; got != want { + t.Fatalf("unexpected second step visibility: got=%q want=%q", got, want) + } + if got, want := payment.StepExecutions[1].UserLabel, "Card payout"; got != want { + t.Fatalf("unexpected second step user label: got=%q want=%q", got, want) + } // Verify immutable snapshot semantics by ensuring clones were created. payment.IntentSnapshot.Ref = "changed" @@ -233,6 +242,19 @@ func TestCreate_InputValidation(t *testing.T) { }, }, }, + { + name: "step report visibility invalid", + in: Input{ + OrganizationRef: bson.NewObjectID(), + IdempotencyKey: "idem-1", + QuotationRef: "quote-1", + IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()}, + QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"}, + Steps: []StepShell{ + {StepRef: "s1", StepCode: "code-1", ReportVisibility: model.ReportVisibility("invalid")}, + }, + }, + }, { name: "step ref must be unique", in: Input{ diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go index 8fd9bab7..d67b68f1 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go @@ -62,14 +62,14 @@ func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuo Money: &paymenttypes.Money{Amount: fee, Currency: "USDT"}, LineType: paymenttypes.PostingLineTypeFee, Side: paymenttypes.EntrySideDebit, - Meta: map[string]string{"component": "platform_fee", "provider": "monetix"}, + Meta: map[string]string{"component": "platform_fee", "provider": paymenttypes.DefaultCardsGatewayID}, }, { LedgerAccountRef: "ledger:tax:usdt", Money: &paymenttypes.Money{Amount: tax, Currency: "USDT"}, LineType: paymenttypes.PostingLineTypeTax, Side: paymenttypes.EntrySideDebit, - Meta: map[string]string{"component": "vat", "provider": "monetix"}, + Meta: map[string]string{"component": "vat", "provider": paymenttypes.DefaultCardsGatewayID}, }, }, FeeRules: []*paymenttypes.AppliedRule{ @@ -101,7 +101,7 @@ func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuo Hops: []*paymenttypes.QuoteRouteHop{ {Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, {Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"}, - {Index: 3, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"}, + {Index: 3, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: paymenttypes.DefaultCardsGatewayID}, }, Settlement: &paymenttypes.QuoteRouteSettlement{ Model: "fix_source", diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go index c06f6956..8e316323 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go @@ -97,6 +97,11 @@ func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution { step := src[i] step.StepRef = strings.TrimSpace(step.StepRef) step.StepCode = strings.TrimSpace(step.StepCode) + step.ReportVisibility = model.NormalizeReportVisibility(step.ReportVisibility) + if !model.IsValidReportVisibility(step.ReportVisibility) { + step.ReportVisibility = model.ReportVisibilityUnspecified + } + step.UserLabel = strings.TrimSpace(step.UserLabel) step.FailureCode = strings.TrimSpace(step.FailureCode) step.FailureMsg = strings.TrimSpace(step.FailureMsg) if step.Attempt == 0 { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go index 0b25dfd9..b3291681 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -112,6 +112,15 @@ func TestMap_Success(t *testing.T) { if got, want := steps[1].GetRefs()[0].GetRail(), gatewayv1.Rail_RAIL_LEDGER; got != want { t.Fatalf("external ref rail mismatch: got=%s want=%s", got.String(), want.String()) } + if got, want := steps[0].GetReportVisibility(), orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER; got != want { + t.Fatalf("report_visibility mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := steps[0].GetUserLabel(), "Card payout"; got != want { + t.Fatalf("user_label mismatch: got=%q want=%q", got, want) + } + if got, want := steps[1].GetReportVisibility(), orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN; got != want { + t.Fatalf("report_visibility mismatch: got=%s want=%s", got.String(), want.String()) + } } func TestMap_InvalidArguments(t *testing.T) { @@ -348,19 +357,22 @@ func newPaymentFixture() *agg.Payment { Version: 3, StepExecutions: []agg.StepExecution{ { - StepRef: "s1", - StepCode: "hop.20.card_payout.send", - State: agg.StepStateRunning, - Attempt: 0, - StartedAt: &startedAt, + StepRef: "s1", + StepCode: "hop.20.card_payout.send", + ReportVisibility: model.ReportVisibilityUser, + UserLabel: " Card payout ", + State: agg.StepStateRunning, + Attempt: 0, + StartedAt: &startedAt, }, { - StepRef: "s2", - StepCode: "edge.10_20.ledger.debit", - State: agg.StepStateFailed, - Attempt: 2, - FailureCode: "ledger_balance_low", - FailureMsg: "insufficient balance", + StepRef: "s2", + StepCode: "edge.10_20.ledger.debit", + ReportVisibility: model.ReportVisibilityHidden, + State: agg.StepStateFailed, + Attempt: 2, + FailureCode: "ledger_balance_low", + FailureMsg: "insufficient balance", ExternalRefs: []agg.ExternalRef{ { GatewayInstanceID: "ledger-1", diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go index fe5530ee..a272543d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/storage/model" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" @@ -81,6 +82,21 @@ func mapStepState(state agg.StepState) orchestrationv2.StepExecutionState { } } +func mapReportVisibility(visibility model.ReportVisibility) orchestrationv2.ReportVisibility { + switch model.NormalizeReportVisibility(visibility) { + case model.ReportVisibilityHidden: + return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN + case model.ReportVisibilityUser: + return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER + case model.ReportVisibilityBackoffice: + return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE + case model.ReportVisibilityAudit: + return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_AUDIT + default: + return orchestrationv2.ReportVisibility_REPORT_VISIBILITY_UNSPECIFIED + } +} + func inferFailureCategory(failureCode string) sharedv1.PaymentFailureCode { code := strings.ToLower(strings.TrimSpace(failureCode)) switch { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go index a56147b2..25cbdcd3 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go @@ -36,14 +36,16 @@ func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepE } return &orchestrationv2.StepExecution{ - StepRef: strings.TrimSpace(step.StepRef), - StepCode: strings.TrimSpace(step.StepCode), - State: mapStepState(state), - Attempt: attempt, - StartedAt: tsOrNil(derefTime(step.StartedAt)), - CompletedAt: tsOrNil(derefTime(step.CompletedAt)), - Failure: mapStepFailure(step, state), - Refs: mapExternalRefs(step.StepCode, step.ExternalRefs), + StepRef: strings.TrimSpace(step.StepRef), + StepCode: strings.TrimSpace(step.StepCode), + State: mapStepState(state), + Attempt: attempt, + StartedAt: tsOrNil(derefTime(step.StartedAt)), + CompletedAt: tsOrNil(derefTime(step.CompletedAt)), + Failure: mapStepFailure(step, state), + Refs: mapExternalRefs(step.StepCode, step.ExternalRefs), + ReportVisibility: mapReportVisibility(step.ReportVisibility), + UserLabel: strings.TrimSpace(step.UserLabel), }, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index afe1e0af..dd0c555e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -224,8 +224,10 @@ func toStepShells(graph *xplan.Graph) []agg.StepShell { out := make([]agg.StepShell, 0, len(graph.Steps)) for i := range graph.Steps { out = append(out, agg.StepShell{ - StepRef: graph.Steps[i].StepRef, - StepCode: graph.Steps[i].StepCode, + StepRef: graph.Steps[i].StepRef, + StepCode: graph.Steps[i].StepCode, + ReportVisibility: graph.Steps[i].Visibility, + UserLabel: graph.Steps[i].UserLabel, }) } return out diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 1ea46f17..82a3f38f 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -603,21 +603,26 @@ func newExecutableQuote(orgRef bson.ObjectID, quoteRef, intentRef string, route OrganizationBoundBase: pm.OrganizationBoundBase{ OrganizationRef: orgRef, }, - QuoteRef: quoteRef, - Intent: model.PaymentIntent{ - Ref: intentRef, - Kind: model.PaymentKindPayout, - Source: testLedgerEndpoint("ledger-src"), - Destination: testLedgerEndpoint("ledger-dst"), - Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, - SettlementCurrency: "USD", + QuoteRef: quoteRef, + RequestShape: model.QuoteRequestShapeSingle, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{ + Ref: intentRef, + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: testLedgerEndpoint("ledger-dst"), + Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + SettlementCurrency: "USD", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + Route: route, + }, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, }, - Quote: &model.PaymentQuoteSnapshot{ - QuoteRef: quoteRef, - DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, - Route: route, - }, - StatusV2: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, ExpiresAt: now.Add(1 * time.Hour), } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go index 5fda64db..4f03aff1 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go @@ -35,13 +35,14 @@ func TestResolve_Expired(t *testing.T) { _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, - }, - Quote: &model.PaymentQuoteSnapshot{}, - StatusV2: &model.QuoteStatusV2{ - State: model.QuoteStateExecutable, + QuoteRef: "quote-ref", + RequestShape: model.QuoteRequestShapeSingle, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, }, ExpiresAt: now.Add(-time.Second), }, nil @@ -62,14 +63,17 @@ func TestResolve_NotExecutableState(t *testing.T) { _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, - }, - Quote: &model.PaymentQuoteSnapshot{}, - StatusV2: &model.QuoteStatusV2{ - State: model.QuoteStateBlocked, - BlockReason: model.QuoteBlockReasonRouteUnavailable, + QuoteRef: "quote-ref", + RequestShape: model.QuoteRequestShapeSingle, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{ + State: model.QuoteStateBlocked, + BlockReason: model.QuoteBlockReasonRouteUnavailable, + }, + }, }, ExpiresAt: now.Add(time.Minute), }, nil @@ -83,20 +87,23 @@ func TestResolve_NotExecutableState(t *testing.T) { } } -func TestResolve_NotExecutableExecutionNote(t *testing.T) { +func TestResolve_NotExecutableIndicative(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, + QuoteRef: "quote-ref", + RequestShape: model.QuoteRequestShapeSingle, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateIndicative}, + }, }, - Quote: &model.PaymentQuoteSnapshot{}, - ExecutionNote: "quote will not be executed", - ExpiresAt: now.Add(time.Minute), + ExpiresAt: now.Add(time.Minute), }, nil }, }, Input{ @@ -115,13 +122,19 @@ func TestResolve_ShapeMismatch(t *testing.T) { _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intents: []model.PaymentIntent{ - {Kind: model.PaymentKindPayout}, - {Kind: model.PaymentKindPayout}, - }, - Quotes: []*model.PaymentQuoteSnapshot{ - {}, + QuoteRef: "quote-ref", + RequestShape: model.QuoteRequestShapeSingle, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, }, ExpiresAt: now.Add(time.Minute), }, nil diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go index 8afa5cf6..a6b65fcb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go @@ -17,18 +17,23 @@ func TestResolve_SingleShapeOK(t *testing.T) { orgID := bson.NewObjectID() record := &model.PaymentQuoteRecord{ - QuoteRef: "stored-quote-ref", - Intent: model.PaymentIntent{ - Ref: "intent-1", - Kind: model.PaymentKindPayout, + QuoteRef: "stored-quote-ref", + RequestShape: model.QuoteRequestShapeSingle, + ExpiresAt: now.Add(time.Minute), + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{ + Ref: "intent-1", + Kind: model.PaymentKindPayout, + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: "", + }, + Status: &model.QuoteStatusV2{ + State: model.QuoteStateExecutable, + }, + }, }, - Quote: &model.PaymentQuoteSnapshot{ - QuoteRef: "", - }, - StatusV2: &model.QuoteStatusV2{ - State: model.QuoteStateExecutable, - }, - ExpiresAt: now.Add(time.Minute), } resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) @@ -65,8 +70,8 @@ func TestResolve_SingleShapeOK(t *testing.T) { } out.QuoteSnapshot.QuoteRef = "changed" - if record.Quote.QuoteRef != "" { - t.Fatalf("expected stored quote snapshot to be unchanged, got %q", record.Quote.QuoteRef) + if record.Items[0].Quote.QuoteRef != "" { + t.Fatalf("expected stored quote snapshot to be unchanged, got %q", record.Items[0].Quote.QuoteRef) } } @@ -75,15 +80,14 @@ func TestResolve_ArrayShapeOK(t *testing.T) { orgID := bson.NewObjectID() record := &model.PaymentQuoteRecord{ - QuoteRef: "batch-like-single", - Intents: []model.PaymentIntent{ - {Ref: "intent-1", Kind: model.PaymentKindInternalTransfer}, - }, - Quotes: []*model.PaymentQuoteSnapshot{ - {QuoteRef: "snapshot-ref"}, - }, - StatusesV2: []*model.QuoteStatusV2{ - {State: model.QuoteStateExecutable}, + QuoteRef: "batch-like-single", + RequestShape: model.QuoteRequestShapeBatch, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindInternalTransfer}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "snapshot-ref"}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, }, ExpiresAt: now.Add(time.Minute), } @@ -123,18 +127,19 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) { orgID := bson.NewObjectID() record := &model.PaymentQuoteRecord{ - QuoteRef: "batch-quote-ref", - Intents: []model.PaymentIntent{ - {Ref: "intent-a", Kind: model.PaymentKindPayout}, - {Ref: "intent-b", Kind: model.PaymentKindPayout}, - }, - Quotes: []*model.PaymentQuoteSnapshot{ - {QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}}, - {QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "15", Currency: "USDT"}}, - }, - StatusesV2: []*model.QuoteStatusV2{ - {State: model.QuoteStateExecutable}, - {State: model.QuoteStateExecutable}, + QuoteRef: "batch-quote-ref", + RequestShape: model.QuoteRequestShapeBatch, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-a", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{Ref: "intent-b", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "15", Currency: "USDT"}}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, }, ExpiresAt: now.Add(time.Minute), } @@ -177,18 +182,19 @@ func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) { _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intents: []model.PaymentIntent{ - {Ref: "intent-1", Kind: model.PaymentKindPayout}, - {Ref: "intent-2", Kind: model.PaymentKindPayout}, - }, - Quotes: []*model.PaymentQuoteSnapshot{ - {}, - {}, - }, - StatusesV2: []*model.QuoteStatusV2{ - {State: model.QuoteStateExecutable}, - {State: model.QuoteStateExecutable}, + QuoteRef: "quote-ref", + RequestShape: model.QuoteRequestShapeBatch, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{Ref: "intent-2", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, }, ExpiresAt: now.Add(time.Minute), }, nil @@ -209,18 +215,19 @@ func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) { _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intents: []model.PaymentIntent{ - {Ref: "intent-1", Kind: model.PaymentKindPayout}, - {Ref: "intent-2", Kind: model.PaymentKindPayout}, - }, - Quotes: []*model.PaymentQuoteSnapshot{ - {}, - {}, - }, - StatusesV2: []*model.QuoteStatusV2{ - {State: model.QuoteStateExecutable}, - {State: model.QuoteStateExecutable}, + QuoteRef: "quote-ref", + RequestShape: model.QuoteRequestShapeBatch, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{Ref: "intent-2", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, }, ExpiresAt: now.Add(time.Minute), }, nil diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go index 91c56a2c..6adbf5bc 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go @@ -114,13 +114,8 @@ func ensureExecutable( return ErrQuoteExpired } - if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return xerr.Wrapf(ErrQuoteNotExecutable, "%s", note) - } - if status == nil { - // Legacy records may not have status metadata. - return nil + return xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil") } switch status.State { @@ -150,36 +145,75 @@ func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*res return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil") } - hasArrayShape := len(record.Intents) > 0 || len(record.Quotes) > 0 || len(record.StatusesV2) > 0 - if hasArrayShape { - return resolveArrayShapeItem(record, intentRef) + if len(record.Items) == 0 { + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "items are empty") + } + switch record.RequestShape { + case model.QuoteRequestShapeSingle: + if len(record.Items) != 1 { + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "single shape requires exactly one item") + } + return resolveItem(record.Items[0], intentRef) + case model.QuoteRequestShapeBatch: + index, err := resolveBatchItemIndex(record.Items, intentRef) + if err != nil { + return nil, err + } + return resolveItem(record.Items[index], intentRef) + default: + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "request shape is invalid") } - return resolveSingleShapeItem(record, intentRef) } -func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { - if record == nil { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil") - } - - if record.Quote == nil { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is empty") - } - if isEmptyIntentSnapshot(record.Intent) { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intent snapshot is empty") - } - if intentRef != "" { - recordIntentRef := strings.TrimSpace(record.Intent.Ref) - if recordIntentRef == "" || recordIntentRef != intentRef { - return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) +func resolveBatchItemIndex(items []*model.PaymentQuoteItemV2, intentRef string) (int, error) { + if len(items) == 1 { + if intentRef == "" { + return 0, nil } + item := items[0] + if item == nil || item.Intent == nil || strings.TrimSpace(item.Intent.Ref) != intentRef { + return -1, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) + } + return 0, nil } - intentSnapshot, err := cloneIntentSnapshot(record.Intent) + if intentRef == "" { + return -1, ErrIntentRefRequired + } + + index, found := findItemIndex(items, intentRef) + if !found { + return -1, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) + } + return index, nil +} + +func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuoteItem, error) { + if item == nil { + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "item is nil") + } + if item.Intent == nil { + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intent snapshot is nil") + } + if item.Quote == nil { + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is nil") + } + if item.Status == nil { + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil") + } + if intentRef != "" && strings.TrimSpace(item.Intent.Ref) != intentRef { + return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) + } + + intentSnapshot, err := cloneIntentSnapshot(*item.Intent) if err != nil { return nil, err } - quoteSnapshot, err := cloneQuoteSnapshot(record.Quote) + quoteSnapshot, err := cloneQuoteSnapshot(item.Quote) + if err != nil { + return nil, err + } + statusSnapshot, err := cloneStatusSnapshot(item.Status) if err != nil { return nil, err } @@ -187,76 +221,21 @@ func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) return &resolvedQuoteItem{ Intent: intentSnapshot, Quote: quoteSnapshot, - Status: record.StatusV2, - }, nil -} - -func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { - if len(record.Intents) == 0 { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents are empty") - } - if len(record.Quotes) == 0 { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quotes are empty") - } - if len(record.Intents) != len(record.Quotes) { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents and quotes count mismatch") - } - if len(record.StatusesV2) > 0 && len(record.StatusesV2) != len(record.Quotes) { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "statuses and quotes count mismatch") - } - - index := 0 - if len(record.Intents) > 1 { - if intentRef == "" { - return nil, ErrIntentRefRequired - } - selected, found := findIntentIndex(record.Intents, intentRef) - if !found { - return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) - } - index = selected - } else if intentRef != "" { - if strings.TrimSpace(record.Intents[0].Ref) != intentRef { - return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) - } - } - - quoteSnapshot := record.Quotes[index] - if quoteSnapshot == nil { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is nil") - } - - intentSnapshot, err := cloneIntentSnapshot(record.Intents[index]) - if err != nil { - return nil, err - } - clonedQuote, err := cloneQuoteSnapshot(quoteSnapshot) - if err != nil { - return nil, err - } - - var statusSnapshot *model.QuoteStatusV2 - if len(record.StatusesV2) > 0 { - if record.StatusesV2[index] == nil { - return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil") - } - statusSnapshot = record.StatusesV2[index] - } - - return &resolvedQuoteItem{ - Intent: intentSnapshot, - Quote: clonedQuote, Status: statusSnapshot, }, nil } -func findIntentIndex(intents []model.PaymentIntent, targetRef string) (int, bool) { +func findItemIndex(items []*model.PaymentQuoteItemV2, targetRef string) (int, bool) { target := strings.TrimSpace(targetRef) if target == "" { return -1, false } - for idx := range intents { - if strings.TrimSpace(intents[idx].Ref) == target { + for idx := range items { + item := items[idx] + if item == nil || item.Intent == nil { + continue + } + if strings.TrimSpace(item.Intent.Ref) == target { return idx, true } } @@ -291,6 +270,17 @@ func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSna return dst, nil } +func cloneStatusSnapshot(src *model.QuoteStatusV2) (*model.QuoteStatusV2, error) { + if src == nil { + return nil, nil + } + dst := &model.QuoteStatusV2{} + if err := bsonClone(src, dst); err != nil { + return nil, err + } + return dst, nil +} + func bsonClone(src any, dst any) error { data, err := bson.Marshal(src) if err != nil { @@ -298,7 +288,3 @@ func bsonClone(src any, dst any) error { } return bson.Unmarshal(data, dst) } - -func isEmptyIntentSnapshot(intent model.PaymentIntent) bool { - return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified) -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go index 2ff07d69..0def2253 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" ) @@ -143,6 +144,8 @@ func (s *svc) normalizeStepExecutions( stepCode = stepsByRef[stepRef].StepCode } exec.StepCode = stepCode + exec.ReportVisibility = effectiveStepVisibility(exec.ReportVisibility, stepsByRef[stepRef].Visibility) + exec.UserLabel = firstNonEmpty(exec.UserLabel, stepsByRef[stepRef].UserLabel) cloned := cloneStepExecution(exec) out[stepRef] = &cloned } @@ -154,10 +157,15 @@ func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.Ste exec.StepCode = strings.TrimSpace(exec.StepCode) exec.FailureCode = strings.TrimSpace(exec.FailureCode) exec.FailureMsg = strings.TrimSpace(exec.FailureMsg) + exec.UserLabel = strings.TrimSpace(exec.UserLabel) + exec.ReportVisibility = model.NormalizeReportVisibility(exec.ReportVisibility) exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs) if exec.StepRef == "" { return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].step_ref is required") } + if !model.IsValidReportVisibility(exec.ReportVisibility) { + return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].report_visibility is invalid") + } state, ok := normalizeStepState(exec.State) if !ok { @@ -187,14 +195,25 @@ func seedMissingExecutions( maxAttemptsByRef[stepRef] = 1 } executionsByRef[stepRef] = &agg.StepExecution{ - StepRef: step.StepRef, - StepCode: step.StepCode, - State: agg.StepStatePending, - Attempt: attempt, + StepRef: step.StepRef, + StepCode: step.StepCode, + ReportVisibility: effectiveStepVisibility(model.ReportVisibilityUnspecified, step.Visibility), + UserLabel: strings.TrimSpace(step.UserLabel), + State: agg.StepStatePending, + Attempt: attempt, } } } +func effectiveStepVisibility(execVisibility, graphVisibility model.ReportVisibility) model.ReportVisibility { + execVisibility = model.NormalizeReportVisibility(execVisibility) + graphVisibility = model.NormalizeReportVisibility(graphVisibility) + if execVisibility != model.ReportVisibilityUnspecified { + return execVisibility + } + return graphVisibility +} + func normalizeStepState(state agg.StepState) (agg.StepState, bool) { switch strings.ToLower(strings.TrimSpace(string(state))) { case "": diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go index 72949bd8..7e17b58e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go @@ -120,6 +120,84 @@ func TestSchedule_AfterFailureRunsWhenDependencyExhausted(t *testing.T) { assertBlockedReason(t, out, "observe", BlockedNeedsAttention) } +func TestSchedule_SendFailureRunsSendFailureReleaseAndSkipsObserveBranches(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("send", nil), + step("observe", []string{"send"}), + successStep("debit", "observe"), + failureStep("release_send", "send"), + failureStep("release_observe", "observe"), + }, + StepExecutions: []agg.StepExecution{ + exec("send", agg.StepStateFailed, 2), + exec("observe", agg.StepStatePending, 1), + exec("debit", agg.StepStatePending, 1), + exec("release_send", agg.StepStatePending, 1), + exec("release_observe", agg.StepStatePending, 1), + }, + Retry: RetryPolicy{MaxAttempts: 2}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + send := mustExecution(t, out, "send") + if send.State != agg.StepStateNeedsAttention { + t.Fatalf("send state mismatch: got=%q want=%q", send.State, agg.StepStateNeedsAttention) + } + + assertRunnableRefs(t, out, []string{"release_send"}) + assertSkippedRefs(t, out, []string{"observe", "debit", "release_observe"}) + assertBlockedReason(t, out, "send", BlockedNeedsAttention) + + releaseSend := mustExecution(t, out, "release_send") + if releaseSend.State != agg.StepStatePending { + t.Fatalf("release_send state mismatch: got=%q want=%q", releaseSend.State, agg.StepStatePending) + } +} + +func TestSchedule_ObserveFailureRunsObserveFailureReleaseAndSkipsSendFailureRelease(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("send", nil), + step("observe", []string{"send"}), + successStep("debit", "observe"), + failureStep("release_send", "send"), + failureStep("release_observe", "observe"), + }, + StepExecutions: []agg.StepExecution{ + exec("send", agg.StepStateCompleted, 1), + exec("observe", agg.StepStateFailed, 2), + exec("debit", agg.StepStatePending, 1), + exec("release_send", agg.StepStatePending, 1), + exec("release_observe", agg.StepStatePending, 1), + }, + Retry: RetryPolicy{MaxAttempts: 2}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + observe := mustExecution(t, out, "observe") + if observe.State != agg.StepStateNeedsAttention { + t.Fatalf("observe state mismatch: got=%q want=%q", observe.State, agg.StepStateNeedsAttention) + } + + assertRunnableRefs(t, out, []string{"release_observe"}) + assertSkippedRefs(t, out, []string{"debit", "release_send"}) + assertBlockedReason(t, out, "observe", BlockedNeedsAttention) + + releaseObserve := mustExecution(t, out, "release_observe") + if releaseObserve.State != agg.StepStatePending { + t.Fatalf("release_observe state mismatch: got=%q want=%q", releaseObserve.State, agg.StepStatePending) + } +} + func TestSchedule_RetryExhaustedPromotesNeedsAttention(t *testing.T) { runtime := New() diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go index 985229a5..ecb5453d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go @@ -43,8 +43,8 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) { if got, want := graph.RouteRef, "route-1"; got != want { t.Fatalf("route_ref mismatch: got=%q want=%q", got, want) } - if len(graph.Steps) != 8 { - t.Fatalf("expected 8 steps, got %d", len(graph.Steps)) + if len(graph.Steps) != 9 { + t.Fatalf("expected 9 steps, got %d", len(graph.Steps)) } assertStep(t, graph.Steps[0], "hop.10.crypto.send", model.RailOperationSend, model.RailCrypto, model.ReportVisibilityBackoffice) @@ -55,6 +55,7 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) { assertStep(t, graph.Steps[5], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser) assertStep(t, graph.Steps[6], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[7], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[8], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) { t.Fatalf("step[1] deps mismatch: got=%v want=%v", got, want) @@ -76,19 +77,28 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) { t.Fatalf("expected debit commit policy AFTER_SUCCESS, got %q", graph.Steps[6].CommitPolicy) } if graph.Steps[7].CommitPolicy != model.CommitPolicyAfterFailure { - t.Fatalf("expected release commit policy AFTER_FAILURE, got %q", graph.Steps[7].CommitPolicy) + t.Fatalf("expected send-failure release commit policy AFTER_FAILURE, got %q", graph.Steps[7].CommitPolicy) + } + if graph.Steps[8].CommitPolicy != model.CommitPolicyAfterFailure { + t.Fatalf("expected observe-failure release commit policy AFTER_FAILURE, got %q", graph.Steps[8].CommitPolicy) } if got, want := graph.Steps[6].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) { t.Fatalf("debit commit_after mismatch: got=%v want=%v", got, want) } - if got, want := graph.Steps[7].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) { - t.Fatalf("release commit_after mismatch: got=%v want=%v", got, want) + if got, want := graph.Steps[7].CommitAfter, []string{graph.Steps[4].StepRef}; !equalStringSlice(got, want) { + t.Fatalf("send-failure release commit_after mismatch: got=%v want=%v", got, want) + } + if got, want := graph.Steps[8].CommitAfter, []string{graph.Steps[5].StepRef}; !equalStringSlice(got, want) { + t.Fatalf("observe-failure release commit_after mismatch: got=%v want=%v", got, want) } if got, want := graph.Steps[6].Metadata["mode"], "finalize_debit"; got != want { t.Fatalf("expected debit mode %q, got %q", want, got) } if got, want := graph.Steps[7].Metadata["mode"], "unlock_hold"; got != want { - t.Fatalf("expected release mode %q, got %q", want, got) + t.Fatalf("expected send-failure release mode %q, got %q", want, got) + } + if got, want := graph.Steps[8].Metadata["mode"], "unlock_hold"; got != want { + t.Fatalf("expected observe-failure release mode %q, got %q", want, got) } } @@ -109,14 +119,21 @@ func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T) if err != nil { t.Fatalf("Compile returned error: %v", err) } - if len(graph.Steps) != 5 { - t.Fatalf("expected 5 steps, got %d", len(graph.Steps)) + if len(graph.Steps) != 6 { + t.Fatalf("expected 6 steps, got %d", len(graph.Steps)) } assertStep(t, graph.Steps[0], "edge.10_20.ledger.block", model.RailOperationBlock, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[1], "hop.20.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser) assertStep(t, graph.Steps[2], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser) assertStep(t, graph.Steps[3], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[4], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[5], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) + if got, want := graph.Steps[4].CommitAfter, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) { + t.Fatalf("send-failure release commit_after mismatch: got=%v want=%v", got, want) + } + if got, want := graph.Steps[5].CommitAfter, []string{graph.Steps[2].StepRef}; !equalStringSlice(got, want) { + t.Fatalf("observe-failure release commit_after mismatch: got=%v want=%v", got, want) + } } func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) { @@ -246,8 +263,8 @@ func TestCompile_GuardsArePrepended(t *testing.T) { if err != nil { t.Fatalf("Compile returned error: %v", err) } - if len(graph.Steps) != 7 { - t.Fatalf("expected 7 steps, got %d", len(graph.Steps)) + if len(graph.Steps) != 8 { + t.Fatalf("expected 8 steps, got %d", len(graph.Steps)) } if graph.Steps[0].Kind != StepKindLiquidityCheck { t.Fatalf("expected first guard liquidity_check, got %q", graph.Steps[0].Kind) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go index 7546ce6c..f8b2d431 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -237,6 +237,21 @@ func appendSettlementBranches( if strings.TrimSpace(anchorObserveRef) == "" { return } + anchorSendRef := strings.TrimSpace(anchorObserveRef) + if anchorSendRef != "" { + for i := range ex.steps { + step := ex.steps[i] + if strings.TrimSpace(step.StepRef) != anchorObserveRef { + continue + } + if len(step.DependsOn) > 0 { + anchorSendRef = strings.TrimSpace(step.DependsOn[0]) + } else { + anchorSendRef = "" + } + break + } + } successStep := Step{ StepCode: edgeCode(from, to, rail, "debit"), @@ -253,6 +268,23 @@ func appendSettlementBranches( } ex.appendBranch(successStep) + if anchorSendRef != "" { + sendFailureStep := Step{ + StepCode: edgeCode(from, to, rail, "release"), + Kind: StepKindFundsRelease, + Action: model.RailOperationRelease, + DependsOn: []string{anchorSendRef}, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + CommitPolicy: model.CommitPolicyAfterFailure, + CommitAfter: []string{anchorSendRef}, + Metadata: map[string]string{"mode": "unlock_hold"}, + } + ex.appendBranch(sendFailureStep) + } + failureStep := Step{ StepCode: edgeCode(from, to, rail, "release"), Kind: StepKindFundsRelease, diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go index 87e82f15..8bfce504 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go @@ -79,8 +79,8 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin StepCode: "hop.4.card_payout.send", Action: model.RailOperationSend, Rail: model.RailCardPayout, - Gateway: "monetix", - InstanceID: "monetix", + Gateway: paymenttypes.DefaultCardsGatewayID, + InstanceID: paymenttypes.DefaultCardsGatewayID, }, StepExecution: agg.StepExecution{ StepRef: "hop_4_card_payout_send", diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go index a3721439..49c18bb2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go @@ -47,7 +47,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsTransfer(t *testing.T) { gatewayInvokeResolver: resolver, gatewayRegistry: registry, cardGatewayRoutes: map[string]CardGatewayRoute{ - "monetix": {FundingAddress: "TUA_DEST"}, + paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST"}, }, } @@ -75,7 +75,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsTransfer(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 4, Rail: "CARD", Gateway: "monetix", InstanceID: "monetix", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, @@ -170,7 +170,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 1, Rail: "CRYPTO", Gateway: "crypto_1", InstanceID: "crypto_1", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 4, Rail: "CARD", Gateway: "monetix", InstanceID: "monetix", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go index 28e8a8cd..eca5fb22 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go @@ -108,7 +108,7 @@ func TestBuildGatewayExecutionEvent_MatchesCardObserveByCardPayoutRef(t *testing State: agg.StepStateRunning, ExternalRefs: []agg.ExternalRef{ { - GatewayInstanceID: "monetix", + GatewayInstanceID: paymenttypes.DefaultCardsGatewayID, Kind: erecon.ExternalRefKindCardPayout, Ref: "payout-1", }, @@ -128,7 +128,7 @@ func TestBuildGatewayExecutionEvent_MatchesCardObserveByCardPayoutRef(t *testing if got, want := event.StepRef, "hop_4_card_payout_observe"; got != want { t.Fatalf("step_ref mismatch: got=%q want=%q", got, want) } - if got, want := event.GatewayInstanceID, "monetix"; got != want { + if got, want := event.GatewayInstanceID, paymenttypes.DefaultCardsGatewayID; got != want { t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want) } } @@ -319,7 +319,7 @@ func TestRunningObserveCandidates_UsesCardPayoutRefAsTransfer(t *testing.T) { State: agg.StepStateRunning, ExternalRefs: []agg.ExternalRef{ { - GatewayInstanceID: "monetix", + GatewayInstanceID: paymenttypes.DefaultCardsGatewayID, Kind: erecon.ExternalRefKindCardPayout, Ref: "payout-2", }, @@ -335,7 +335,7 @@ func TestRunningObserveCandidates_UsesCardPayoutRefAsTransfer(t *testing.T) { if got, want := candidates[0].transferRef, "payout-2"; got != want { t.Fatalf("transfer_ref mismatch: got=%q want=%q", got, want) } - if got, want := candidates[0].gatewayInstanceID, "monetix"; got != want { + if got, want := candidates[0].gatewayInstanceID, paymenttypes.DefaultCardsGatewayID; got != want { t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want) } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go index 35c2d280..555c36c0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/guard_executor_test.go @@ -228,8 +228,8 @@ func testLiquidityProbePayment( { Index: 2, Rail: "CARD", - Gateway: "monetix", - InstanceID: "monetix", + Gateway: paymenttypes.DefaultCardsGatewayID, + InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination, }, }, diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go index 37dc7aa4..6eed5339 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go @@ -138,6 +138,108 @@ func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSe } } +func TestGatewayLedgerExecutor_ExecuteLedger_BlockUsesOperatingToHoldAndPayoutAmount(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var transferReq *ledgerv1.TransferRequest + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-block"}, nil + }, + }, + } + + out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_3_4_ledger_block", + StepCode: "edge.3_4.ledger.block", + Action: model.RailOperationBlock, + Rail: model.RailLedger, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_3_4_ledger_block", + StepCode: "edge.3_4.ledger.block", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if transferReq == nil { + t.Fatal("expected ledger transfer request") + } + if got, want := transferReq.GetMoney().GetAmount(), "76.5"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetMoney().GetCurrency(), "RUB"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING; got != want { + t.Fatalf("from_role mismatch: got=%v want=%v", got, want) + } + if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD; got != want { + t.Fatalf("to_role mismatch: got=%v want=%v", got, want) + } +} + +func TestGatewayLedgerExecutor_ExecuteLedger_ReleaseUsesHoldToOperatingAndPayoutAmount(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var transferReq *ledgerv1.TransferRequest + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-release"}, nil + }, + }, + } + + out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_3_4_ledger_release", + StepCode: "edge.3_4.ledger.release", + Action: model.RailOperationRelease, + Rail: model.RailLedger, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_3_4_ledger_release", + StepCode: "edge.3_4.ledger.release", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if transferReq == nil { + t.Fatal("expected ledger transfer request") + } + if got, want := transferReq.GetMoney().GetAmount(), "76.5"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetMoney().GetCurrency(), "RUB"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetFromRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD; got != want { + t.Fatalf("from_role mismatch: got=%v want=%v", got, want) + } + if got, want := transferReq.GetToRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING; got != want { + t.Fatalf("to_role mismatch: got=%v want=%v", got, want) + } +} + func TestGatewayLedgerExecutor_ExecuteLedger_UsesMetadataRoleOverrides(t *testing.T) { orgID := bson.NewObjectID() payment := testLedgerExecutorPayment(orgID) diff --git a/api/payments/quotation/internal/service/quotation/card_payout_constants.go b/api/payments/quotation/internal/service/quotation/card_payout_constants.go index 50cc7fab..fa895a39 100644 --- a/api/payments/quotation/internal/service/quotation/card_payout_constants.go +++ b/api/payments/quotation/internal/service/quotation/card_payout_constants.go @@ -1,5 +1,7 @@ package quotation +import paymenttypes "github.com/tech/sendico/pkg/payments/types" + const ( - defaultCardGateway = "monetix" + defaultCardGateway = paymenttypes.DefaultCardsGatewayID ) diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go index 485280f6..07155172 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go @@ -12,7 +12,7 @@ import ( ) const ( - defaultCardGatewayKey = "monetix" + defaultCardGatewayKey = paymenttypes.DefaultCardsGatewayID ) type CardGatewayFundingRoute struct { diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go index bdafd3b6..c3e4fd36 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go @@ -14,13 +14,13 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) { resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{ DefaultMode: model.FundingModeNone, CardRoutes: map[string]CardGatewayFundingRoute{ - "monetix": { + paymenttypes.DefaultCardsGatewayID: { FundingAddress: "T-FUNDING", FeeWalletRef: "wallet-fee", }, }, FeeLedgerAccounts: map[string]string{ - "monetix": "ledger:fees", + paymenttypes.DefaultCardsGatewayID: "ledger:fees", }, }) @@ -47,7 +47,7 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) { }, }, Attributes: map[string]string{ - "gateway": "monetix", + "gateway": paymenttypes.DefaultCardsGatewayID, "initiator_ref": "usr-1", }, }) @@ -57,8 +57,8 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) { if profile == nil { t.Fatalf("expected profile") } - if profile.GatewayID != "monetix" { - t.Fatalf("expected gateway monetix, got %q", profile.GatewayID) + if profile.GatewayID != paymenttypes.DefaultCardsGatewayID { + t.Fatalf("expected gateway %s, got %q", paymenttypes.DefaultCardsGatewayID, profile.GatewayID) } if profile.Mode != model.FundingModeNone { t.Fatalf("expected mode none, got %q", profile.Mode) @@ -149,7 +149,7 @@ func TestStaticFundingProfileResolver_EmptyInputReturnsNil(t *testing.T) { func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) { resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{ Profiles: map[string]*GatewayFundingProfile{ - "monetix": { + paymenttypes.DefaultCardsGatewayID: { Mode: model.FundingModeDepositObserved, DepositCheck: &model.DepositCheckPolicy{ WalletRef: "wallet-deposit", @@ -164,7 +164,7 @@ func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) { }) first, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{ - GatewayID: "monetix", + GatewayID: paymenttypes.DefaultCardsGatewayID, }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -175,7 +175,7 @@ func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) { first.DepositCheck.WalletRef = "changed" second, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{ - GatewayID: "monetix", + GatewayID: paymenttypes.DefaultCardsGatewayID, }) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go index 0f6cbabc..486094bc 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -67,7 +67,21 @@ func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent { return nil } if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil { - return fx + side := fx.GetSide() + if side == fxv1.Side_SIDE_UNSPECIFIED { + side = fxv1.Side_SELL_BASE_BUY_QUOTE + } + return &sharedv1.FXIntent{ + Pair: &fxv1.CurrencyPair{ + Base: strings.TrimSpace(fx.GetPair().GetBase()), + Quote: strings.TrimSpace(fx.GetPair().GetQuote()), + }, + Side: side, + Firm: fx.GetFirm(), + TtlMs: fx.GetTtlMs(), + PreferredProvider: strings.TrimSpace(fx.GetPreferredProvider()), + MaxAgeMs: fx.GetMaxAgeMs(), + } } amount := intent.GetAmount() if amount == nil { diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers_test.go b/api/payments/quotation/internal/service/quotation/internal_helpers_test.go index 90b030e7..3b892ac1 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers_test.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers_test.go @@ -114,3 +114,30 @@ func TestShouldRequestFX_UsesFXIntentOrCurrencyDifference(t *testing.T) { t.Fatalf("expected shouldRequestFX=true for derived FX from currency mismatch") } } + +func TestFXIntentForQuote_DefaultsUnspecifiedSideForExplicitPair(t *testing.T) { + intent := &sharedv1.PaymentIntent{ + Fx: &sharedv1.FXIntent{ + Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, + Side: fxv1.Side_SIDE_UNSPECIFIED, + Firm: true, + TtlMs: 5000, + PreferredProvider: "provider-a", + MaxAgeMs: 750, + }, + } + + fx := fxIntentForQuote(intent) + if fx == nil { + t.Fatal("expected fx intent") + } + if fx.GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE { + t.Fatalf("unexpected side: got=%s", fx.GetSide().String()) + } + if fx.GetPair() == nil || fx.GetPair().GetBase() != "USDT" || fx.GetPair().GetQuote() != "RUB" { + t.Fatalf("unexpected pair: %+v", fx.GetPair()) + } + if !fx.GetFirm() || fx.GetTtlMs() != 5000 || fx.GetPreferredProvider() != "provider-a" || fx.GetMaxAgeMs() != 750 { + t.Fatalf("unexpected fx options: %+v", fx) + } +} diff --git a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go index fefd1f9a..736b9d77 100644 --- a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go +++ b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/payments/storage/model" chainpkg "github.com/tech/sendico/pkg/chain" "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.uber.org/zap" "google.golang.org/grpc/codes" @@ -22,6 +23,13 @@ type managedWalletNetworkResolver struct { logger *zap.Logger } +type discoveredGatewayCandidate struct { + gatewayID string + instanceID string + network string + invokeURI string +} + func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolver { if core == nil { return nil @@ -39,23 +47,35 @@ func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolve } func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) { + asset, err := r.ResolveManagedWalletAsset(ctx, managedWalletRef) + if err != nil { + return "", err + } + network := strings.ToUpper(strings.TrimSpace(asset.GetChain())) + if network == "" { + return "", merrors.NoData("managed wallet network is missing") + } + return network, nil +} + +func (r *managedWalletNetworkResolver) ResolveManagedWalletAsset(ctx context.Context, managedWalletRef string) (*paymenttypes.Asset, error) { if r == nil { - return "", merrors.NoData("chain gateway unavailable") + return nil, merrors.NoData("chain gateway unavailable") } walletRef := strings.TrimSpace(managedWalletRef) if walletRef == "" { - return "", merrors.InvalidArgument("managed_wallet_ref is required") + return nil, merrors.InvalidArgument("managed_wallet_ref is required") } var discoveryErr error if r.gatewayRegistry != nil && r.gatewayInvokeResolver != nil { - network, err := r.resolveFromDiscoveredGateways(ctx, walletRef) + asset, err := r.resolveAssetFromDiscoveredGateways(ctx, walletRef) if err == nil { - return network, nil + return asset, nil } discoveryErr = err if r.logger != nil { - r.logger.Warn("Managed wallet network lookup via discovery failed", + r.logger.Warn("Managed wallet asset lookup via discovery failed", zap.String("wallet_ref", walletRef), zap.Error(err), ) @@ -64,72 +84,33 @@ func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.C if r.resolver == nil { if discoveryErr != nil { - return "", discoveryErr + return nil, discoveryErr } - return "", merrors.NoData("chain gateway unavailable") + return nil, merrors.NoData("chain gateway unavailable") } client, err := r.resolver.Resolve(ctx, "") if err != nil { - return "", err + return nil, err } if client == nil { - return "", merrors.NoData("chain gateway unavailable") + return nil, merrors.NoData("chain gateway unavailable") } resp, err := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef}) if err != nil { - return "", err + return nil, err } - return managedWalletNetworkFromResponse(resp) + return managedWalletAssetFromResponse(resp) } -func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context.Context, walletRef string) (string, error) { - entries, err := r.gatewayRegistry.List(ctx) +func (r *managedWalletNetworkResolver) resolveAssetFromDiscoveredGateways(ctx context.Context, walletRef string) (*paymenttypes.Asset, error) { + candidates, err := r.listDiscoveredGatewayCandidates(ctx) if err != nil { - return "", err + return nil, err } - - type candidate struct { - gatewayID string - instanceID string - network string - invokeURI string - } - candidates := make([]candidate, 0, len(entries)) - seenInvokeURI := map[string]struct{}{} - for _, entry := range entries { - if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto { - continue - } - invokeURI := strings.TrimSpace(entry.InvokeURI) - if invokeURI == "" { - continue - } - key := strings.ToLower(invokeURI) - if _, exists := seenInvokeURI[key]; exists { - continue - } - seenInvokeURI[key] = struct{}{} - candidates = append(candidates, candidate{ - gatewayID: strings.TrimSpace(entry.ID), - instanceID: strings.TrimSpace(entry.InstanceID), - network: strings.ToUpper(strings.TrimSpace(entry.Network)), - invokeURI: invokeURI, - }) - } - if len(candidates) == 0 { - return "", merrors.NoData("chain gateway unavailable") + return nil, merrors.NoData("chain gateway unavailable") } - sort.Slice(candidates, func(i, j int) bool { - if candidates[i].gatewayID != candidates[j].gatewayID { - return candidates[i].gatewayID < candidates[j].gatewayID - } - if candidates[i].instanceID != candidates[j].instanceID { - return candidates[i].instanceID < candidates[j].instanceID - } - return candidates[i].invokeURI < candidates[j].invokeURI - }) var firstErr error for _, candidate := range candidates { @@ -150,7 +131,7 @@ func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context } continue } - network, extractErr := managedWalletNetworkFromResponse(resp) + asset, extractErr := managedWalletAssetFromResponse(resp) if extractErr != nil { if firstErr == nil { firstErr = extractErr @@ -163,30 +144,87 @@ func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context zap.String("gateway_id", candidate.gatewayID), zap.String("instance_id", candidate.instanceID), zap.String("gateway_network", candidate.network), - zap.String("resolved_network", network), + zap.String("resolved_network", asset.GetChain()), ) } - return network, nil + return asset, nil } if firstErr != nil { - return "", firstErr + return nil, firstErr } - return "", merrors.NoData("managed wallet not found in discovered gateways") + return nil, merrors.NoData("managed wallet not found in discovered gateways") +} + +func (r *managedWalletNetworkResolver) listDiscoveredGatewayCandidates(ctx context.Context) ([]discoveredGatewayCandidate, error) { + entries, err := r.gatewayRegistry.List(ctx) + if err != nil { + return nil, err + } + + candidates := make([]discoveredGatewayCandidate, 0, len(entries)) + seenInvokeURI := map[string]struct{}{} + for _, entry := range entries { + if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto { + continue + } + invokeURI := strings.TrimSpace(entry.InvokeURI) + if invokeURI == "" { + continue + } + key := strings.ToLower(invokeURI) + if _, exists := seenInvokeURI[key]; exists { + continue + } + seenInvokeURI[key] = struct{}{} + candidates = append(candidates, discoveredGatewayCandidate{ + gatewayID: strings.TrimSpace(entry.ID), + instanceID: strings.TrimSpace(entry.InstanceID), + network: strings.ToUpper(strings.TrimSpace(entry.Network)), + invokeURI: invokeURI, + }) + } + + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].gatewayID != candidates[j].gatewayID { + return candidates[i].gatewayID < candidates[j].gatewayID + } + if candidates[i].instanceID != candidates[j].instanceID { + return candidates[i].instanceID < candidates[j].instanceID + } + return candidates[i].invokeURI < candidates[j].invokeURI + }) + return candidates, nil } func managedWalletNetworkFromResponse(resp *chainv1.GetManagedWalletResponse) (string, error) { - wallet := resp.GetWallet() - if wallet == nil || wallet.GetAsset() == nil { - return "", merrors.NoData("managed wallet asset is missing") + asset, err := managedWalletAssetFromResponse(resp) + if err != nil { + return "", err } - network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain()))) + network := strings.ToUpper(strings.TrimSpace(asset.GetChain())) if network == "" || network == "UNSPECIFIED" { return "", merrors.NoData("managed wallet network is missing") } return network, nil } +func managedWalletAssetFromResponse(resp *chainv1.GetManagedWalletResponse) (*paymenttypes.Asset, error) { + wallet := resp.GetWallet() + if wallet == nil || wallet.GetAsset() == nil { + return nil, merrors.NoData("managed wallet asset is missing") + } + network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain()))) + if network == "" || network == "UNSPECIFIED" { + return nil, merrors.NoData("managed wallet network is missing") + } + return &paymenttypes.Asset{ + Chain: network, + TokenSymbol: strings.ToUpper(strings.TrimSpace(wallet.GetAsset().GetTokenSymbol())), + ContractAddress: strings.TrimSpace(wallet.GetAsset().GetContractAddress()), + }, nil +} + func isManagedWalletNotFound(err error) bool { if err == nil { return false diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go index 7663a37a..e6ee0f0a 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go @@ -169,18 +169,18 @@ func (s *QuotationServiceV2) ProcessQuotePayments( } expires := make([]time.Time, 0, len(details)) - intents := make([]model.PaymentIntent, 0, len(details)) - snapshots := make([]*model.PaymentQuoteSnapshot, 0, len(details)) - statuses := make([]*quote_persistence_service.StatusInput, 0, len(details)) + items := make([]quote_persistence_service.PersistItemInput, 0, len(details)) for _, detail := range details { if detail == nil || detail.Intent.Amount == nil || detail.Quote == nil { logger.Warn("ProcessQuotePayments contains incomplete detail") return nil, merrors.InvalidArgument("batch processing detail is incomplete") } expires = append(expires, detail.ExpiresAt) - intents = append(intents, detail.Intent) - snapshots = append(snapshots, quoteSnapshotFromComputed(detail.Quote)) - statuses = append(statuses, statusInputFromStatus(detail.Status)) + items = append(items, quote_persistence_service.PersistItemInput{ + Intent: pointerTo(detail.Intent), + Quote: quoteSnapshotFromComputed(detail.Quote), + Status: statusInputFromStatus(detail.Status), + }) } expiresAt, ok := minExpiry(expires) @@ -195,9 +195,8 @@ func (s *QuotationServiceV2) ProcessQuotePayments( IdempotencyKey: requestCtx.IdempotencyKey, Hash: fingerprint, ExpiresAt: expiresAt, - Intents: intents, - Quotes: snapshots, - Statuses: statuses, + RequestShape: model.QuoteRequestShapeBatch, + Items: items, }) if err != nil { logger.Warn("ProcessQuotePayments failed to build persistence record", zap.Error(err)) @@ -244,7 +243,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( result.Record = stored logger.Info("ProcessQuotePayments persisted quote batch", zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), - zap.Int("quotes_count", len(stored.Quotes)), + zap.Int("quotes_count", len(stored.Items)), zap.Time("expires_at", stored.ExpiresAt), zap.Duration("elapsed", time.Since(startedAt)), ) diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go index 29fa44d0..b70d63b0 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go @@ -181,9 +181,14 @@ func (s *QuotationServiceV2) ProcessQuotePayment( IdempotencyKey: requestCtx.IdempotencyKey, Hash: fingerprint, ExpiresAt: expiresAt, - Intent: pointerTo(detail.Intent), - Quote: quoteSnapshotFromComputed(detail.Quote), - Status: statusInputFromStatus(detail.Status), + RequestShape: model.QuoteRequestShapeSingle, + Items: []quote_persistence_service.PersistItemInput{ + { + Intent: pointerTo(detail.Intent), + Quote: quoteSnapshotFromComputed(detail.Quote), + Status: statusInputFromStatus(detail.Status), + }, + }, }) if err != nil { logger.Warn("ProcessQuotePayment failed to build persistence record", zap.Error(err)) diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go index 29a83569..47312c1d 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go @@ -13,28 +13,34 @@ func (s *QuotationServiceV2) singleResultFromRecord(record *model.PaymentQuoteRe if record == nil { return nil, merrors.InvalidArgument("record is required") } - if record.Quote == nil { + if record.RequestShape != model.QuoteRequestShapeSingle { + return nil, merrors.InvalidArgument("record request shape is not single") + } + if len(record.Items) != 1 || record.Items[0] == nil { + return nil, merrors.InvalidArgument("record single item is required") + } + item := record.Items[0] + if item.Intent == nil { + return nil, merrors.InvalidArgument("record intent is required") + } + if item.Quote == nil { return nil, merrors.InvalidArgument("record quote is required") } - status := statusFromStored(record.StatusV2) + status := statusFromStored(item.Status) mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{ Meta: quote_response_mapper_v2.QuoteMeta{ ID: record.GetID().Hex(), CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, }, - Quote: canonicalFromSnapshot(record.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), + Quote: canonicalFromSnapshot(item.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), Status: status, }) if err != nil { return nil, err } - intentRef := strings.TrimSpace(record.Intent.Ref) - if len(record.Intents) == 1 { - intentRef = firstNonEmpty(strings.TrimSpace(record.Intents[0].Ref), intentRef) - } - mapped.Quote.IntentRef = intentRef + mapped.Quote.IntentRef = strings.TrimSpace(item.Intent.Ref) return &QuotePaymentResult{ Response: "ationv2.QuotePaymentResponse{ Quote: mapped.Quote, @@ -48,17 +54,22 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec if record == nil { return nil, merrors.InvalidArgument("record is required") } - if len(record.Quotes) == 0 { - return nil, merrors.InvalidArgument("record quotes are required") + if record.RequestShape != model.QuoteRequestShapeBatch { + return nil, merrors.InvalidArgument("record request shape is not batch") + } + if len(record.Items) == 0 { + return nil, merrors.InvalidArgument("record items are required") } - quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Quotes)) - for idx, snapshot := range record.Quotes { - var storedStatus *model.QuoteStatusV2 - if idx < len(record.StatusesV2) { - storedStatus = record.StatusesV2[idx] + quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Items)) + for _, item := range record.Items { + if item == nil { + return nil, merrors.InvalidArgument("record item is required") } - status := statusFromStored(storedStatus) + if item.Quote == nil { + return nil, merrors.InvalidArgument("record item quote is required") + } + status := statusFromStored(item.Status) mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{ Meta: quote_response_mapper_v2.QuoteMeta{ @@ -66,14 +77,14 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt, }, - Quote: canonicalFromSnapshot(snapshot, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), + Quote: canonicalFromSnapshot(item.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), Status: status, }) if err != nil { return nil, err } - if idx < len(record.Intents) { - mapped.Quote.IntentRef = strings.TrimSpace(record.Intents[idx].Ref) + if item.Intent != nil { + mapped.Quote.IntentRef = strings.TrimSpace(item.Intent.Ref) } quotes = append(quotes, mapped.Quote) } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go index b8e6a494..3158bf26 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go @@ -14,6 +14,7 @@ import ( quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/merrors" pkgmodel "github.com/tech/sendico/pkg/model" + 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" @@ -188,6 +189,131 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { t.Logf("single response:\n%s", mustProtoJSON(t, result.Response)) } +func TestQuotePayment_FixReceivedRUB_ProducesUSDTDebit_EndToEnd(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + orgID := bson.NewObjectID() + + store := newInMemoryQuotesStore() + core := &fakeQuoteCore{now: now} + svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), + QuotesStore: store, + Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { + return "q-intent-fix-received" + })), + Computation: quote_computation_service.New( + core, + quote_computation_service.WithManagedWalletNetworkResolver(staticManagedWalletResolverForE2E{ + assetsByRef: map[string]*paymenttypes.Asset{ + "wallet-usdt-source": { + Chain: "TRON_NILE", + TokenSymbol: "USDT", + }, + }, + }), + ), + Now: func() time.Time { return now }, + NewRef: func() string { return "quote-fix-received-rub" }, + }) + + intent := makeTransferIntent(t, "5000", "RUB", "wallet-usdt-source", "4111111111111111", "RU") + intent.SettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + intent.SettlementCurrency = "RUB" + + req := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{ + OrganizationRef: orgID.Hex(), + }, + IdempotencyKey: "idem-fix-received-rub", + InitiatorRef: "initiator-42", + PreviewOnly: false, + Intent: intent, + } + + result, err := svc.ProcessQuotePayment(context.Background(), req) + if err != nil { + t.Fatalf("ProcessQuotePayment returned error: %v", err) + } + if result == nil || result.Response == nil || result.Response.GetQuote() == nil { + t.Fatalf("expected quote response") + } + quote := result.Response.GetQuote() + + rate := decimal.RequireFromString("91.5") + received := decimal.RequireFromString("5000") + expectedPrincipal := received.Div(rate) + + feeTotal := decimal.Zero + for _, line := range quote.GetFeeLines() { + if line == nil || line.GetMoney() == nil { + continue + } + if !strings.EqualFold(line.GetMoney().GetCurrency(), "USDT") { + continue + } + lineAmount := decimal.RequireFromString(line.GetMoney().GetAmount()) + switch line.GetSide() { + case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: + feeTotal = feeTotal.Sub(lineAmount) + default: + feeTotal = feeTotal.Add(lineAmount) + } + } + expectedTotalDebit := expectedPrincipal.Add(feeTotal) + + if got, want := quote.GetTransferPrincipalAmount().GetAmount(), expectedPrincipal.String(); got != want { + t.Fatalf("unexpected principal amount: got=%q want=%q", got, want) + } + if got, want := quote.GetTransferPrincipalAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected principal currency: got=%q want=%q", got, want) + } + if got, want := quote.GetDestinationAmount().GetAmount(), "5000"; got != want { + t.Fatalf("unexpected destination amount: got=%q want=%q", got, want) + } + if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want { + t.Fatalf("unexpected destination currency: got=%q want=%q", got, want) + } + if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), expectedTotalDebit.String(); got != want { + t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want) + } + if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected payer_total_debit_amount currency: got=%q want=%q", got, want) + } + if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String()) + } + if quote.GetRoute() == nil || quote.GetRoute().GetSettlement() == nil { + t.Fatalf("expected route settlement") + } + if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_received"; got != want { + t.Fatalf("unexpected route settlement model: got=%q want=%q", got, want) + } + if quote.GetFxQuote() == nil { + t.Fatalf("expected fx quote") + } + if got, want := quote.GetFxQuote().GetPair().GetBase(), "USDT"; got != want { + t.Fatalf("unexpected fx base: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want { + t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want { + t.Fatalf("unexpected fx side: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetFxQuote().GetQuoteAmount().GetAmount(), "5000"; got != want { + t.Fatalf("unexpected fx quote amount: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetQuoteAmount().GetCurrency(), "RUB"; got != want { + t.Fatalf("unexpected fx quote amount currency: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetBaseAmount().GetAmount(), expectedPrincipal.String(); got != want { + t.Fatalf("unexpected fx base amount: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetBaseAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected fx base amount currency: got=%q want=%q", got, want) + } +} + func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) { now := time.Unix(1_700_000_000, 0).UTC() orgID := bson.NewObjectID() @@ -605,29 +731,70 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi return nil, time.Time{}, fmt.Errorf("route hops are required for route-bound quote pricing") } - baseAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount()) + intentAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount()) + amountCurrency := strings.ToUpper(strings.TrimSpace(in.Intent.Amount.GetCurrency())) rate := decimal.RequireFromString("91.5") - quoteAmount := baseAmount.Mul(rate) + + baseCurrency := "USDT" + quoteCurrency := "RUB" + fxSide := fxv1.Side_SELL_BASE_BUY_QUOTE + if in.Intent.FX != nil && in.Intent.FX.Pair != nil { + if base := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetBase())); base != "" { + baseCurrency = base + } + if quote := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetQuote())); quote != "" { + quoteCurrency = quote + } + switch in.Intent.FX.Side { + case paymenttypes.FXSideBuyBaseSellQuote: + fxSide = fxv1.Side_BUY_BASE_SELL_QUOTE + default: + fxSide = fxv1.Side_SELL_BASE_BUY_QUOTE + } + } + + baseAmount := intentAmount + quoteAmount := intentAmount.Mul(rate) + switch { + case strings.EqualFold(amountCurrency, quoteCurrency): + quoteAmount = intentAmount + baseAmount = intentAmount.Div(rate) + case strings.EqualFold(amountCurrency, baseCurrency): + baseAmount = intentAmount + quoteAmount = intentAmount.Mul(rate) + } + + payAmount := baseAmount + payCurrency := baseCurrency + settlementAmount := quoteAmount + settlementCurrency := quoteCurrency + if fxSide == fxv1.Side_BUY_BASE_SELL_QUOTE { + payAmount = quoteAmount + payCurrency = quoteCurrency + settlementAmount = baseAmount + settlementCurrency = baseCurrency + } + feeAmount := decimal.RequireFromString("1.50") taxAmount := decimal.RequireFromString("0.30") - if routeFeeClass(in.Route) != "card_payout:3_hops:monetix" { + if routeFeeClass(in.Route) != "card_payout:3_hops:"+paymenttypes.DefaultCardsGatewayID { feeAmount = decimal.RequireFromString("2.00") taxAmount = decimal.RequireFromString("0.40") } quote := "e_computation_service.ComputedQuote{ DebitAmount: &moneyv1.Money{ - Amount: baseAmount.String(), - Currency: "USDT", + Amount: payAmount.String(), + Currency: payCurrency, }, CreditAmount: &moneyv1.Money{ - Amount: quoteAmount.String(), - Currency: "RUB", + Amount: settlementAmount.String(), + Currency: settlementCurrency, }, FeeLines: []*feesv1.DerivedPostingLine{ { LedgerAccountRef: "ledger:fees:usdt", - Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: "USDT"}, + Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: payCurrency}, LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, Meta: map[string]string{ @@ -637,7 +804,7 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi }, { LedgerAccountRef: "ledger:tax:usdt", - Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: "USDT"}, + Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: payCurrency}, LineType: accountingv1.PostingLineType_POSTING_LINE_TAX, Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, Meta: map[string]string{ @@ -664,13 +831,13 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi FXQuote: &oraclev1.Quote{ QuoteRef: "fx-usdt-rub", Pair: &fxv1.CurrencyPair{ - Base: "USDT", - Quote: "RUB", + Base: baseCurrency, + Quote: quoteCurrency, }, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + Side: fxSide, Price: &moneyv1.Decimal{Value: rate.String()}, - BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: "USDT"}, - QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: "RUB"}, + BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: baseCurrency}, + QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: quoteCurrency}, ExpiresAtUnixMs: f.now.Add(f.fxTTLValue()).UnixMilli(), Provider: "test-oracle", RateRef: "rate-usdt-rub", @@ -815,6 +982,30 @@ type staticGatewayRegistryForE2E struct { items []*model.GatewayInstanceDescriptor } +type staticManagedWalletResolverForE2E struct { + assetsByRef map[string]*paymenttypes.Asset +} + +func (r staticManagedWalletResolverForE2E) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) { + if len(r.assetsByRef) == 0 { + return nil, nil + } + asset, ok := r.assetsByRef[strings.TrimSpace(managedWalletRef)] + if !ok || asset == nil { + return nil, nil + } + cloned := *asset + return &cloned, nil +} + +func (r staticManagedWalletResolverForE2E) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) { + asset, err := r.ResolveManagedWalletAsset(ctx, managedWalletRef) + if err != nil || asset == nil { + return "", err + } + return strings.TrimSpace(asset.GetChain()), nil +} + func (r staticGatewayRegistryForE2E) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { if len(r.items) == 0 { return nil, nil diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go index 129f59a8..33a65313 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go @@ -23,7 +23,7 @@ import ( func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) { svc := New(nil, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{ GatewayModes: map[string]model.FundingMode{ - "monetix": model.FundingModeBalanceReserve, + paymenttypes.DefaultCardsGatewayID: model.FundingModeBalanceReserve, }, }))) @@ -168,6 +168,69 @@ func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing } } +func TestBuildPlan_UsesSourceAssetCurrencyForSourceStep(t *testing.T) { + svc := New(nil) + orgID := bson.NewObjectID() + intent := sampleCryptoToCardQuoteIntent() + intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived + intent.Amount = &paymenttypes.Money{ + Amount: "5000", + Currency: "RUB", + } + intent.SettlementCurrency = "RUB" + intent.RequiresFX = false + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + PreviewOnly: false, + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(planModel.Items) != 1 { + t.Fatalf("expected one plan item") + } + item := planModel.Items[0] + if item == nil { + t.Fatalf("expected plan item") + } + if !item.QuoteInput.Intent.RequiresFX { + t.Fatalf("expected derived FX requirement for fix_received cross-currency flow") + } + if item.QuoteInput.Intent.FX == nil || item.QuoteInput.Intent.FX.Pair == nil { + t.Fatalf("expected derived FX pair") + } + if got, want := strings.TrimSpace(item.QuoteInput.Intent.FX.Pair.GetBase()), "USDT"; got != want { + t.Fatalf("unexpected derived FX base currency: got=%q want=%q", got, want) + } + if got, want := strings.TrimSpace(item.QuoteInput.Intent.FX.Pair.GetQuote()), "RUB"; got != want { + t.Fatalf("unexpected derived FX quote currency: got=%q want=%q", got, want) + } + steps := item.Steps + if got, want := len(steps), 4; got != want { + t.Fatalf("unexpected step count: got=%d want=%d", got, want) + } + if steps[0] == nil || steps[0].Amount == nil { + t.Fatalf("expected source step amount") + } + if got, want := strings.TrimSpace(steps[0].Amount.GetCurrency()), "USDT"; got != want { + t.Fatalf("unexpected source step currency: got=%q want=%q", got, want) + } + last := steps[len(steps)-1] + if last == nil || last.Amount == nil { + t.Fatalf("expected destination step amount") + } + if got, want := strings.TrimSpace(last.Amount.GetCurrency()), "RUB"; got != want { + t.Fatalf("unexpected destination step currency: got=%q want=%q", got, want) + } + if got, want := steps[1].Operation, model.RailOperationFXConvert; got != want { + t.Fatalf("unexpected middle operation: got=%q want=%q", got, want) + } +} + func TestBuildPlan_ResolvesIndependentEconomicsKnobs(t *testing.T) { svc := New(nil) orgID := bson.NewObjectID() @@ -397,7 +460,7 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) { } svc := New(core, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{ GatewayModes: map[string]model.FundingMode{ - "monetix": model.FundingModeBalanceReserve, + paymenttypes.DefaultCardsGatewayID: model.FundingModeBalanceReserve, }, }))) @@ -459,7 +522,7 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) { if got, want := len(hops), 2; got != want { t.Fatalf("unexpected route hops in build input: got=%d want=%d", got, want) } - if got, want := hops[1].GetGateway(), "monetix"; got != want { + if got, want := hops[1].GetGateway(), paymenttypes.DefaultCardsGatewayID; got != want { t.Fatalf("unexpected destination gateway in build input route: got=%q want=%q", got, want) } if core.lastQuoteIn.ExecutionConditions == nil { @@ -611,7 +674,7 @@ func sampleCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent { }, SettlementCurrency: "USD", Attributes: map[string]string{ - "gateway": "monetix", + "gateway": paymenttypes.DefaultCardsGatewayID, }, } } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go index 9c4cc6dd..213ccd41 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" ) func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model.PaymentIntent { @@ -22,6 +23,7 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model Source: modelEndpointFromQuoteEndpoint(src.Source), Destination: modelEndpointFromQuoteEndpoint(src.Destination), Amount: cloneModelMoney(src.Amount), + FX: fxIntentFromHydratedIntent(src), RequiresFX: src.RequiresFX, Attributes: cloneStringMap(src.Attributes), SettlementMode: modelSettlementMode(src.SettlementMode), @@ -30,6 +32,72 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model } } +func fxIntentFromHydratedIntent(src *transfer_intent_hydrator.QuoteIntent) *model.FXIntent { + if src == nil { + return nil + } + if strings.TrimSpace(string(src.FXSide)) == "" || src.FXSide == paymenttypes.FXSideUnspecified { + return nil + } + return &model.FXIntent{Side: src.FXSide} +} + +func ensureDerivedFXIntent(intent *model.PaymentIntent) { + if intent == nil { + return + } + + amountCurrency := "" + if intent.Amount != nil { + amountCurrency = normalizeAsset(intent.Amount.GetCurrency()) + } + settlementCurrency := normalizeAsset(intent.SettlementCurrency) + if settlementCurrency == "" { + settlementCurrency = amountCurrency + } + if intent.SettlementCurrency == "" && settlementCurrency != "" { + intent.SettlementCurrency = settlementCurrency + } + + sourceCurrency := sourceAssetToken(intent.Source) + + // For FIX_RECEIVED, destination amounts can be provided in payout currency. + // Derive FX necessity from source asset currency when available. + if !intent.RequiresFX && + intent.SettlementMode == model.SettlementModeFixReceived && + sourceCurrency != "" && + settlementCurrency != "" && + !strings.EqualFold(sourceCurrency, settlementCurrency) { + intent.RequiresFX = true + } + + if !intent.RequiresFX { + return + } + + baseCurrency := firstNonEmpty(sourceCurrency, amountCurrency) + quoteCurrency := settlementCurrency + if baseCurrency == "" || quoteCurrency == "" { + return + } + + if intent.FX == nil { + intent.FX = &model.FXIntent{} + } + if intent.FX.Pair == nil { + intent.FX.Pair = &paymenttypes.CurrencyPair{} + } + if normalizeAsset(intent.FX.Pair.Base) == "" { + intent.FX.Pair.Base = baseCurrency + } + if normalizeAsset(intent.FX.Pair.Quote) == "" { + intent.FX.Pair.Quote = quoteCurrency + } + if strings.TrimSpace(string(intent.FX.Side)) == "" || intent.FX.Side == paymenttypes.FXSideUnspecified { + intent.FX.Side = paymenttypes.FXSideSellBaseBuyQuote + } +} + func modelEndpointFromQuoteEndpoint(src transfer_intent_hydrator.QuoteEndpoint) model.PaymentEndpoint { result := model.PaymentEndpoint{ Type: model.EndpointTypeUnspecified, diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters_test.go new file mode 100644 index 00000000..16524937 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters_test.go @@ -0,0 +1,71 @@ +package quote_computation_service + +import ( + "testing" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func TestEnsureDerivedFXIntent_DefaultsSideWhenEmpty(t *testing.T) { + intent := &model.PaymentIntent{ + RequiresFX: true, + SettlementCurrency: "RUB", + Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"}, + FX: &model.FXIntent{}, + } + + ensureDerivedFXIntent(intent) + + if intent.FX == nil { + t.Fatal("expected fx intent") + } + if got, want := intent.FX.Side, paymenttypes.FXSideSellBaseBuyQuote; got != want { + t.Fatalf("unexpected side: got=%q want=%q", got, want) + } +} + +func TestEnsureDerivedFXIntent_DefaultsSideWhenUnspecified(t *testing.T) { + intent := &model.PaymentIntent{ + RequiresFX: true, + SettlementCurrency: "RUB", + Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"}, + FX: &model.FXIntent{Side: paymenttypes.FXSideUnspecified}, + } + + ensureDerivedFXIntent(intent) + + if intent.FX == nil { + t.Fatal("expected fx intent") + } + if got, want := intent.FX.Side, paymenttypes.FXSideSellBaseBuyQuote; got != want { + t.Fatalf("unexpected side: got=%q want=%q", got, want) + } +} + +func TestEnsureDerivedFXIntent_PreservesExplicitSideFromHydratedIntent(t *testing.T) { + hydrated := &transfer_intent_hydrator.QuoteIntent{ + Source: transfer_intent_hydrator.QuoteEndpoint{ + Type: transfer_intent_hydrator.QuoteEndpointTypeManagedWallet, + ManagedWallet: &transfer_intent_hydrator.QuoteManagedWalletEndpoint{ + ManagedWalletRef: "mw-src", + Asset: &paymenttypes.Asset{TokenSymbol: "USDT"}, + }, + }, + Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"}, + SettlementCurrency: "RUB", + RequiresFX: true, + FXSide: paymenttypes.FXSideBuyBaseSellQuote, + } + + intent := modelIntentFromQuoteIntent(hydrated) + ensureDerivedFXIntent(&intent) + + if intent.FX == nil { + t.Fatal("expected fx intent") + } + if got, want := intent.FX.Side, paymenttypes.FXSideBuyBaseSellQuote; got != want { + t.Fatalf("unexpected side: got=%q want=%q", got, want) + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go index fcd9ccb0..6d0a1719 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network.go @@ -10,6 +10,10 @@ import ( "go.uber.org/zap" ) +type managedWalletAssetResolver interface { + ResolveManagedWalletAsset(ctx context.Context, managedWalletRef string) (*paymenttypes.Asset, error) +} + func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork( ctx context.Context, endpoint *model.PaymentEndpoint, @@ -25,7 +29,30 @@ func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork( if walletRef == "" { return merrors.InvalidArgument("managed_wallet_ref is required") } - if endpoint.ManagedWallet.Asset != nil && strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()) != "" { + asset := endpoint.ManagedWallet.Asset + if asset == nil { + asset = &paymenttypes.Asset{} + endpoint.ManagedWallet.Asset = asset + } + if resolver, ok := s.managedWalletNetworkResolver.(managedWalletAssetResolver); ok && resolver != nil { + if strings.TrimSpace(asset.GetChain()) == "" || strings.TrimSpace(asset.GetTokenSymbol()) == "" { + if resolved, err := resolver.ResolveManagedWalletAsset(ctx, walletRef); err == nil && resolved != nil { + if strings.TrimSpace(asset.GetChain()) == "" { + asset.Chain = strings.ToUpper(strings.TrimSpace(resolved.GetChain())) + } + if strings.TrimSpace(asset.GetTokenSymbol()) == "" { + asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(resolved.GetTokenSymbol())) + } + if strings.TrimSpace(asset.GetContractAddress()) == "" { + asset.ContractAddress = strings.TrimSpace(resolved.GetContractAddress()) + } + } + } + } + if strings.TrimSpace(asset.GetChain()) != "" { + asset.Chain = strings.ToUpper(strings.TrimSpace(asset.GetChain())) + asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol())) + asset.ContractAddress = strings.TrimSpace(asset.GetContractAddress()) return nil } if s.managedWalletNetworkResolver == nil { @@ -57,11 +84,6 @@ func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork( ) } - asset := endpoint.ManagedWallet.Asset - if asset == nil { - asset = &paymenttypes.Asset{} - endpoint.ManagedWallet.Asset = asset - } asset.Chain = network asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.TokenSymbol)) asset.ContractAddress = strings.TrimSpace(asset.ContractAddress) diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go index 7b5981c8..4fad726d 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/managed_wallet_network_test.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -147,6 +148,93 @@ func TestBuildPlan_ManagedWalletNetworkResolverCachesByWalletRef(t *testing.T) { } } +func TestBuildPlan_ResolvesManagedWalletAssetTokenForSourceCurrency(t *testing.T) { + resolver := &fakeManagedWalletNetworkResolver{ + networks: map[string]string{ + "wallet-usdt-source": "TRON_NILE", + }, + assets: map[string]*paymenttypes.Asset{ + "wallet-usdt-source": { + Chain: "TRON_NILE", + TokenSymbol: "USDT", + }, + }, + } + svc := New(nil, + WithManagedWalletNetworkResolver(resolver), + WithGatewayRegistry(staticGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto-tron", + InstanceID: "crypto-tron", + Rail: model.RailCrypto, + Network: "TRON_NILE", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "fx-tron", + InstanceID: "fx-tron", + Rail: model.RailProviderSettlement, + Network: "TRON_NILE", + Currencies: []string{"USDT", "RUB"}, + Operations: []model.RailOperation{model.RailOperationFXConvert}, + IsEnabled: true, + }, + { + ID: "card-gw", + InstanceID: "card-gw", + Rail: model.RailCardPayout, + Currencies: []string{"RUB"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + }, + }), + ) + + intent := sampleCryptoToCardQuoteIntent() + intent.Source.ManagedWallet.Asset = nil + intent.Amount = &paymenttypes.Money{ + Amount: "5000", + Currency: "RUB", + } + intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived + intent.SettlementCurrency = "RUB" + intent.RequiresFX = false + orgID := bson.NewObjectID() + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-wallet-asset", + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil { + t.Fatalf("expected one plan item") + } + item := planModel.Items[0] + if got, want := item.Steps[0].GatewayID, "crypto-tron"; got != want { + t.Fatalf("unexpected source gateway: got=%q want=%q", got, want) + } + if item.Steps[0] == nil || item.Steps[0].Amount == nil { + t.Fatalf("expected source step amount") + } + if got, want := item.Steps[0].Amount.GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected source step currency: got=%q want=%q", got, want) + } + if got, want := resolver.assetCalls, 1; got != want { + t.Fatalf("unexpected asset resolver calls: got=%d want=%d", got, want) + } +} + func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) { resolver := &fakeManagedWalletNetworkResolver{ err: merrors.NoData("wallet not found"), @@ -168,9 +256,12 @@ func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) { } type fakeManagedWalletNetworkResolver struct { - networks map[string]string - err error - calls int + networks map[string]string + assets map[string]*paymenttypes.Asset + err error + assetErr error + calls int + assetCalls int } func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context.Context, managedWalletRef string) (string, error) { @@ -183,3 +274,22 @@ func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context } return f.networks[managedWalletRef], nil } + +func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) { + f.assetCalls++ + if f.assetErr != nil { + return nil, f.assetErr + } + if f.assets == nil { + return nil, nil + } + src := f.assets[managedWalletRef] + if src == nil { + return nil, nil + } + return &paymenttypes.Asset{ + Chain: src.GetChain(), + TokenSymbol: src.GetTokenSymbol(), + ContractAddress: src.GetContractAddress(), + }, nil +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index 82e313d9..21043734 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -136,6 +136,7 @@ func (s *QuoteComputationService) buildPlanItem( } modelIntent.Source = clonePaymentEndpoint(source) modelIntent.Destination = clonePaymentEndpoint(destination) + ensureDerivedFXIntent(&modelIntent) sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true) if err != nil { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go index 9b7b490e..d7c3803a 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) @@ -20,6 +21,7 @@ func buildComputationSteps( attrs := intent.Attributes amount := protoMoneyFromModel(intent.Amount) + sourceAmount := sourceStepAmount(intent, amount) destinationAmount := destinationStepAmount(intent, amount) sourceRail := sourceRailForIntent(intent) destinationRail := destinationRailForIntent(intent) @@ -45,7 +47,7 @@ func buildComputationSteps( Operation: sourceOperationForRail(rails[0]), GatewayID: sourceGatewayID, InstanceID: sourceInstanceID, - Amount: cloneProtoMoney(amount), + Amount: cloneProtoMoney(sourceAmount), Optional: false, IncludeInAggregate: false, }, @@ -63,7 +65,7 @@ func buildComputationSteps( Rail: model.RailProviderSettlement, Operation: model.RailOperationFXConvert, DependsOn: []string{sourceStepID}, - Amount: cloneProtoMoney(amount), + Amount: cloneProtoMoney(sourceAmount), Optional: false, IncludeInAggregate: false, }) @@ -82,12 +84,16 @@ func buildComputationSteps( operation = model.RailOperationFXConvert fxAssigned = true } + stepAmount := amount + if operation == model.RailOperationFXConvert { + stepAmount = sourceAmount + } steps = append(steps, &QuoteComputationStep{ StepID: stepID, Rail: rail, Operation: operation, DependsOn: []string{lastStepID}, - Amount: cloneProtoMoney(amount), + Amount: cloneProtoMoney(stepAmount), Optional: false, IncludeInAggregate: false, }) @@ -209,11 +215,78 @@ func destinationStepAmount(intent model.PaymentIntent, sourceAmount *moneyv1.Mon } settlementCurrency := strings.ToUpper(strings.TrimSpace(intent.SettlementCurrency)) - if settlementCurrency == "" && intent.FX != nil && intent.FX.Pair != nil { - settlementCurrency = strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote)) + if settlementCurrency == "" { + settlementCurrency = settlementCurrencyFromFX(intent.FX) } if settlementCurrency != "" { amount.Currency = settlementCurrency } return amount } + +func settlementCurrencyFromFX(fx *model.FXIntent) string { + if fx == nil || fx.Pair == nil { + return "" + } + base := normalizeAsset(fx.Pair.GetBase()) + quote := normalizeAsset(fx.Pair.GetQuote()) + switch fx.Side { + case paymenttypes.FXSideBuyBaseSellQuote: + return firstNonEmpty(base, quote) + case paymenttypes.FXSideSellBaseBuyQuote: + return firstNonEmpty(quote, base) + default: + return firstNonEmpty(quote, base) + } +} + +func sourceStepAmount(intent model.PaymentIntent, amount *moneyv1.Money) *moneyv1.Money { + result := cloneProtoMoney(amount) + if result == nil { + return nil + } + if currency := sourceStepCurrency(intent, result.GetCurrency()); currency != "" { + result.Currency = currency + } + return result +} + +func sourceStepCurrency(intent model.PaymentIntent, fallback string) string { + if currency := sourceCurrencyFromFX(intent.FX); currency != "" { + return currency + } + if currency := sourceAssetToken(intent.Source); currency != "" { + return currency + } + return normalizeAsset(fallback) +} + +func sourceCurrencyFromFX(fx *model.FXIntent) string { + if fx == nil || fx.Pair == nil { + return "" + } + base := normalizeAsset(fx.Pair.GetBase()) + quote := normalizeAsset(fx.Pair.GetQuote()) + switch fx.Side { + case paymenttypes.FXSideBuyBaseSellQuote: + return quote + case paymenttypes.FXSideSellBaseBuyQuote: + return base + default: + return firstNonEmpty(base, quote) + } +} + +func sourceAssetToken(endpoint model.PaymentEndpoint) string { + if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil { + if token := normalizeAsset(endpoint.ManagedWallet.Asset.GetTokenSymbol()); token != "" { + return token + } + } + if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil { + if token := normalizeAsset(endpoint.ExternalChain.Asset.GetTokenSymbol()); token != "" { + return token + } + } + return "" +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps_test.go new file mode 100644 index 00000000..d91b50d6 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps_test.go @@ -0,0 +1,93 @@ +package quote_computation_service + +import ( + "testing" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func TestDestinationStepAmount_UsesSideAwareCurrencyFallback(t *testing.T) { + tests := []struct { + name string + side paymenttypes.FXSide + want string + }{ + { + name: "buy_base_sell_quote uses base settlement currency", + side: paymenttypes.FXSideBuyBaseSellQuote, + want: "RUB", + }, + { + name: "sell_base_buy_quote uses quote settlement currency", + side: paymenttypes.FXSideSellBaseBuyQuote, + want: "USDT", + }, + { + name: "unspecified defaults to quote settlement currency", + side: paymenttypes.FXSideUnspecified, + want: "USDT", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + intent := model.PaymentIntent{ + RequiresFX: true, + FX: &model.FXIntent{ + Pair: &paymenttypes.CurrencyPair{ + Base: "RUB", + Quote: "USDT", + }, + Side: tt.side, + }, + } + + got := destinationStepAmount(intent, &moneyv1.Money{ + Amount: "100", + Currency: "EUR", + }) + if got == nil { + t.Fatal("expected destination amount") + } + if got.GetCurrency() != tt.want { + t.Fatalf("unexpected destination currency: got=%q want=%q", got.GetCurrency(), tt.want) + } + }) + } +} + +func TestSourceCurrencyFromFX_RespectsSide(t *testing.T) { + tests := []struct { + name string + side paymenttypes.FXSide + want string + }{ + { + name: "buy_base_sell_quote debits quote currency", + side: paymenttypes.FXSideBuyBaseSellQuote, + want: "USDT", + }, + { + name: "sell_base_buy_quote debits base currency", + side: paymenttypes.FXSideSellBaseBuyQuote, + want: "RUB", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sourceCurrencyFromFX(&model.FXIntent{ + Pair: &paymenttypes.CurrencyPair{ + Base: "RUB", + Quote: "USDT", + }, + Side: tt.side, + }) + if got != tt.want { + t.Fatalf("unexpected source currency: got=%q want=%q", got, tt.want) + } + }) + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go index 1b0215a0..11e558d8 100644 --- a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go @@ -104,10 +104,10 @@ func shapeMatches(record *model.PaymentQuoteRecord, shape QuoteShape) bool { switch shape { case QuoteShapeSingle: - return len(record.Quotes) == 0 + return record.RequestShape == model.QuoteRequestShapeSingle && len(record.Items) == 1 case QuoteShapeBatch: - return len(record.Quotes) > 0 + return record.RequestShape == model.QuoteRequestShapeBatch && len(record.Items) > 0 default: - return true + return record.RequestShape != model.QuoteRequestShapeUnspecified } } diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go index 73b8ffe0..f63956e9 100644 --- a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go @@ -39,11 +39,7 @@ func TestTryReuse_ParamMismatch(t *testing.T) { svc := New() store := &fakeQuotesStore{ getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "stored-hash", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - }, nil + return testSingleRecord("stored-hash", "q1"), nil }, } @@ -62,11 +58,7 @@ func TestTryReuse_ShapeMismatch(t *testing.T) { svc := New() store := &fakeQuotesStore{ getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "hash-1", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - }, nil + return testSingleRecord("hash-1", "q1"), nil }, } @@ -85,13 +77,7 @@ func TestTryReuse_ShapeMismatchSingle(t *testing.T) { svc := New() store := &fakeQuotesStore{ getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "hash-1", - Quotes: []*model.PaymentQuoteSnapshot{ - {QuoteRef: "q1"}, - }, - }, nil + return testBatchRecord("hash-1", "q1"), nil }, } @@ -108,11 +94,7 @@ func TestTryReuse_ShapeMismatchSingle(t *testing.T) { func TestTryReuse_Success(t *testing.T) { svc := New() - existing := &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "hash-1", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - } + existing := testSingleRecord("hash-1", "q1") store := &fakeQuotesStore{ getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { return existing, nil @@ -141,11 +123,7 @@ func TestCreateOrReuse_CreateSuccess(t *testing.T) { store := &fakeQuotesStore{ createFn: func(context.Context, *model.PaymentQuoteRecord) error { return nil }, } - record := &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "hash-1", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - } + record := testSingleRecord("hash-1", "q1") got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{ Record: record, @@ -169,22 +147,14 @@ func TestCreateOrReuse_CreateSuccess(t *testing.T) { func TestCreateOrReuse_DuplicateReturnsExisting(t *testing.T) { svc := New() - existing := &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "hash-1", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - } + existing := testSingleRecord("hash-1", "q1") store := &fakeQuotesStore{ createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote }, getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { return existing, nil }, } - record := &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "hash-1", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"}, - } + record := testSingleRecord("hash-1", "q2") got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{ Record: record, @@ -211,20 +181,12 @@ func TestCreateOrReuse_DuplicateParamMismatch(t *testing.T) { store := &fakeQuotesStore{ createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote }, getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "stored-hash", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - }, nil + return testSingleRecord("stored-hash", "q1"), nil }, } _, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{ - Record: &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "new-hash", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"}, - }, + Record: testSingleRecord("new-hash", "q2"), Reuse: ReuseInput{ OrganizationID: bson.NewObjectID(), IdempotencyKey: "idem-1", @@ -247,11 +209,7 @@ func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing } _, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{ - Record: &model.PaymentQuoteRecord{ - IdempotencyKey: "idem-1", - Hash: "hash-1", - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"}, - }, + Record: testSingleRecord("hash-1", "q2"), Reuse: ReuseInput{ OrganizationID: bson.NewObjectID(), IdempotencyKey: "idem-1", @@ -286,3 +244,33 @@ func (f *fakeQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.O } return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey) } + +func testSingleRecord(hash, quoteRef string) *model.PaymentQuoteRecord { + return &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: hash, + RequestShape: model.QuoteRequestShapeSingle, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-1"}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: quoteRef}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + } +} + +func testBatchRecord(hash, quoteRef string) *model.PaymentQuoteRecord { + return &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: hash, + RequestShape: model.QuoteRequestShapeBatch, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-1"}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: quoteRef}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go index cc2da92b..8ade4f8d 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go @@ -13,19 +13,18 @@ type StatusInput struct { BlockReason quotationv2.QuoteBlockReason } +type PersistItemInput struct { + Intent *model.PaymentIntent + Quote *model.PaymentQuoteSnapshot + Status *StatusInput +} + type PersistInput struct { OrganizationID bson.ObjectID QuoteRef string IdempotencyKey string Hash string ExpiresAt time.Time - - Intent *model.PaymentIntent - Intents []model.PaymentIntent - - Quote *model.PaymentQuoteSnapshot - Quotes []*model.PaymentQuoteSnapshot - - Status *StatusInput - Statuses []*StatusInput + RequestShape model.QuoteRequestShape + Items []PersistItemInput } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go index fe0465b6..96d2adfd 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go @@ -52,53 +52,46 @@ func (s *QuotePersistenceService) BuildRecord(in PersistInput) (*model.PaymentQu if in.ExpiresAt.IsZero() { return nil, merrors.InvalidArgument("expires_at is required") } - - isSingle := in.Quote != nil - isBatch := len(in.Quotes) > 0 - - if isSingle == isBatch { - return nil, merrors.InvalidArgument("exactly one quote shape is required") + switch in.RequestShape { + case model.QuoteRequestShapeSingle: + if len(in.Items) != 1 { + return nil, merrors.InvalidArgument("single shape requires exactly one item") + } + case model.QuoteRequestShapeBatch: + if len(in.Items) == 0 { + return nil, merrors.InvalidArgument("batch shape requires at least one item") + } + default: + return nil, merrors.InvalidArgument("request_shape is required") } record := &model.PaymentQuoteRecord{ QuoteRef: strings.TrimSpace(in.QuoteRef), IdempotencyKey: strings.TrimSpace(in.IdempotencyKey), + RequestShape: in.RequestShape, Hash: strings.TrimSpace(in.Hash), ExpiresAt: in.ExpiresAt, + Items: make([]*model.PaymentQuoteItemV2, 0, len(in.Items)), } record.SetID(bson.NewObjectID()) record.SetOrganizationRef(in.OrganizationID) - if isSingle { - if in.Intent == nil { - return nil, merrors.InvalidArgument("intent is required") + for idx, item := range in.Items { + if item.Intent == nil { + return nil, merrors.InvalidArgument("items[" + itoa(idx) + "].intent is required") } - status, err := mapStatusInput(in.Status) + if item.Quote == nil { + return nil, merrors.InvalidArgument("items[" + itoa(idx) + "].quote is required") + } + status, err := mapStatusInput(item.Status) if err != nil { - return nil, err + return nil, merrors.InvalidArgument("items[" + itoa(idx) + "]." + err.Error()) } - record.Intent = *in.Intent - record.Quote = in.Quote - record.StatusV2 = status - return record, nil + record.Items = append(record.Items, &model.PaymentQuoteItemV2{ + Intent: item.Intent, + Quote: item.Quote, + Status: status, + }) } - - if len(in.Intents) == 0 { - return nil, merrors.InvalidArgument("intents are required") - } - if len(in.Intents) != len(in.Quotes) { - return nil, merrors.InvalidArgument("intents and quotes count mismatch") - } - statuses, err := mapStatusInputs(in.Statuses) - if err != nil { - return nil, err - } - if len(statuses) != len(in.Quotes) { - return nil, merrors.InvalidArgument("statuses and quotes count mismatch") - } - - record.Intents = in.Intents - record.Quotes = in.Quotes - record.StatusesV2 = statuses return record, nil } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go index 053623af..82ad2536 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go @@ -24,12 +24,17 @@ func TestPersistSingle(t *testing.T) { IdempotencyKey: "idem-1", Hash: "hash-1", ExpiresAt: time.Now().Add(time.Minute), - Intent: &model.PaymentIntent{Ref: "intent-1"}, - Quote: &model.PaymentQuoteSnapshot{ - QuoteRef: "quote-1", - }, - Status: &StatusInput{ - State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, + RequestShape: model.QuoteRequestShapeSingle, + Items: []PersistItemInput{ + { + Intent: &model.PaymentIntent{Ref: "intent-1"}, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: "quote-1", + }, + Status: &StatusInput{ + State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, + }, + }, }, }) if err != nil { @@ -41,17 +46,20 @@ func TestPersistSingle(t *testing.T) { if store.created == nil { t.Fatalf("expected record to be created") } - if store.created.ExecutionNote != "" { - t.Fatalf("expected no legacy execution note, got %q", store.created.ExecutionNote) + if store.created.RequestShape != model.QuoteRequestShapeSingle { + t.Fatalf("unexpected request shape: %q", store.created.RequestShape) } - if store.created.StatusV2 == nil { - t.Fatalf("expected v2 status metadata") + if len(store.created.Items) != 1 || store.created.Items[0] == nil { + t.Fatalf("expected single persisted item") } - if store.created.StatusV2.State != model.QuoteStateExecutable { - t.Fatalf("unexpected state: %q", store.created.StatusV2.State) + if store.created.Items[0].Status == nil { + t.Fatalf("expected item status metadata") } - if store.created.StatusV2.BlockReason != model.QuoteBlockReasonUnspecified { - t.Fatalf("unexpected block_reason: %q", store.created.StatusV2.BlockReason) + if store.created.Items[0].Status.State != model.QuoteStateExecutable { + t.Fatalf("unexpected state: %q", store.created.Items[0].Status.State) + } + if store.created.Items[0].Status.BlockReason != model.QuoteBlockReasonUnspecified { + t.Fatalf("unexpected block_reason: %q", store.created.Items[0].Status.BlockReason) } } @@ -66,21 +74,22 @@ func TestPersistBatch(t *testing.T) { IdempotencyKey: "idem-batch-1", Hash: "hash-batch-1", ExpiresAt: time.Now().Add(time.Minute), - Intents: []model.PaymentIntent{ - {Ref: "i1"}, - {Ref: "i2"}, - }, - Quotes: []*model.PaymentQuoteSnapshot{ - {QuoteRef: "q1"}, - {QuoteRef: "q2"}, - }, - Statuses: []*StatusInput{ + RequestShape: model.QuoteRequestShapeBatch, + Items: []PersistItemInput{ { - State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED, - BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE, + Intent: &model.PaymentIntent{Ref: "i1"}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + Status: &StatusInput{ + State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED, + BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE, + }, }, { - State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE, + Intent: &model.PaymentIntent{Ref: "i2"}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"}, + Status: &StatusInput{ + State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE, + }, }, }, }) @@ -90,11 +99,14 @@ func TestPersistBatch(t *testing.T) { if record == nil { t.Fatalf("expected record") } - if len(record.StatusesV2) != 2 { - t.Fatalf("expected 2 statuses, got %d", len(record.StatusesV2)) + if record.RequestShape != model.QuoteRequestShapeBatch { + t.Fatalf("unexpected request shape: %q", record.RequestShape) } - if record.StatusesV2[0].BlockReason != model.QuoteBlockReasonRouteUnavailable { - t.Fatalf("unexpected first status block reason: %q", record.StatusesV2[0].BlockReason) + if len(record.Items) != 2 { + t.Fatalf("expected 2 items, got %d", len(record.Items)) + } + if record.Items[0].Status == nil || record.Items[0].Status.BlockReason != model.QuoteBlockReasonRouteUnavailable { + t.Fatalf("unexpected first status block reason") } } @@ -114,11 +126,16 @@ func TestPersistValidation(t *testing.T) { IdempotencyKey: "i", Hash: "h", ExpiresAt: time.Now().Add(time.Minute), - Intent: &model.PaymentIntent{Ref: "intent"}, - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"}, - Status: &StatusInput{ - State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED, - BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + RequestShape: model.QuoteRequestShapeSingle, + Items: []PersistItemInput{ + { + Intent: &model.PaymentIntent{Ref: "intent"}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"}, + Status: &StatusInput{ + State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED, + BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + }, + }, }, }) if !errors.Is(err, merrors.ErrInvalidArg) { @@ -131,16 +148,22 @@ func TestPersistValidation(t *testing.T) { IdempotencyKey: "i", Hash: "h", ExpiresAt: time.Now().Add(time.Minute), - Intents: []model.PaymentIntent{ - {Ref: "i1"}, + RequestShape: model.QuoteRequestShapeSingle, + Items: []PersistItemInput{ + { + Intent: &model.PaymentIntent{Ref: "i1"}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + Status: &StatusInput{State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE}, + }, + { + Intent: &model.PaymentIntent{Ref: "i2"}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"}, + Status: &StatusInput{State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE}, + }, }, - Quotes: []*model.PaymentQuoteSnapshot{ - {QuoteRef: "q1"}, - }, - Statuses: []*StatusInput{}, }) if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument for statuses mismatch, got %v", err) + t.Fatalf("expected invalid argument for single shape with multiple items, got %v", err) } } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go index 6bd1b14e..ab8320f7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go @@ -28,22 +28,6 @@ func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) { }, nil } -func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) { - if len(inputs) == 0 { - return nil, nil - } - - result := make([]*model.QuoteStatusV2, 0, len(inputs)) - for i, item := range inputs { - mapped, err := mapStatusInput(item) - if err != nil { - return nil, merrors.InvalidArgument("statuses[" + itoa(i) + "]: " + err.Error()) - } - result = append(result, mapped) - } - return result, nil -} - func mapQuoteState(state quotationv2.QuoteState) model.QuoteState { switch state { case quotationv2.QuoteState_QUOTE_STATE_INDICATIVE: diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go index 2a62a7a2..24fcc1fa 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -41,7 +42,7 @@ func TestMap_ExecutableQuote(t *testing.T) { }, Route: "ationv2.RouteSpecification{ Rail: "CARD", - Provider: "monetix", + Provider: paymenttypes.DefaultCardsGatewayID, PayoutMethod: "CARD", Settlement: "ationv2.RouteSettlement{ Asset: &paymentv1.ChainAsset{ @@ -100,7 +101,7 @@ func TestMap_ExecutableQuote(t *testing.T) { if got := out.Quote.GetPricedAt().AsTime(); !got.Equal(pricedAt) { t.Fatalf("unexpected priced_at: %v", got) } - if got, want := out.Quote.GetRoute().GetProvider(), "monetix"; got != want { + if got, want := out.Quote.GetRoute().GetProvider(), paymenttypes.DefaultCardsGatewayID; got != want { t.Fatalf("unexpected route provider: got=%q want=%q", got, want) } if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want { diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go index d14be17e..f6ab7930 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go @@ -139,7 +139,6 @@ Converts plan/build errors to QuoteBlockReason. Produces quote state (executable/blocked/indicative + block_reason when blocked). QuotePersistenceService Persists quote record with v2 status metadata. -Keeps legacy ExecutionNote for backward compatibility. QuoteResponseMapperV2 Maps canonical quote + status to quotationv2.PaymentQuote. Enforces your lifecycle/execution invariants. diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go index e115a766..02cc48db 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/pkg/merrors" payecon "github.com/tech/sendico/pkg/payments/economics" paymenttypes "github.com/tech/sendico/pkg/payments/types" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -133,6 +134,7 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn Comment: strings.TrimSpace(in.Intent.GetComment()), SettlementMode: settlementMode, FeeTreatment: feeTreatment, + FXSide: fxSideFromProto(in.Intent.GetFxSide()), SettlementCurrency: settlementCurrency, RequiresFX: requiresFX, Attributes: map[string]string{ @@ -210,3 +212,14 @@ func feeTreatmentFromProto(value quotationv2.FeeTreatment) QuoteFeeTreatment { return QuoteFeeTreatmentUnspecified } } + +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 + } +} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go index 419b2034..84dd3657 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go @@ -84,6 +84,7 @@ type QuoteIntent struct { Comment string SettlementMode QuoteSettlementMode FeeTreatment QuoteFeeTreatment + FXSide paymenttypes.FXSide SettlementCurrency string RequiresFX bool Attributes map[string]string diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go index 4b4a0d60..baa51757 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go @@ -7,6 +7,8 @@ import ( "testing" pkgmodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1" @@ -98,6 +100,51 @@ func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) { } } +func TestHydrateOne_PropagatesFXSide(t *testing.T) { + h := New(nil, WithRefFactory(func() string { return "q-intent-fx-side" })) + intent := "ationv2.QuoteIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src-1"}), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + Country: "US", + }), + }, + }, + }, + Amount: newMoney("10", "USDT"), + SettlementCurrency: "RUB", + FxSide: fxv1.Side_BUY_BASE_SELL_QUOTE, + } + + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err != nil { + t.Fatalf("HydrateOne returned error: %v", err) + } + if got == nil { + t.Fatalf("expected hydrated intent") + } + if got.FXSide != paymenttypes.FXSideBuyBaseSellQuote { + t.Fatalf("unexpected fx side: got=%q", got.FXSide) + } +} + func TestHydrateOne_ResolvesPaymentMethodRefViaPrivateMethod(t *testing.T) { orgRef := bson.NewObjectID().Hex() methodRef := bson.NewObjectID().Hex() @@ -681,14 +728,6 @@ func newMoney(amount, currency string) *moneyv1.Money { } } -func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint { - return &endpointv1.PaymentEndpoint{ - Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{ - PaymentMethodRef: methodRef, - }, - } -} - func mustMarshalBSON(t *testing.T, value any) []byte { t.Helper() data, err := bson.Marshal(value) diff --git a/api/payments/storage/model/quote.go b/api/payments/storage/model/quote.go index 31cd901f..2f460414 100644 --- a/api/payments/storage/model/quote.go +++ b/api/payments/storage/model/quote.go @@ -12,20 +12,13 @@ type PaymentQuoteRecord struct { storable.Base `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"` - QuoteRef string `bson:"quoteRef" json:"quoteRef"` - IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` - Intent PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"` - Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"` - Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"` - Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"` - StatusV2 *QuoteStatusV2 `bson:"statusV2,omitempty" json:"statusV2,omitempty"` - StatusesV2 []*QuoteStatusV2 `bson:"statusesV2,omitempty" json:"statusesV2,omitempty"` - Plan *PaymentPlan `bson:"plan,omitempty" json:"plan,omitempty"` - Plans []*PaymentPlan `bson:"plans,omitempty" json:"plans,omitempty"` - ExecutionNote string `bson:"executionNote,omitempty" json:"executionNote,omitempty"` - ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` - PurgeAt time.Time `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"` - Hash string `bson:"hash" json:"hash"` + QuoteRef string `bson:"quoteRef" json:"quoteRef"` + IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` + RequestShape QuoteRequestShape `bson:"requestShape,omitempty" json:"requestShape,omitempty"` + Items []*PaymentQuoteItemV2 `bson:"items,omitempty" json:"items,omitempty"` + ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` + PurgeAt time.Time `bson:"purgeAt,omitempty" json:"purgeAt,omitempty"` + Hash string `bson:"hash" json:"hash"` } // Collection implements storable.Storable. diff --git a/api/payments/storage/model/quote_v2.go b/api/payments/storage/model/quote_v2.go index 87cdeeb8..8ee33490 100644 --- a/api/payments/storage/model/quote_v2.go +++ b/api/payments/storage/model/quote_v2.go @@ -1,5 +1,14 @@ package model +// QuoteRequestShape identifies the API surface that created the quote record. +type QuoteRequestShape string + +const ( + QuoteRequestShapeUnspecified QuoteRequestShape = "unspecified" + QuoteRequestShapeSingle QuoteRequestShape = "single" + QuoteRequestShapeBatch QuoteRequestShape = "batch" +) + // QuoteState captures v2 quote state metadata for persistence. type QuoteState string @@ -30,3 +39,10 @@ type QuoteStatusV2 struct { State QuoteState `bson:"state,omitempty" json:"state,omitempty"` BlockReason QuoteBlockReason `bson:"blockReason,omitempty" json:"blockReason,omitempty"` } + +// PaymentQuoteItemV2 keeps one intent/quote/status tuple in a stable shape. +type PaymentQuoteItemV2 struct { + Intent *PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"` + Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"` + Status *QuoteStatusV2 `bson:"status,omitempty" json:"status,omitempty"` +} diff --git a/api/payments/storage/quote/mongo/store/quotes.go b/api/payments/storage/quote/mongo/store/quotes.go index d780fd2f..48ffcab9 100644 --- a/api/payments/storage/quote/mongo/store/quotes.go +++ b/api/payments/storage/quote/mongo/store/quotes.go @@ -3,6 +3,7 @@ package store import ( "context" "errors" + "strconv" "strings" "time" @@ -90,25 +91,39 @@ func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) er if quote.IdempotencyKey == "" { return merrors.InvalidArgument("quotesStore: idempotency key is required") } - quote.ExecutionNote = strings.TrimSpace(quote.ExecutionNote) + quote.RequestShape = model.QuoteRequestShape(strings.TrimSpace(string(quote.RequestShape))) + if quote.RequestShape == "" || quote.RequestShape == model.QuoteRequestShapeUnspecified { + return merrors.InvalidArgument("quotesStore: request shape is required") + } + if len(quote.Items) == 0 { + return merrors.InvalidArgument("quotesStore: items are required") + } + if quote.RequestShape == model.QuoteRequestShapeSingle && len(quote.Items) != 1 { + return merrors.InvalidArgument("quotesStore: single shape requires exactly one item") + } if quote.ExpiresAt.IsZero() { return merrors.InvalidArgument("quotesStore: expires_at is required") } if quote.PurgeAt.IsZero() || quote.PurgeAt.Before(quote.ExpiresAt) { quote.PurgeAt = quote.ExpiresAt.Add(q.retention) } - if quote.Intent.Attributes != nil { - for k, v := range quote.Intent.Attributes { - quote.Intent.Attributes[k] = strings.TrimSpace(v) + for i := range quote.Items { + item := quote.Items[i] + if item == nil { + return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "] is required") } - } - if len(quote.Intents) > 0 { - for i := range quote.Intents { - if quote.Intents[i].Attributes == nil { - continue - } - for k, v := range quote.Intents[i].Attributes { - quote.Intents[i].Attributes[k] = strings.TrimSpace(v) + if item.Intent == nil { + return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].intent is required") + } + if item.Quote == nil { + return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].quote is required") + } + if item.Status == nil { + return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].status is required") + } + if item.Intent.Attributes != nil { + for k, v := range item.Intent.Attributes { + item.Intent.Attributes[k] = strings.TrimSpace(v) } } } diff --git a/api/pkg/payments/types/gateway_ids.go b/api/pkg/payments/types/gateway_ids.go new file mode 100644 index 00000000..f11b1a79 --- /dev/null +++ b/api/pkg/payments/types/gateway_ids.go @@ -0,0 +1,6 @@ +package types + +// Well-known gateway identifiers used across payment routing. +const ( + DefaultCardsGatewayID = "mcards" +) diff --git a/api/proto/payments/orchestration/v2/orchestration.proto b/api/proto/payments/orchestration/v2/orchestration.proto index d47fb312..48c4a808 100644 --- a/api/proto/payments/orchestration/v2/orchestration.proto +++ b/api/proto/payments/orchestration/v2/orchestration.proto @@ -152,6 +152,10 @@ message StepExecution { Failure failure = 7; // External references produced by the step. repeated ExternalReference refs = 8; + // Reporting visibility for user/backoffice/audit projections. + ReportVisibility report_visibility = 9; + // Optional user-facing operation label. + string user_label = 10; } // Kept local on purpose: no shared enum exists for orchestration step runtime. @@ -172,6 +176,20 @@ enum StepExecutionState { STEP_EXECUTION_STATE_SKIPPED = 6; } +// ReportVisibility determines which audience should see the step. +enum ReportVisibility { + // Default zero value. + REPORT_VISIBILITY_UNSPECIFIED = 0; + // Hidden from all external reports. + REPORT_VISIBILITY_HIDDEN = 1; + // Visible to end users. + REPORT_VISIBILITY_USER = 2; + // Visible to backoffice operators. + REPORT_VISIBILITY_BACKOFFICE = 3; + // Visible only for audit/compliance review. + REPORT_VISIBILITY_AUDIT = 4; +} + // Failure describes a normalized step failure. message Failure { // Broad, shared failure category. diff --git a/api/proto/payments/quotation/v2/quotation.proto b/api/proto/payments/quotation/v2/quotation.proto index b5427f87..ccce05eb 100644 --- a/api/proto/payments/quotation/v2/quotation.proto +++ b/api/proto/payments/quotation/v2/quotation.proto @@ -6,6 +6,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v2;quo import "api/proto/payments/shared/v1/shared.proto"; import "api/proto/common/money/v1/money.proto"; +import "api/proto/common/fx/v1/fx.proto"; import "api/proto/common/payment/v1/settlement.proto"; import "api/proto/payments/endpoint/v1/endpoint.proto"; import "api/proto/payments/quotation/v2/interface.proto"; @@ -19,6 +20,7 @@ message QuoteIntent { payments.quotation.v2.FeeTreatment fee_treatment = 5; string settlement_currency = 6; string comment = 7; + common.fx.v1.Side fx_side = 8; } // QuotePaymentRequest is the request to quote a single v2 payment. diff --git a/api/server/interface/api/sresponse/money.go b/api/server/interface/api/sresponse/money.go index 93291d12..4e751a7b 100644 --- a/api/server/interface/api/sresponse/money.go +++ b/api/server/interface/api/sresponse/money.go @@ -14,19 +14,3 @@ func toMoney(m *moneyv1.Money) *paymenttypes.Money { Currency: m.GetCurrency(), } } - -func toMoneyList(list []*moneyv1.Money) []*paymenttypes.Money { - if len(list) == 0 { - return nil - } - result := make([]*paymenttypes.Money, 0, len(list)) - for _, item := range list { - if m := toMoney(item); m != nil { - result = append(result, m) - } - } - if len(result) == 0 { - return nil - } - return result -} diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 31a35a8a..1aa4c4a7 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -64,14 +64,26 @@ type PaymentQuotes struct { } type Payment struct { - PaymentRef string `json:"paymentRef,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` - State string `json:"state,omitempty"` - FailureCode string `json:"failureCode,omitempty"` - FailureReason string `json:"failureReason,omitempty"` - LastQuote *PaymentQuote `json:"lastQuote,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - Meta map[string]string `json:"meta,omitempty"` + PaymentRef string `json:"paymentRef,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + State string `json:"state,omitempty"` + FailureCode string `json:"failureCode,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + Operations []PaymentOperation `json:"operations,omitempty"` + LastQuote *PaymentQuote `json:"lastQuote,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +type PaymentOperation struct { + StepRef string `json:"stepRef,omitempty"` + Code string `json:"code,omitempty"` + State string `json:"state,omitempty"` + Label string `json:"label,omitempty"` + FailureCode string `json:"failureCode,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + StartedAt time.Time `json:"startedAt,omitempty"` + CompletedAt time.Time `json:"completedAt,omitempty"` } type paymentQuoteResponse struct { @@ -269,12 +281,14 @@ func toPayment(p *orchestrationv2.Payment) *Payment { if p == nil { return nil } - failureCode, failureReason := firstFailure(p.GetStepExecutions()) + operations := toUserVisibleOperations(p.GetStepExecutions()) + failureCode, failureReason := firstFailure(operations) return &Payment{ PaymentRef: p.GetPaymentRef(), State: enumJSONName(p.GetState().String()), FailureCode: failureCode, FailureReason: failureReason, + Operations: operations, LastQuote: toPaymentQuote(p.GetQuoteSnapshot()), CreatedAt: timestampAsTime(p.GetCreatedAt()), Meta: paymentMeta(p), @@ -282,21 +296,65 @@ func toPayment(p *orchestrationv2.Payment) *Payment { } } -func firstFailure(steps []*orchestrationv2.StepExecution) (string, string) { - for _, step := range steps { - if step == nil || step.GetFailure() == nil { +func firstFailure(operations []PaymentOperation) (string, string) { + for _, op := range operations { + if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" { continue } - failure := step.GetFailure() - message := strings.TrimSpace(failure.GetMessage()) - if message == "" { - message = strings.TrimSpace(failure.GetCode()) - } - return enumJSONName(failure.GetCategory().String()), message + return strings.TrimSpace(op.FailureCode), strings.TrimSpace(op.FailureReason) } return "", "" } +func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation { + if len(steps) == 0 { + return nil + } + ops := make([]PaymentOperation, 0, len(steps)) + for _, step := range steps { + if step == nil || !isUserVisibleStep(step.GetReportVisibility()) { + continue + } + ops = append(ops, toPaymentOperation(step)) + } + if len(ops) == 0 { + return nil + } + return ops +} + +func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation { + op := PaymentOperation{ + StepRef: step.GetStepRef(), + Code: step.GetStepCode(), + State: enumJSONName(step.GetState().String()), + Label: strings.TrimSpace(step.GetUserLabel()), + StartedAt: timestampAsTime(step.GetStartedAt()), + CompletedAt: timestampAsTime(step.GetCompletedAt()), + } + failure := step.GetFailure() + if failure == nil { + return op + } + op.FailureCode = enumJSONName(failure.GetCategory().String()) + op.FailureReason = strings.TrimSpace(failure.GetMessage()) + if op.FailureReason == "" { + op.FailureReason = strings.TrimSpace(failure.GetCode()) + } + return op +} + +func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool { + switch visibility { + case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN, + orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE, + orchestrationv2.ReportVisibility_REPORT_VISIBILITY_AUDIT: + return false + default: + return true + } +} + func paymentMeta(p *orchestrationv2.Payment) map[string]string { if p == nil { return nil diff --git a/api/server/interface/api/sresponse/payment_test.go b/api/server/interface/api/sresponse/payment_test.go new file mode 100644 index 00000000..12883537 --- /dev/null +++ b/api/server/interface/api/sresponse/payment_test.go @@ -0,0 +1,119 @@ +package sresponse + +import ( + "testing" + + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" +) + +func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) { + steps := []*orchestrationv2.StepExecution{ + { + StepRef: "hidden", + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN, + }, + { + StepRef: "user", + StepCode: "hop.4.card_payout.send", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER, + }, + { + StepRef: "unspecified", + StepCode: "hop.4.card_payout.observe", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_UNSPECIFIED, + }, + { + StepRef: "backoffice", + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE, + }, + } + + ops := toUserVisibleOperations(steps) + if len(ops) != 2 { + t.Fatalf("operations count mismatch: got=%d want=2", len(ops)) + } + if got, want := ops[0].StepRef, "user"; got != want { + t.Fatalf("first operation step_ref mismatch: got=%q want=%q", got, want) + } + if got, want := ops[1].StepRef, "unspecified"; got != want { + t.Fatalf("second operation step_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestToPaymentFailureUsesVisibleOperationsOnly(t *testing.T) { + dto := toPayment(&orchestrationv2.Payment{ + PaymentRef: "pay-1", + State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED, + StepExecutions: []*orchestrationv2.StepExecution{ + { + StepRef: "hidden_failed", + StepCode: "edge.1_2.ledger.debit", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN, + Failure: &orchestrationv2.Failure{ + Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER, + Message: "internal hold release failure", + }, + }, + { + StepRef: "user_failed", + StepCode: "hop.4.card_payout.send", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER, + Failure: &orchestrationv2.Failure{ + Category: sharedv1.PaymentFailureCode_FAILURE_CHAIN, + Message: "card declined", + }, + }, + }, + }) + if dto == nil { + t.Fatal("expected non-nil payment dto") + } + if got, want := dto.FailureCode, "failure_chain"; got != want { + t.Fatalf("failure_code mismatch: got=%q want=%q", got, want) + } + if got, want := dto.FailureReason, "card declined"; got != want { + t.Fatalf("failure_reason mismatch: got=%q want=%q", got, want) + } + if len(dto.Operations) != 1 { + t.Fatalf("operations count mismatch: got=%d want=1", len(dto.Operations)) + } + if got, want := dto.Operations[0].StepRef, "user_failed"; got != want { + t.Fatalf("visible operation mismatch: got=%q want=%q", got, want) + } +} + +func TestToPaymentIgnoresHiddenFailures(t *testing.T) { + dto := toPayment(&orchestrationv2.Payment{ + PaymentRef: "pay-2", + State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED, + StepExecutions: []*orchestrationv2.StepExecution{ + { + StepRef: "hidden_failed", + StepCode: "edge.1_2.ledger.release", + State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED, + ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE, + Failure: &orchestrationv2.Failure{ + Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER, + Message: "backoffice only failure", + }, + }, + }, + }) + if dto == nil { + t.Fatal("expected non-nil payment dto") + } + if got := dto.FailureCode; got != "" { + t.Fatalf("expected empty failure_code, got=%q", got) + } + if got := dto.FailureReason; got != "" { + t.Fatalf("expected empty failure_reason, got=%q", got) + } + if len(dto.Operations) != 0 { + t.Fatalf("expected no visible operations, got=%d", len(dto.Operations)) + } +} diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index cf66b04e..a2e76f37 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -8,6 +8,7 @@ import ( pkgmodel "github.com/tech/sendico/pkg/model" payecon "github.com/tech/sendico/pkg/payments/economics" paymenttypes "github.com/tech/sendico/pkg/payments/types" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" @@ -58,6 +59,7 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e SettlementMode: resolvedSettlementMode, FeeTreatment: resolvedFeeTreatment, SettlementCurrency: settlementCurrency, + FxSide: mapFXSide(intent), } if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" { quoteIntent.Comment = comment @@ -65,6 +67,20 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e return quoteIntent, nil } +func mapFXSide(intent *srequest.PaymentIntent) fxv1.Side { + if intent == nil || intent.FX == nil { + return fxv1.Side_SIDE_UNSPECIFIED + } + switch strings.TrimSpace(string(intent.FX.Side)) { + case string(srequest.FXSideBuyBaseSellQuote): + return fxv1.Side_BUY_BASE_SELL_QUOTE + case string(srequest.FXSideSellBaseBuyQuote): + return fxv1.Side_SELL_BASE_BUY_QUOTE + default: + return fxv1.Side_SIDE_UNSPECIFIED + } +} + func validatePaymentKind(kind srequest.PaymentKind) error { switch strings.TrimSpace(string(kind)) { case string(srequest.PaymentKindPayout), string(srequest.PaymentKindInternalTransfer), string(srequest.PaymentKindFxConversion): diff --git a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go index 89f8f349..b1d65c8f 100644 --- a/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go +++ b/api/server/internal/server/paymentapiimp/mapper_fee_treatment_test.go @@ -4,6 +4,7 @@ import ( "testing" paymenttypes "github.com/tech/sendico/pkg/payments/types" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "github.com/tech/sendico/server/interface/api/srequest" @@ -201,4 +202,54 @@ func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) { if got.GetSettlementCurrency() != "RUB" { t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) } + if got.GetFxSide() != fxv1.Side_SELL_BASE_BUY_QUOTE { + t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String()) + } +} + +func TestMapQuoteIntent_PropagatesFXSideBuyBaseSellQuote(t *testing.T) { + source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-source-1", + }, nil) + if err != nil { + t.Fatalf("failed to build source endpoint: %v", err) + } + + destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{ + Pan: "2200700142860161", + FirstName: "John", + LastName: "Doe", + ExpMonth: 3, + ExpYear: 2030, + }, nil) + if err != nil { + t.Fatalf("failed to build destination endpoint: %v", err) + } + + intent := &srequest.PaymentIntent{ + Kind: srequest.PaymentKindPayout, + Source: &source, + Destination: &destination, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + SettlementMode: srequest.SettlementModeFixSource, + FeeTreatment: srequest.FeeTreatmentAddToSource, + FX: &srequest.FXIntent{ + Pair: &srequest.CurrencyPair{ + Base: "RUB", + Quote: "USDT", + }, + Side: srequest.FXSideBuyBaseSellQuote, + }, + } + + got, err := mapQuoteIntent(intent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.GetFxSide() != fxv1.Side_BUY_BASE_SELL_QUOTE { + t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String()) + } + if got.GetSettlementCurrency() != "RUB" { + t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency()) + } } diff --git a/frontend/pshared/lib/data/dto/payment/intent/payment.dart b/frontend/pshared/lib/data/dto/payment/intent/payment.dart index 7327ddef..7a21d973 100644 --- a/frontend/pshared/lib/data/dto/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/intent/payment.dart @@ -37,7 +37,6 @@ class PaymentIntentDTO { this.feeTreatment, }); - factory PaymentIntentDTO.fromJson(Map json) => - _$PaymentIntentDTOFromJson(json); + factory PaymentIntentDTO.fromJson(Map json) => _$PaymentIntentDTOFromJson(json); Map toJson() => _$PaymentIntentDTOToJson(this); } diff --git a/frontend/pshared/lib/data/mapper/payment/intent/fx.dart b/frontend/pshared/lib/data/mapper/payment/intent/fx.dart index fbafbc25..cd36a201 100644 --- a/frontend/pshared/lib/data/mapper/payment/intent/fx.dart +++ b/frontend/pshared/lib/data/mapper/payment/intent/fx.dart @@ -6,22 +6,22 @@ import 'package:pshared/models/payment/fx/intent.dart'; extension FxIntentMapper on FxIntent { FxIntentDTO toDTO() => FxIntentDTO( - pair: pair?.toDTO(), - side: fxSideToValue(side), - firm: firm, - ttlMs: ttlMs, - preferredProvider: preferredProvider, - maxAgeMs: maxAgeMs, - ); + pair: pair?.toDTO(), + side: fxSideToValue(side), + firm: firm, + ttlMs: ttlMs, + preferredProvider: preferredProvider, + maxAgeMs: maxAgeMs, + ); } extension FxIntentDTOMapper on FxIntentDTO { FxIntent toDomain() => FxIntent( - pair: pair?.toDomain(), - side: fxSideFromValue(side), - firm: firm, - ttlMs: ttlMs, - preferredProvider: preferredProvider, - maxAgeMs: maxAgeMs, - ); + pair: pair?.toDomain(), + side: fxSideFromValue(side), + firm: firm, + ttlMs: ttlMs, + preferredProvider: preferredProvider, + maxAgeMs: maxAgeMs, + ); } diff --git a/frontend/pshared/pubspec.yaml b/frontend/pshared/pubspec.yaml index bcb81b4c..836c0ab1 100644 --- a/frontend/pshared/pubspec.yaml +++ b/frontend/pshared/pubspec.yaml @@ -8,7 +8,7 @@ environment: # Add regular dependencies here. dependencies: analyzer: ^10.0.0 - json_annotation: ^4.10.0 + json_annotation: ^4.11.0 http: ^1.1.0 provider: ^6.0.5 flutter: diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 7c4154b1..9d6ce40e 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -7,7 +7,9 @@ import 'package:pshared/api/requests/payment/initiate_payments.dart'; import 'package:pshared/api/requests/payment/quote.dart'; import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/data/dto/money.dart'; +import 'package:pshared/data/dto/payment/currency_pair.dart'; import 'package:pshared/data/dto/payment/endpoint.dart'; +import 'package:pshared/data/dto/payment/intent/fx.dart'; import 'package:pshared/data/dto/payment/intent/payment.dart'; import 'package:pshared/data/mapper/payment/payment.dart'; import 'package:pshared/models/payment/asset.dart'; @@ -76,6 +78,36 @@ void main() { expect(destination['type'], equals('cardToken')); }); + test('quote payment request serializes fx side to backend value', () { + final request = QuotePaymentRequest( + idempotencyKey: '', + previewOnly: true, + intent: const PaymentIntentDTO( + kind: 'payout', + source: PaymentEndpointDTO( + type: 'managedWallet', + data: {'managed_wallet_ref': 'mw-1'}, + ), + destination: PaymentEndpointDTO( + type: 'cardToken', + data: {'token': 'tok_1', 'masked_pan': '4111'}, + ), + amount: MoneyDTO(amount: '10', currency: 'USDT'), + settlementMode: 'fix_source', + fx: FxIntentDTO( + pair: CurrencyPairDTO(base: 'RUB', quote: 'USDT'), + side: 'buy_base_sell_quote', + ), + ), + ); + + final json = + jsonDecode(jsonEncode(request.toJson())) as Map; + final intent = json['intent'] as Map; + final fx = intent['fx'] as Map; + expect(fx['side'], equals('buy_base_sell_quote')); + }); + test('quote response parses backend fx quote pricedAtUnixMs', () { final response = PaymentQuoteResponse.fromJson({ 'accessToken': {'token': 'token', 'expiration': '2026-02-25T00:00:00Z'}, diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index a190ace0..a14f021a 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -52,7 +52,7 @@ dependencies: share_plus: ^12.0.1 collection: ^1.18.0 flutter_timezone: ^5.0.1 - json_annotation: ^4.10.0 + json_annotation: ^4.11.0 go_router: ^17.0.0 jovial_svg: ^1.1.23 cached_network_image: ^3.4.1 -- 2.49.1 From 54e5c799e85e86f266ef2acdaab7e83b3f585845 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 10:18:40 +0100 Subject: [PATCH 13/23] fixed ledger boundaries operatoin types --- .../service/orchestrationv2/xplan/compile_flow_test.go | 8 ++++---- .../service/orchestrationv2/xplan/service_boundaries.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go index ecb5453d..8dc2b5a1 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go @@ -49,11 +49,11 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) { assertStep(t, graph.Steps[0], "hop.10.crypto.send", model.RailOperationSend, model.RailCrypto, model.ReportVisibilityBackoffice) assertStep(t, graph.Steps[1], "hop.10.crypto.observe", model.RailOperationObserveConfirm, model.RailCrypto, model.ReportVisibilityBackoffice) - assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationExternalCredit, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[3], "edge.10_20.ledger.block", model.RailOperationBlock, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[4], "hop.20.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser) assertStep(t, graph.Steps[5], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser) - assertStep(t, graph.Steps[6], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[6], "edge.10_20.ledger.debit", model.RailOperationExternalDebit, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[7], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[8], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) @@ -125,7 +125,7 @@ func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T) assertStep(t, graph.Steps[0], "edge.10_20.ledger.block", model.RailOperationBlock, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[1], "hop.20.card_payout.send", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser) assertStep(t, graph.Steps[2], "hop.20.card_payout.observe", model.RailOperationObserveConfirm, model.RailCardPayout, model.ReportVisibilityUser) - assertStep(t, graph.Steps[3], "edge.10_20.ledger.debit", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[3], "edge.10_20.ledger.debit", model.RailOperationExternalDebit, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[4], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) assertStep(t, graph.Steps[5], "edge.10_20.ledger.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) if got, want := graph.Steps[4].CommitAfter, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) { @@ -158,7 +158,7 @@ func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) { } assertStep(t, graph.Steps[0], "hop.10.crypto.send", model.RailOperationSend, model.RailCrypto, model.ReportVisibilityBackoffice) assertStep(t, graph.Steps[1], "hop.10.crypto.observe", model.RailOperationObserveConfirm, model.RailCrypto, model.ReportVisibilityBackoffice) - assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationExternalCredit, model.RailLedger, model.ReportVisibilityHidden) } func TestCompile_ExternalViaSettlement_UsesFXConvertOnSettlementHop(t *testing.T) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go index f8b2d431..bc2221b5 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -195,7 +195,7 @@ func makeFundsCreditStep(from normalizedHop, to normalizedHop, rail model.Rail) return Step{ StepCode: edgeCode(from, to, rail, "credit"), Kind: StepKindFundsCredit, - Action: model.RailOperationCredit, + Action: model.RailOperationExternalCredit, Rail: rail, HopIndex: to.index, HopRole: paymenttypes.QuoteRouteHopRoleTransit, @@ -256,7 +256,7 @@ func appendSettlementBranches( successStep := Step{ StepCode: edgeCode(from, to, rail, "debit"), Kind: StepKindFundsDebit, - Action: model.RailOperationDebit, + Action: model.RailOperationExternalDebit, DependsOn: []string{anchorObserveRef}, Rail: rail, HopIndex: to.index, -- 2.49.1 From 336f352858c450884203528d28a3bc7309b0212d Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 16:25:52 +0100 Subject: [PATCH 14/23] Fixed billing fees unreachable error propagation. Added USDT ledger creation. Fixed ledger boundaries operation types --- .../internal/service/documents/service.go | 2 +- .../fees/internal/service/fees/service.go | 2 +- .../internal/server/internal/discovery.go | 2 +- api/fx/ingestor/internal/app/app.go | 2 +- .../oracle/internal/service/oracle/service.go | 2 +- api/ledger/client/client.go | 126 +++++++++---- api/ledger/client/client_test.go | 64 +++++++ api/ledger/client/fake.go | 50 ++++-- .../internal/service/ledger/connector.go | 20 ++- api/ledger/internal/service/ledger/metrics.go | 54 ++++-- api/ledger/internal/service/ledger/posting.go | 20 +-- .../internal/service/ledger/posting_debit.go | 18 +- .../service/ledger/posting_external.go | 44 ++--- .../internal/service/ledger/posting_fx.go | 10 +- .../service/ledger/posting_transfer.go | 8 +- api/ledger/internal/service/ledger/service.go | 170 ++++++++++-------- .../server/notificationimp/notification.go | 2 +- .../internal/server/internal/discovery.go | 4 +- .../internal/server/internal/dependencies.go | 13 -- .../internal/server/internal/discovery.go | 2 +- .../server/internal/discovery_clients.go | 50 ++++-- .../server/internal/discovery_wrappers.go | 16 ++ .../service/orchestrator/ledger_executor.go | 45 +++-- .../orchestrator/ledger_executor_test.go | 140 +++++++++++++++ .../internal/server/internal/discovery.go | 2 +- .../managed_wallet_network_resolver.go | 12 -- .../quotation/plan_builder_adapters.go | 4 - .../service/quotation/quote_engine.go | 6 +- .../quote_engine_conversion_fee_test.go | 88 ++++++++- api/pkg/discovery/operations.go | 71 ++++++++ api/pkg/discovery/rail_vocab.go | 24 ++- api/pkg/discovery/rail_vocab_test.go | 4 +- api/server/internal/api/discovery_resolver.go | 41 +---- .../server/paymentapiimp/documents.go | 2 +- .../dashboard/buttons/balance/add/form.dart | 2 +- .../balance/add/ledger/currency_item.dart | 10 ++ .../fields.dart} | 8 +- 37 files changed, 838 insertions(+), 302 deletions(-) create mode 100644 api/pkg/discovery/operations.go create mode 100644 frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/currency_item.dart rename frontend/pweb/lib/pages/dashboard/buttons/balance/add/{ledger_fields.dart => ledger/fields.dart} (81%) diff --git a/api/billing/documents/internal/service/documents/service.go b/api/billing/documents/internal/service/documents/service.go index 1510f0bb..e8a3dbdb 100644 --- a/api/billing/documents/internal/service/documents/service.go +++ b/api/billing/documents/internal/service/documents/service.go @@ -392,7 +392,7 @@ func (s *Service) startDiscoveryAnnouncer() { announce := discovery.Announcement{ Service: "BILLING_DOCUMENTS", - Operations: []string{"documents.batch_resolve", "documents.get"}, + Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet}, InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } diff --git a/api/billing/fees/internal/service/fees/service.go b/api/billing/fees/internal/service/fees/service.go index aac2b5de..2cdec8d7 100644 --- a/api/billing/fees/internal/service/fees/service.go +++ b/api/billing/fees/internal/service/fees/service.go @@ -564,7 +564,7 @@ func (s *Service) startDiscoveryAnnouncer() { announce := discovery.Announcement{ Service: "BILLING_FEES", - Operations: []string{"fee.calc"}, + Operations: []string{discovery.OperationFeeCalc}, InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } diff --git a/api/discovery/internal/server/internal/discovery.go b/api/discovery/internal/server/internal/discovery.go index 7e5186f3..9d66bc8a 100644 --- a/api/discovery/internal/server/internal/discovery.go +++ b/api/discovery/internal/server/internal/discovery.go @@ -51,7 +51,7 @@ func (i *Imp) startDiscovery(cfg *config) error { announce := discovery.Announcement{ Service: "DISCOVERY", InstanceID: discovery.InstanceID(), - Operations: []string{"discovery.lookup"}, + Operations: []string{discovery.OperationDiscoveryLookup}, Version: appversion.Create().Short(), } i.announcer = discovery.NewAnnouncer(i.logger, producer, mservice.Discovery, announce) diff --git a/api/fx/ingestor/internal/app/app.go b/api/fx/ingestor/internal/app/app.go index 1f599a36..dcdc3eef 100644 --- a/api/fx/ingestor/internal/app/app.go +++ b/api/fx/ingestor/internal/app/app.go @@ -86,7 +86,7 @@ func (a *App) Run(ctx context.Context) error { producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker) announce := discovery.Announcement{ Service: "FX_INGESTOR", - Operations: []string{"fx.ingest"}, + Operations: []string{discovery.OperationFXIngest}, Version: appversion.Create().Short(), } announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce) diff --git a/api/fx/oracle/internal/service/oracle/service.go b/api/fx/oracle/internal/service/oracle/service.go index 195df8aa..12c9cb63 100644 --- a/api/fx/oracle/internal/service/oracle/service.go +++ b/api/fx/oracle/internal/service/oracle/service.go @@ -106,7 +106,7 @@ func (s *Service) startDiscoveryAnnouncer() { } announce := discovery.Announcement{ Service: "FX_ORACLE", - Operations: []string{"fx.quote"}, + Operations: []string{discovery.OperationFXQuote}, InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index 161961e9..b1f26419 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model/account_role" @@ -23,7 +24,36 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -const ledgerConnectorID = "ledger" +const ( + ledgerConnectorID = "ledger" + ledgerRailName = "LEDGER" + + opParamOperation = "operation" + opParamToMoney = "to_money" + opParamAmount = "amount" + opParamCurrency = "currency" + opParamOrganizationRef = "organization_ref" + opParamAccountType = "account_type" + opParamStatus = "status" + opParamAllowNegative = "allow_negative" + opParamRole = "role" + opParamDescription = "description" + opParamMetadata = "metadata" + opParamCharges = "charges" + opParamEventTime = "event_time" + opParamContraLedgerAccountRef = "contra_ledger_account_ref" + opParamLedgerAccountRef = "ledger_account_ref" + opParamLineType = "line_type" + opParamAccountCode = "account_code" + opParamIsSettlement = "is_settlement" + + txMetaPaymentPlanID = "payment_plan_id" + txMetaFromRail = "from_rail" + txMetaToRail = "to_rail" + txMetaExternalReference = "external_reference_id" + txMetaFXRateUsed = "fx_rate_used" + txMetaFeeAmount = "fee_amount" +) // Client exposes typed helpers around the ledger gRPC API. type Client interface { @@ -36,6 +66,8 @@ type Client interface { ListConnectorAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) + PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) + PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) @@ -148,7 +180,7 @@ func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx) metadata := ledgerTxMetadata(tx.Metadata, tx) extraParams := map[string]interface{}{} if op := strings.TrimSpace(tx.Operation); op != "" { - extraParams["operation"] = op + extraParams[opParamOperation] = op } if len(extraParams) == 0 { extraParams = nil @@ -204,13 +236,13 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc return nil, merrors.InvalidArgument("ledger: currency is required") } params := map[string]interface{}{ - "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), - "account_type": req.GetAccountType().String(), - "status": req.GetStatus().String(), - "allow_negative": req.GetAllowNegative(), + opParamOrganizationRef: strings.TrimSpace(req.GetOrganizationRef()), + opParamAccountType: req.GetAccountType().String(), + opParamStatus: req.GetStatus().String(), + opParamAllowNegative: req.GetAllowNegative(), } if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { - params["role"] = role.String() + params[opParamRole] = role.String() } label := "" if desc := req.GetDescribable(); desc != nil { @@ -218,12 +250,12 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc if desc.Description != nil { trimmed := strings.TrimSpace(desc.GetDescription()) if trimmed != "" { - params["description"] = trimmed + params[opParamDescription] = trimmed } } } if len(req.GetMetadata()) > 0 { - params["metadata"] = mapStringToInterface(req.GetMetadata()) + params[opParamMetadata] = mapStringToInterface(req.GetMetadata()) } resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{ Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, @@ -277,6 +309,30 @@ func (c *ledgerClient) PostDebitWithCharges(ctx context.Context, req *ledgerv1.P return c.submitLedgerOperation(ctx, connectorv1.OperationType_DEBIT, req.GetLedgerAccountRef(), "", req.GetMoney(), req) } +func (c *ledgerClient) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + return c.submitLedgerOperationWithExtras( + ctx, + connectorv1.OperationType_CREDIT, + "", + req.GetLedgerAccountRef(), + req.GetMoney(), + req, + map[string]interface{}{opParamOperation: discovery.OperationExternalCredit}, + ) +} + +func (c *ledgerClient) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + return c.submitLedgerOperationWithExtras( + ctx, + connectorv1.OperationType_DEBIT, + req.GetLedgerAccountRef(), + "", + req.GetMoney(), + req, + map[string]interface{}{opParamOperation: discovery.OperationExternalDebit}, + ) +} + func (c *ledgerClient) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { return c.submitLedgerOperation(ctx, connectorv1.OperationType_TRANSFER, req.GetFromLedgerAccountRef(), req.GetToLedgerAccountRef(), req.GetMoney(), req) } @@ -292,7 +348,7 @@ func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXR } params := ledgerOperationParams(req.GetOrganizationRef(), req.GetDescription(), req.GetMetadata(), req.GetCharges(), req.GetEventTime()) params["rate"] = strings.TrimSpace(req.GetRate()) - params["to_money"] = map[string]interface{}{"amount": req.GetToMoney().GetAmount(), "currency": req.GetToMoney().GetCurrency()} + params[opParamToMoney] = map[string]interface{}{opParamAmount: req.GetToMoney().GetAmount(), opParamCurrency: req.GetToMoney().GetCurrency()} operation := &connectorv1.Operation{ Type: connectorv1.OperationType_FX, IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), @@ -466,7 +522,7 @@ func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opTy params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime) if contraRef != "" { - params["contra_ledger_account_ref"] = strings.TrimSpace(contraRef) + params[opParamContraLedgerAccountRef] = strings.TrimSpace(contraRef) } if len(extraParams) > 0 { for key, value := range extraParams { @@ -534,17 +590,17 @@ func accountRoleFromLedgerProto(role ledgerv1.AccountRole) account_role.AccountR func ledgerOperationParams(orgRef, description string, metadata map[string]string, charges []*ledgerv1.PostingLine, eventTime *timestamppb.Timestamp) map[string]interface{} { params := map[string]interface{}{ - "organization_ref": strings.TrimSpace(orgRef), - "description": strings.TrimSpace(description), + opParamOrganizationRef: strings.TrimSpace(orgRef), + opParamDescription: strings.TrimSpace(description), } if len(metadata) > 0 { - params["metadata"] = mapStringToInterface(metadata) + params[opParamMetadata] = mapStringToInterface(metadata) } if len(charges) > 0 { - params["charges"] = chargesToInterface(charges) + params[opParamCharges] = chargesToInterface(charges) } if eventTime != nil { - params["event_time"] = eventTime.AsTime().UTC().Format(time.RFC3339Nano) + params[opParamEventTime] = eventTime.AsTime().UTC().Format(time.RFC3339Nano) } return params } @@ -580,25 +636,25 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc details = account.GetProviderDetails().AsMap() } accountType := ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED - if v := strings.TrimSpace(fmt.Sprint(details["account_type"])); v != "" { + if v := strings.TrimSpace(fmt.Sprint(details[opParamAccountType])); v != "" { accountType = parseAccountType(v) } status := ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED - if v := strings.TrimSpace(fmt.Sprint(details["status"])); v != "" { + if v := strings.TrimSpace(fmt.Sprint(details[opParamStatus])); v != "" { status = parseAccountStatus(v) } allowNegative := false - if v, ok := details["allow_negative"].(bool); ok { + if v, ok := details[opParamAllowNegative].(bool); ok { allowNegative = v } role := ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED - if v := strings.TrimSpace(fmt.Sprint(details["role"])); v != "" { + if v := strings.TrimSpace(fmt.Sprint(details[opParamRole])); v != "" { if parsed, ok := ledgerconv.ParseAccountRole(v); ok { role = parsed } } if role == ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { - switch v := details["is_settlement"].(type) { + switch v := details[opParamIsSettlement].(type) { case bool: if v { role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT @@ -609,13 +665,13 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc } } } - accountCode := strings.TrimSpace(fmt.Sprint(details["account_code"])) + accountCode := strings.TrimSpace(fmt.Sprint(details[opParamAccountCode])) accountID := "" if ref := account.GetRef(); ref != nil { accountID = strings.TrimSpace(ref.GetAccountId()) } organizationRef := strings.TrimSpace(account.GetOwnerRef()) - if v := strings.TrimSpace(fmt.Sprint(details["organization_ref"])); v != "" { + if v := strings.TrimSpace(fmt.Sprint(details[opParamOrganizationRef])); v != "" { organizationRef = v } describable := account.GetDescribable() @@ -674,7 +730,7 @@ func operationDescription(op *connectorv1.Operation) string { if op == nil || op.GetParams() == nil { return "" } - if value, ok := op.GetParams().AsMap()["description"]; ok { + if value, ok := op.GetParams().AsMap()[opParamDescription]; ok { return strings.TrimSpace(fmt.Sprint(value)) } return "" @@ -731,10 +787,10 @@ func chargesToInterface(charges []*ledgerv1.PostingLine) []interface{} { continue } result = append(result, map[string]interface{}{ - "ledger_account_ref": strings.TrimSpace(line.GetLedgerAccountRef()), - "amount": strings.TrimSpace(line.GetMoney().GetAmount()), - "currency": strings.TrimSpace(line.GetMoney().GetCurrency()), - "line_type": line.GetLineType().String(), + opParamLedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), + opParamAmount: strings.TrimSpace(line.GetMoney().GetAmount()), + opParamCurrency: strings.TrimSpace(line.GetMoney().GetCurrency()), + opParamLineType: line.GetLineType().String(), }) } if len(result) == 0 { @@ -793,7 +849,7 @@ func (c *ledgerClient) callContext(ctx context.Context) (context.Context, contex } func isLedgerRail(value string) bool { - return strings.EqualFold(strings.TrimSpace(value), "LEDGER") + return strings.EqualFold(strings.TrimSpace(value), ledgerRailName) } func cloneMoney(input *moneyv1.Money) *moneyv1.Money { @@ -823,22 +879,22 @@ func ledgerTxMetadata(base map[string]string, tx rail.LedgerTx) map[string]strin meta = map[string]string{} } if val := strings.TrimSpace(tx.PaymentPlanID); val != "" { - meta["payment_plan_id"] = val + meta[txMetaPaymentPlanID] = val } if val := strings.TrimSpace(tx.FromRail); val != "" { - meta["from_rail"] = val + meta[txMetaFromRail] = val } if val := strings.TrimSpace(tx.ToRail); val != "" { - meta["to_rail"] = val + meta[txMetaToRail] = val } if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" { - meta["external_reference_id"] = val + meta[txMetaExternalReference] = val } if val := strings.TrimSpace(tx.FXRateUsed); val != "" { - meta["fx_rate_used"] = val + meta[txMetaFXRateUsed] = val } if val := strings.TrimSpace(tx.FeeAmount); val != "" { - meta["fee_amount"] = val + meta[txMetaFeeAmount] = val } if len(meta) == 0 { return nil diff --git a/api/ledger/client/client_test.go b/api/ledger/client/client_test.go index afeb6c3a..12f1d127 100644 --- a/api/ledger/client/client_test.go +++ b/api/ledger/client/client_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tech/sendico/pkg/discovery" + accountrolev1 "github.com/tech/sendico/pkg/proto/common/account_role/v1" 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" @@ -92,3 +94,65 @@ func TestTransferInternal_SubmitsTransferOperation(t *testing.T) { assert.Equal(t, "op-1", resp.GetJournalEntryRef()) assert.Equal(t, ledgerv1.EntryType_ENTRY_TRANSFER, resp.GetEntryType()) } + +func TestPostExternalCreditWithCharges_SubmitsExternalOperation(t *testing.T) { + ctx := context.Background() + + var captured *connectorv1.Operation + stub := &stubConnector{ + submitFn: func(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + captured = req.GetOperation() + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{OperationId: "op-ext-credit"}}, nil + }, + } + + client := NewWithClient(Config{}, stub) + resp, err := client.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ + IdempotencyKey: "id-ext-credit", + OrganizationRef: "org-1", + Money: &moneyv1.Money{Currency: "USDT", Amount: "1.0"}, + Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, captured) + + assert.Equal(t, connectorv1.OperationType_CREDIT, captured.GetType()) + assert.Equal(t, "", captured.GetTo().GetAccount().GetAccountId()) + assert.Equal(t, accountrolev1.AccountRole_OPERATING, captured.GetToRole()) + assert.Equal(t, discovery.OperationExternalCredit, captured.GetParams().AsMap()["operation"]) + assert.Equal(t, "op-ext-credit", resp.GetJournalEntryRef()) + assert.Equal(t, ledgerv1.EntryType_ENTRY_CREDIT, resp.GetEntryType()) +} + +func TestPostExternalDebitWithCharges_SubmitsExternalOperation(t *testing.T) { + ctx := context.Background() + + var captured *connectorv1.Operation + stub := &stubConnector{ + submitFn: func(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + captured = req.GetOperation() + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{OperationId: "op-ext-debit"}}, nil + }, + } + + client := NewWithClient(Config{}, stub) + resp, err := client.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ + IdempotencyKey: "id-ext-debit", + OrganizationRef: "org-1", + Money: &moneyv1.Money{Currency: "RUB", Amount: "77.14"}, + Role: ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, captured) + + assert.Equal(t, connectorv1.OperationType_DEBIT, captured.GetType()) + assert.Equal(t, "", captured.GetFrom().GetAccount().GetAccountId()) + assert.Equal(t, accountrolev1.AccountRole_HOLD, captured.GetFromRole()) + assert.Equal(t, discovery.OperationExternalDebit, captured.GetParams().AsMap()["operation"]) + assert.Equal(t, "op-ext-debit", resp.GetJournalEntryRef()) + assert.Equal(t, ledgerv1.EntryType_ENTRY_DEBIT, resp.GetEntryType()) +} diff --git a/api/ledger/client/fake.go b/api/ledger/client/fake.go index 62d1676b..04451875 100644 --- a/api/ledger/client/fake.go +++ b/api/ledger/client/fake.go @@ -4,29 +4,31 @@ import ( "context" "github.com/tech/sendico/pkg/payments/rail" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" 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" ) // Fake implements Client for tests. type Fake struct { - ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error) - CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error) - HoldBalanceFn func(ctx context.Context, accountID string, amount string) error - CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) - ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) - ListConnectorAccountsFn func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) - PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) - PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) - TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) - ApplyFXWithChargesFn func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) - BlockAccountFn func(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) - UnblockAccountFn func(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) - GetBalanceFn func(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) - GetJournalEntryFn func(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) - GetStatementFn func(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) - CloseFn func() error + ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error) + CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error) + HoldBalanceFn func(ctx context.Context, accountID string, amount string) error + CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) + ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) + ListConnectorAccountsFn func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) + PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) + PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) + PostExternalCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) + PostExternalDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) + TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) + ApplyFXWithChargesFn func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) + BlockAccountFn func(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) + UnblockAccountFn func(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) + GetBalanceFn func(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) + GetJournalEntryFn func(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) + GetStatementFn func(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) + CloseFn func() error } func (f *Fake) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) { @@ -85,6 +87,20 @@ func (f *Fake) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebit return &ledgerv1.PostResponse{}, nil } +func (f *Fake) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + if f.PostExternalCreditWithChargesFn != nil { + return f.PostExternalCreditWithChargesFn(ctx, req) + } + return &ledgerv1.PostResponse{}, nil +} + +func (f *Fake) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + if f.PostExternalDebitWithChargesFn != nil { + return f.PostExternalDebitWithChargesFn(ctx, req) + } + return &ledgerv1.PostResponse{}, nil +} + func (f *Fake) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { if f.TransferInternalFn != nil { return f.TransferInternalFn(ctx, req) diff --git a/api/ledger/internal/service/ledger/connector.go b/api/ledger/internal/service/ledger/connector.go index 934feecd..68e0b12a 100644 --- a/api/ledger/internal/service/ledger/connector.go +++ b/api/ledger/internal/service/ledger/connector.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/ledger/internal/appversion" "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" accountrolev1 "github.com/tech/sendico/pkg/proto/common/account_role/v1" @@ -16,6 +17,7 @@ import ( 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" + "go.uber.org/zap" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -222,7 +224,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil } - operation := strings.ToLower(strings.TrimSpace(reader.String("operation"))) + operation := discovery.NormalizeOperation(reader.String("operation")) switch op.GetType() { case connectorv1.OperationType_CREDIT: @@ -230,11 +232,11 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if accountID == "" && op.GetToRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: to.account or to_role is required", op, "")}}, nil } - if operation != "" && operation != "external.credit" { + if operation != "" && operation != discovery.OperationExternalCredit { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: unsupported operation override", op, "")}}, nil } creditFn := c.svc.PostCreditWithCharges - if operation == "external.credit" { + if operation == discovery.OperationExternalCredit { creditFn = c.svc.PostExternalCreditWithCharges } resp, err := creditFn(ctx, &ledgerv1.PostCreditRequest{ @@ -250,6 +252,10 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 Role: accountRoleFromConnectorRole(op.GetToRole()), }) if err != nil { + c.svc.logger.Warn("Operation failed", zap.Error(err), zap.String("operation", operation), + zap.String("idempotency_key", op.IdempotencyKey), zap.String("description", description), + zap.String("organization_ref", orgRef), zap.String("ledger_account_ref", accountID), + ) return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil } return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_OPERATION_SUCCESS)}, nil @@ -258,11 +264,11 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if accountID == "" && op.GetFromRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: from.account or from_role is required", op, "")}}, nil } - if operation != "" && operation != "external.debit" { + if operation != "" && operation != discovery.OperationExternalDebit { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: unsupported operation override", op, "")}}, nil } debitFn := c.svc.PostDebitWithCharges - if operation == "external.debit" { + if operation == discovery.OperationExternalDebit { debitFn = c.svc.PostExternalDebitWithCharges } resp, err := debitFn(ctx, &ledgerv1.PostDebitRequest{ @@ -393,14 +399,14 @@ func ledgerOperationParams() []*connectorv1.OperationParamSpec { Type: connectorv1.ParamType_STRING, Required: false, Description: "Optional ledger operation override (external.credit).", - AllowedValues: []string{"external.credit"}, + AllowedValues: []string{discovery.OperationExternalCredit}, } externalDebit := &connectorv1.ParamSpec{ Key: "operation", Type: connectorv1.ParamType_STRING, Required: false, Description: "Optional ledger operation override (external.debit).", - AllowedValues: []string{"external.debit"}, + AllowedValues: []string{discovery.OperationExternalDebit}, } return []*connectorv1.OperationParamSpec{ {OperationType: connectorv1.OperationType_CREDIT, Params: append(common, externalCredit, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})}, diff --git a/api/ledger/internal/service/ledger/metrics.go b/api/ledger/internal/service/ledger/metrics.go index ad51bab4..8ffca07d 100644 --- a/api/ledger/internal/service/ledger/metrics.go +++ b/api/ledger/internal/service/ledger/metrics.go @@ -7,6 +7,40 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" ) +type journalEntryType string + +const ( + journalEntryTypeCredit journalEntryType = "credit" + journalEntryTypeDebit journalEntryType = "debit" + journalEntryTypeTransfer journalEntryType = "transfer" + journalEntryTypeFX journalEntryType = "fx" +) + +type journalEntryStatus string + +const ( + journalEntryStatusAttempted journalEntryStatus = "attempted" + journalEntryStatusSuccess journalEntryStatus = "success" + journalEntryStatusError journalEntryStatus = "error" +) + +type journalEntryErrorType string + +const ( + journalEntryErrorNotImplemented journalEntryErrorType = "not_implemented" + journalEntryErrorFailed journalEntryErrorType = "failed" + journalEntryErrorIdempotencyCheck journalEntryErrorType = "idempotency_check_failed" + journalEntryErrorAccountResolve journalEntryErrorType = "account_resolve_failed" + journalEntryErrorAccountInvalid journalEntryErrorType = "account_invalid" + journalEntryErrorContraResolve journalEntryErrorType = "contra_resolve_failed" + journalEntryErrorContraMissingID journalEntryErrorType = "contra_missing_id" + journalEntryErrorSystemAccountResolve journalEntryErrorType = "system_account_resolve_failed" + journalEntryErrorSystemAccountInvalid journalEntryErrorType = "system_account_invalid" + journalEntryErrorSystemAccountMissing journalEntryErrorType = "system_account_missing_id" + journalEntryErrorUnbalancedAfterContra journalEntryErrorType = "unbalanced_after_contra" + journalEntryErrorTransactionFailed journalEntryErrorType = "transaction_failed" +) + var ( metricsOnce sync.Once @@ -110,16 +144,16 @@ func initMetrics() { // Metric recording helpers -func recordJournalEntry(entryType, status string, durationSeconds float64) { +func recordJournalEntry(entryType journalEntryType, status journalEntryStatus, durationSeconds float64) { initMetrics() - journalEntriesTotal.WithLabelValues(entryType, status).Inc() - journalEntryLatency.WithLabelValues(entryType).Observe(durationSeconds) + journalEntriesTotal.WithLabelValues(string(entryType), string(status)).Inc() + journalEntryLatency.WithLabelValues(string(entryType)).Observe(durationSeconds) } -func recordJournalEntryError(entryType, errorType string) { +func recordJournalEntryError(entryType journalEntryType, errorType journalEntryErrorType) { initMetrics() - journalEntryErrors.WithLabelValues(entryType, errorType).Inc() - journalEntriesTotal.WithLabelValues(entryType, "error").Inc() + journalEntryErrors.WithLabelValues(string(entryType), string(errorType)).Inc() + journalEntriesTotal.WithLabelValues(string(entryType), string(journalEntryStatusError)).Inc() } func recordBalanceQuery(status string, durationSeconds float64) { @@ -128,9 +162,9 @@ func recordBalanceQuery(status string, durationSeconds float64) { balanceQueryLatency.WithLabelValues(status).Observe(durationSeconds) } -func recordTransactionAmount(currency, entryType string, amount float64) { +func recordTransactionAmount(currency string, entryType journalEntryType, amount float64) { initMetrics() - transactionAmounts.WithLabelValues(currency, entryType).Observe(amount) + transactionAmounts.WithLabelValues(currency, string(entryType)).Observe(amount) } func recordAccountOperation(operation, status string) { @@ -138,7 +172,7 @@ func recordAccountOperation(operation, status string) { accountOperationsTotal.WithLabelValues(operation, status).Inc() } -func recordDuplicateRequest(entryType string) { +func recordDuplicateRequest(entryType journalEntryType) { initMetrics() - duplicateRequestsTotal.WithLabelValues(entryType).Inc() + duplicateRequestsTotal.WithLabelValues(string(entryType)).Inc() } diff --git a/api/ledger/internal/service/ledger/posting.go b/api/ledger/internal/service/ledger/posting.go index f4969aad..09180631 100644 --- a/api/ledger/internal/service/ledger/posting.go +++ b/api/ledger/internal/service/ledger/posting.go @@ -65,7 +65,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("credit") + recordDuplicateRequest(journalEntryTypeCredit) logger.Info("Duplicate credit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -75,18 +75,18 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - recordJournalEntryError("credit", "idempotency_check_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorIdempotencyCheck) logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") if err != nil { - recordJournalEntryError("credit", "account_resolve_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountResolve) return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { - recordJournalEntryError("credit", "account_invalid") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountInvalid) return nil, err } @@ -159,12 +159,12 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef) if err != nil { - recordJournalEntryError("credit", "contra_resolve_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorContraResolve) return nil, err } contraAccountID := contraAccount.GetID() if contraAccountID == nil { - recordJournalEntryError("credit", "contra_missing_id") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorContraMissingID) return nil, merrors.Internal("contra account missing identifier") } @@ -183,7 +183,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi } if !entryTotal.IsZero() { - recordJournalEntryError("credit", "unbalanced_after_contra") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorUnbalancedAfterContra) return nil, merrors.Internal("failed to balance journal entry") } @@ -237,13 +237,13 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi }) if err != nil { - recordJournalEntryError("credit", "transaction_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorTransactionFailed) return nil, err } amountFloat, _ := creditAmount.Float64() - recordTransactionAmount(req.Money.Currency, "credit", amountFloat) - recordJournalEntry("credit", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeCredit, amountFloat) + recordJournalEntry(journalEntryTypeCredit, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/posting_debit.go b/api/ledger/internal/service/ledger/posting_debit.go index ccf0bdc4..41e69d31 100644 --- a/api/ledger/internal/service/ledger/posting_debit.go +++ b/api/ledger/internal/service/ledger/posting_debit.go @@ -63,7 +63,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("debit") + recordDuplicateRequest(journalEntryTypeDebit) logger.Info("Duplicate debit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -79,11 +79,11 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") if err != nil { - recordJournalEntryError("debit", "account_resolve_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountResolve) return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { - recordJournalEntryError("debit", "account_invalid") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountInvalid) return nil, err } @@ -156,12 +156,12 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef) if err != nil { - recordJournalEntryError("debit", "contra_resolve_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorContraResolve) return nil, err } contraAccountID := contraAccount.GetID() if contraAccountID == nil { - recordJournalEntryError("debit", "contra_missing_id") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorContraMissingID) return nil, merrors.Internal("contra account missing identifier") } @@ -180,7 +180,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR } if !entryTotal.IsZero() { - recordJournalEntryError("debit", "unbalanced_after_contra") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorUnbalancedAfterContra) return nil, merrors.Internal("failed to balance journal entry") } @@ -234,13 +234,13 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR }) if err != nil { - recordJournalEntryError("debit", "transaction_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorTransactionFailed) return nil, err } amountFloat, _ := debitAmount.Float64() - recordTransactionAmount(req.Money.Currency, "debit", amountFloat) - recordJournalEntry("debit", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeDebit, amountFloat) + recordJournalEntry(journalEntryTypeDebit, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/posting_external.go b/api/ledger/internal/service/ledger/posting_external.go index 362eb8b5..ee1beedb 100644 --- a/api/ledger/internal/service/ledger/posting_external.go +++ b/api/ledger/internal/service/ledger/posting_external.go @@ -60,7 +60,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("credit") + recordDuplicateRequest(journalEntryTypeCredit) logger.Info("Duplicate external credit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -70,34 +70,34 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - recordJournalEntryError("credit", "idempotency_check_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorIdempotencyCheck) logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") if err != nil { - recordJournalEntryError("credit", "account_resolve_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountResolve) return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { - recordJournalEntryError("credit", "account_invalid") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountInvalid) return nil, err } systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency) if err != nil { - recordJournalEntryError("credit", "system_account_resolve_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountResolve) return nil, err } if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency); err != nil { - recordJournalEntryError("credit", "system_account_invalid") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountInvalid) return nil, err } systemAccountID := systemAccount.GetID() if systemAccountID == nil { - recordJournalEntryError("credit", "system_account_missing_id") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountMissing) return nil, merrors.Internal("system account missing identifier") } @@ -186,7 +186,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P } if !entryTotal.IsZero() { - recordJournalEntryError("credit", "unbalanced_after_contra") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorUnbalancedAfterContra) return nil, merrors.Internal("failed to balance journal entry") } @@ -240,13 +240,13 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P }) if err != nil { - recordJournalEntryError("credit", "transaction_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorTransactionFailed) return nil, err } amountFloat, _ := creditAmount.Float64() - recordTransactionAmount(req.Money.Currency, "credit", amountFloat) - recordJournalEntry("credit", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeCredit, amountFloat) + recordJournalEntry(journalEntryTypeCredit, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } @@ -293,7 +293,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("debit") + recordDuplicateRequest(journalEntryTypeDebit) logger.Info("Duplicate external debit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -303,34 +303,34 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - recordJournalEntryError("debit", "idempotency_check_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorIdempotencyCheck) logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") if err != nil { - recordJournalEntryError("debit", "account_resolve_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountResolve) return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { - recordJournalEntryError("debit", "account_invalid") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountInvalid) return nil, err } systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency) if err != nil { - recordJournalEntryError("debit", "system_account_resolve_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountResolve) return nil, err } if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency); err != nil { - recordJournalEntryError("debit", "system_account_invalid") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountInvalid) return nil, err } systemAccountID := systemAccount.GetID() if systemAccountID == nil { - recordJournalEntryError("debit", "system_account_missing_id") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountMissing) return nil, merrors.Internal("system account missing identifier") } @@ -419,7 +419,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po } if !entryTotal.IsZero() { - recordJournalEntryError("debit", "unbalanced_after_contra") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorUnbalancedAfterContra) return nil, merrors.Internal("failed to balance journal entry") } @@ -473,13 +473,13 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po }) if err != nil { - recordJournalEntryError("debit", "transaction_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorTransactionFailed) return nil, err } amountFloat, _ := debitAmount.Float64() - recordTransactionAmount(req.Money.Currency, "debit", amountFloat) - recordJournalEntry("debit", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeDebit, amountFloat) + recordJournalEntry(journalEntryTypeDebit, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/posting_fx.go b/api/ledger/internal/service/ledger/posting_fx.go index 10a0a227..515fa7d9 100644 --- a/api/ledger/internal/service/ledger/posting_fx.go +++ b/api/ledger/internal/service/ledger/posting_fx.go @@ -76,7 +76,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp // Check for duplicate idempotency key existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("fx") + recordDuplicateRequest(journalEntryTypeFX) logger.Info("Duplicate FX request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -244,15 +244,15 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp }) if err != nil { - recordJournalEntryError("fx", "transaction_failed") + recordJournalEntryError(journalEntryTypeFX, journalEntryErrorTransactionFailed) return nil, err } fromAmountFloat, _ := fromAmount.Float64() toAmountFloat, _ := toAmount.Float64() - recordTransactionAmount(req.FromMoney.Currency, "fx", fromAmountFloat) - recordTransactionAmount(req.ToMoney.Currency, "fx", toAmountFloat) - recordJournalEntry("fx", "success", 0) + recordTransactionAmount(req.FromMoney.Currency, journalEntryTypeFX, fromAmountFloat) + recordTransactionAmount(req.ToMoney.Currency, journalEntryTypeFX, toAmountFloat) + recordJournalEntry(journalEntryTypeFX, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/posting_transfer.go b/api/ledger/internal/service/ledger/posting_transfer.go index 213b9b4e..4cd8d9c1 100644 --- a/api/ledger/internal/service/ledger/posting_transfer.go +++ b/api/ledger/internal/service/ledger/posting_transfer.go @@ -86,7 +86,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq // Check for duplicate idempotency key existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("transfer") + recordDuplicateRequest(journalEntryTypeTransfer) logger.Info("Duplicate transfer request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -246,13 +246,13 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq }) if err != nil { - recordJournalEntryError("transfer", "failed") + recordJournalEntryError(journalEntryTypeTransfer, journalEntryErrorFailed) return nil, err } amountFloat, _ := transferAmount.Float64() - recordTransactionAmount(req.Money.Currency, "transfer", amountFloat) - recordJournalEntry("transfer", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeTransfer, amountFloat) + recordJournalEntry(journalEntryTypeTransfer, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index a46eca22..c30df397 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -77,7 +77,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging. initMetrics() service := &Service{ - logger: logger.Named("ledger"), + logger: logger.Named("service"), storage: repo, producer: prod, msgCfg: msgCfg, @@ -117,17 +117,10 @@ func (s *Service) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccount func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("credit", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeCredit, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.postCreditResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("credit", "not_implemented") - } - - logger := s.logger.With(zap.String("operation", "credit")) + logger := s.logger.With(zap.String("operation", discovery.OperationLedgerCredit)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -147,7 +140,16 @@ func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostC logger = logger.With(zap.String("contra_ledger_account_ref", contra)) } } - s.logLedgerOperation("credit", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationLedgerCredit, logger) + + responder := s.postCreditResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorNotImplemented) + } + + s.logLedgerOperation(discovery.OperationLedgerCredit, logger, resp, err, time.Since(start)) return resp, err } @@ -156,17 +158,10 @@ func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostC func (s *Service) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("credit", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeCredit, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.postExternalCreditResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("credit", "failed") - } - - logger := s.logger.With(zap.String("operation", "external_credit")) + logger := s.logger.With(zap.String("operation", discovery.OperationExternalCredit)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -183,7 +178,16 @@ func (s *Service) PostExternalCreditWithCharges(ctx context.Context, req *ledger logger = logger.With(zap.String("role", role.String())) } } - s.logLedgerOperation("external_credit", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationExternalCredit, logger) + + responder := s.postExternalCreditResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationExternalCredit, logger, resp, err, time.Since(start)) return resp, err } @@ -192,17 +196,10 @@ func (s *Service) PostExternalCreditWithCharges(ctx context.Context, req *ledger func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("debit", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeDebit, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.postDebitResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("debit", "failed") - } - - logger := s.logger.With(zap.String("operation", "debit")) + logger := s.logger.With(zap.String("operation", discovery.OperationLedgerDebit)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -222,7 +219,16 @@ func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDe logger = logger.With(zap.String("contra_ledger_account_ref", contra)) } } - s.logLedgerOperation("debit", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationLedgerDebit, logger) + + responder := s.postDebitResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationLedgerDebit, logger, resp, err, time.Since(start)) return resp, err } @@ -231,17 +237,10 @@ func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDe func (s *Service) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("debit", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeDebit, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.postExternalDebitResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("debit", "failed") - } - - logger := s.logger.With(zap.String("operation", "external_debit")) + logger := s.logger.With(zap.String("operation", discovery.OperationExternalDebit)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -258,7 +257,16 @@ func (s *Service) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv logger = logger.With(zap.String("role", role.String())) } } - s.logLedgerOperation("external_debit", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationExternalDebit, logger) + + responder := s.postExternalDebitResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationExternalDebit, logger, resp, err, time.Since(start)) return resp, err } @@ -267,17 +275,10 @@ func (s *Service) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("transfer", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeTransfer, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.transferResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("transfer", "failed") - } - - logger := s.logger.With(zap.String("operation", "transfer")) + logger := s.logger.With(zap.String("operation", discovery.OperationLedgerTransfer)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -298,7 +299,16 @@ func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRe logger = logger.With(zap.String("to_role", role.String())) } } - s.logLedgerOperation("transfer", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationLedgerTransfer, logger) + + responder := s.transferResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeTransfer, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationLedgerTransfer, logger, resp, err, time.Since(start)) return resp, err } @@ -307,17 +317,10 @@ func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRe func (s *Service) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("fx", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeFX, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.fxResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("fx", "failed") - } - - logger := s.logger.With(zap.String("operation", "fx")) + logger := s.logger.With(zap.String("operation", discovery.OperationLedgerFX)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -341,7 +344,16 @@ func (s *Service) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXReques logger = logger.With(zap.String("rate", rate)) } } - s.logLedgerOperation("fx", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationLedgerFX, logger) + + responder := s.fxResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeFX, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationLedgerFX, logger, resp, err, time.Since(start)) return resp, err } @@ -365,23 +377,42 @@ func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryReq return responder(ctx) } -func (s *Service) logLedgerOperation(op string, logger mlogger.Logger, resp *ledgerv1.PostResponse, err error) { +func (s *Service) logLedgerOperationStart(op string, logger mlogger.Logger) { if logger == nil { return } - if err != nil { - logger.Warn(fmt.Sprintf("ledger %s failed", op), zap.Error(err)) + logger.Debug("Ledger operation execution started", zap.String("operation_name", op)) +} + +func (s *Service) logLedgerOperation(op string, logger mlogger.Logger, resp *ledgerv1.PostResponse, err error, duration time.Duration) { + if logger == nil { return } entryRef := "" if resp != nil { entryRef = strings.TrimSpace(resp.GetJournalEntryRef()) } - if entryRef == "" { - logger.Info(fmt.Sprintf("ledger %s posted", op)) + status := "succeeded" + fields := []zap.Field{ + zap.String("operation_name", op), + zap.String("status", status), + zap.Int64("duration_ms", duration.Milliseconds()), + } + if entryRef != "" { + fields = append(fields, zap.String("journal_entry_ref", entryRef)) + } + if err != nil { + fields[1] = zap.String("status", "failed") + logger.Debug("Ledger operation execution completed", append(fields, zap.Error(err))...) + logger.Warn("Ledger operation failed", zap.String("operation_name", op), zap.Error(err)) return } - logger.Info(fmt.Sprintf("ledger %s posted", op), zap.String("journal_entry_ref", entryRef)) + logger.Debug("Ledger operation execution completed", fields...) + if entryRef == "" { + logger.Info("Ledger operation posted", zap.String("operation_name", op)) + return + } + logger.Info("Ledger operation posted", zap.String("operation_name", op), zap.String("journal_entry_ref", entryRef)) } func (s *Service) Shutdown() { @@ -402,7 +433,7 @@ func (s *Service) startDiscoveryAnnouncer() { } announce := discovery.Announcement{ Service: "LEDGER", - Operations: []string{"balance.read", "ledger.debit", "ledger.credit", "external.credit", "external.debit"}, + Operations: discovery.LedgerServiceOperations(), InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } @@ -428,8 +459,7 @@ func (s *Service) startOutboxReliableProducer() error { } s.outbox.producer = reliableProducer if s.outbox.producer == nil || s.producer == nil { - s.logger.Info("Outbox reliable publisher disabled", - zap.Bool("enabled", settings.Enabled)) + s.logger.Info("Outbox reliable publisher disabled", zap.Bool("enabled", settings.Enabled)) return } s.logger.Info("Outbox reliable publisher configured", diff --git a/api/notification/internal/server/notificationimp/notification.go b/api/notification/internal/server/notificationimp/notification.go index 8439d9b9..7a3664bd 100644 --- a/api/notification/internal/server/notificationimp/notification.go +++ b/api/notification/internal/server/notificationimp/notification.go @@ -120,7 +120,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { announce := discovery.Announcement{ Service: "NOTIFICATIONS", - Operations: []string{"notify.send"}, + Operations: []string{discovery.OperationNotifySend}, Version: appversion.Create().Short(), } p.announcer = discovery.NewAnnouncer(p.logger, a.Register().Producer(), string(mservice.Notifications), announce) diff --git a/api/payments/methods/internal/server/internal/discovery.go b/api/payments/methods/internal/server/internal/discovery.go index 7a0cb157..42931e2e 100644 --- a/api/payments/methods/internal/server/internal/discovery.go +++ b/api/payments/methods/internal/server/internal/discovery.go @@ -53,8 +53,8 @@ func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) { announce := discovery.Announcement{ Service: "PAYMENTS_METHODS", Operations: []string{ - "payment_methods.manage", - "payment_methods.read", + discovery.OperationPaymentMethodsManage, + discovery.OperationPaymentMethodsRead, }, InvokeURI: invokeURI, Version: appversion.Create().Short(), diff --git a/api/payments/orchestrator/internal/server/internal/dependencies.go b/api/payments/orchestrator/internal/server/internal/dependencies.go index ba0b905c..751050af 100644 --- a/api/payments/orchestrator/internal/server/internal/dependencies.go +++ b/api/payments/orchestrator/internal/server/internal/dependencies.go @@ -1,18 +1,14 @@ package serverimp import ( - oracleclient "github.com/tech/sendico/fx/oracle/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" ) type orchestratorDeps struct { - feesClient feesv1.FeeEngineClient ledgerClient ledgerclient.Client mntxClient mntxclient.Client - oracleClient oracleclient.Client gatewayInvokeResolver orchestrator.GatewayInvokeResolver } @@ -26,9 +22,7 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps { } i.discoveryClients = newDiscoveryClientResolver(i.logger, i.discoveryReg) - deps.feesClient = &discoveryFeeClient{resolver: i.discoveryClients} deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients} - deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients} deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients} deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients} return deps @@ -39,9 +33,6 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest return nil } opts := []orchestrator.Option{} - if deps.feesClient != nil { - opts = append(opts, orchestrator.WithFeeEngine(deps.feesClient, cfg.Fees.callTimeout())) - } if deps.ledgerClient != nil { opts = append(opts, orchestrator.WithLedgerClient(deps.ledgerClient)) } @@ -49,16 +40,12 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient)) } - opts = append(opts, orchestrator.WithMaxFXQuoteTTLMillis(cfg.maxFXQuoteTTLMillis())) if deps.gatewayInvokeResolver != nil { opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver)) } if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 { opts = append(opts, orchestrator.WithCardGatewayRoutes(routes)) } - if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 { - opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts)) - } if registry := buildGatewayRegistry(i.logger, cfg.GatewayInstances, i.discoveryReg); registry != nil { opts = append(opts, orchestrator.WithGatewayRegistry(registry)) } diff --git a/api/payments/orchestrator/internal/server/internal/discovery.go b/api/payments/orchestrator/internal/server/internal/discovery.go index cf1ddec7..45d84462 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.initiate"}, + Operations: []string{discovery.OperationPaymentInitiate}, InvokeURI: cfg.GRPC.DiscoveryInvokeURI(), Version: appversion.Create().Short(), } diff --git a/api/payments/orchestrator/internal/server/internal/discovery_clients.go b/api/payments/orchestrator/internal/server/internal/discovery_clients.go index 317ac9dd..3997445f 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_clients.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_clients.go @@ -32,6 +32,11 @@ var ( ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} mntxServiceNames = []string{"CARD_RAIL_GATEWAY", string(mservice.MntxGateway)} + + feesRequiredOps = []string{discovery.OperationFeeCalc} + ledgerRequiredOps = discovery.LedgerServiceOperations() + oracleRequiredOps = []string{discovery.OperationFXQuote} + mntxRequiredOps = discovery.CardPayoutRailGatewayOperations() ) type discoveryEndpoint struct { @@ -109,27 +114,27 @@ func (r *discoveryClientResolver) Close() { } func (r *discoveryClientResolver) FeesAvailable() bool { - _, ok := r.findEntry("fees", feesServiceNames, "", "") + _, ok := r.findEntry("fees", feesServiceNames, "", "", feesRequiredOps) return ok } func (r *discoveryClientResolver) LedgerAvailable() bool { - _, ok := r.findEntry("ledger", ledgerServiceNames, "", "") + _, ok := r.findEntry("ledger", ledgerServiceNames, "", "", ledgerRequiredOps) return ok } func (r *discoveryClientResolver) OracleAvailable() bool { - _, ok := r.findEntry("oracle", oracleServiceNames, "", "") + _, ok := r.findEntry("oracle", oracleServiceNames, "", "", oracleRequiredOps) return ok } func (r *discoveryClientResolver) MntxAvailable() bool { - _, ok := r.findEntry("mntx", mntxServiceNames, "", "") + _, ok := r.findEntry("mntx", mntxServiceNames, "", "", mntxRequiredOps) return ok } func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) { - entry, ok := r.findEntry("fees", feesServiceNames, "", "") + entry, ok := r.findEntry("fees", feesServiceNames, "", "", feesRequiredOps) if !ok { return nil, merrors.NoData("discovery: fees service unavailable") } @@ -160,7 +165,7 @@ func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEng } func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) { - entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "") + entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "", ledgerRequiredOps) if !ok { return nil, merrors.NoData("discovery: ledger service unavailable") } @@ -194,7 +199,7 @@ func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclien } func (r *discoveryClientResolver) OracleClient(ctx context.Context) (oracleclient.Client, error) { - entry, ok := r.findEntry("oracle", oracleServiceNames, "", "") + entry, ok := r.findEntry("oracle", oracleServiceNames, "", "", oracleRequiredOps) if !ok { return nil, merrors.NoData("discovery: oracle service unavailable") } @@ -228,7 +233,7 @@ func (r *discoveryClientResolver) OracleClient(ctx context.Context) (oracleclien } func (r *discoveryClientResolver) MntxClient(ctx context.Context) (mntxclient.Client, error) { - entry, ok := r.findEntry("mntx", mntxServiceNames, "", "") + entry, ok := r.findEntry("mntx", mntxServiceNames, "", "", mntxRequiredOps) if !ok { return nil, merrors.NoData("discovery: mntx service unavailable") } @@ -316,14 +321,19 @@ func (r *discoveryClientResolver) PaymentGatewayClient(ctx context.Context, invo return client, nil } -func (r *discoveryClientResolver) findEntry(key string, services []string, rail string, network string) (*discovery.RegistryEntry, bool) { +func (r *discoveryClientResolver) findEntry(key string, services []string, rail string, network string, requiredOps []string) (*discovery.RegistryEntry, bool) { if r == nil || r.registry == nil { r.logMissing(key, "discovery registry unavailable", "", nil) return nil, false } + type discoveryMatch struct { + entry discovery.RegistryEntry + opMatch bool + } + entries := r.registry.List(time.Now(), true) - matches := make([]discovery.RegistryEntry, 0) + matches := make([]discoveryMatch, 0) for _, entry := range entries { if !matchesService(entry.Service, services) { continue @@ -334,7 +344,10 @@ func (r *discoveryClientResolver) findEntry(key string, services []string, rail if network != "" && !strings.EqualFold(strings.TrimSpace(entry.Network), network) { continue } - matches = append(matches, entry) + matches = append(matches, discoveryMatch{ + entry: entry, + opMatch: discovery.HasAnyOperation(entry.Operations, requiredOps), + }) } if len(matches) == 0 { @@ -343,16 +356,19 @@ func (r *discoveryClientResolver) findEntry(key string, services []string, rail } sort.Slice(matches, func(i, j int) bool { - if matches[i].RoutingPriority != matches[j].RoutingPriority { - return matches[i].RoutingPriority > matches[j].RoutingPriority + if matches[i].opMatch != matches[j].opMatch { + return matches[i].opMatch } - if matches[i].ID != matches[j].ID { - return matches[i].ID < matches[j].ID + if matches[i].entry.RoutingPriority != matches[j].entry.RoutingPriority { + return matches[i].entry.RoutingPriority > matches[j].entry.RoutingPriority } - return matches[i].InstanceID < matches[j].InstanceID + if matches[i].entry.ID != matches[j].entry.ID { + return matches[i].entry.ID < matches[j].entry.ID + } + return matches[i].entry.InstanceID < matches[j].entry.InstanceID }) - entry := matches[0] + entry := matches[0].entry entryKey := discoveryEntryKey(entry) r.logSelection(key, entryKey, entry) return &entry, true diff --git a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go index 625c1b85..d7816328 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go @@ -134,6 +134,22 @@ func (c *discoveryLedgerClient) PostDebitWithCharges(ctx context.Context, req *l return client.PostDebitWithCharges(ctx, req) } +func (c *discoveryLedgerClient) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + client, err := c.resolver.LedgerClient(ctx) + if err != nil { + return nil, err + } + return client.PostExternalCreditWithCharges(ctx, req) +} + +func (c *discoveryLedgerClient) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + client, err := c.resolver.LedgerClient(ctx) + if err != nil { + return nil, err + } + return client.PostExternalDebitWithCharges(ctx, req) +} + func (c *discoveryLedgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { client, err := c.resolver.LedgerClient(ctx) if err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go index 6cb6ff77..f511c74e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go @@ -62,17 +62,42 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste return nil, err } - transferReq := &ledgerv1.TransferRequest{ - IdempotencyKey: ledgerStepIdempotencyKey(req.Payment, req.Step), - OrganizationRef: req.Payment.OrganizationRef.Hex(), - Money: amount, - Description: ledgerDescription(req.Step), - Metadata: ledgerTransferMetadata(req.Payment, req.Step, roles), - FromRole: ledgerRoleToProto(roles.from), - ToRole: ledgerRoleToProto(roles.to), - } + idempotencyKey := ledgerStepIdempotencyKey(req.Payment, req.Step) + organizationRef := req.Payment.OrganizationRef.Hex() + description := ledgerDescription(req.Step) + metadata := ledgerTransferMetadata(req.Payment, req.Step, roles) - resp, err := e.ledgerClient.TransferInternal(ctx, transferReq) + var resp *ledgerv1.PostResponse + switch action { + case model.RailOperationExternalCredit: + resp, err = e.ledgerClient.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: organizationRef, + Money: amount, + Description: description, + Metadata: metadata, + Role: ledgerRoleToProto(roles.to), + }) + case model.RailOperationExternalDebit: + resp, err = e.ledgerClient.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: organizationRef, + Money: amount, + Description: description, + Metadata: metadata, + Role: ledgerRoleToProto(roles.from), + }) + default: + resp, err = e.ledgerClient.TransferInternal(ctx, &ledgerv1.TransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: organizationRef, + Money: amount, + Description: description, + Metadata: metadata, + FromRole: ledgerRoleToProto(roles.from), + ToRole: ledgerRoleToProto(roles.to), + }) + } if err != nil { return nil, err } diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go index 6eed5339..87d12a35 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go @@ -80,6 +80,146 @@ func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRol } } +func TestGatewayLedgerExecutor_ExecuteLedger_ExternalCreditUsesPostCreditWithCharges(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var ( + postReq *ledgerv1.PostCreditRequest + transferCalled bool + ) + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + PostExternalCreditWithChargesFn: func(_ context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + postReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-ext-credit"}, nil + }, + TransferInternalFn: func(_ context.Context, _ *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferCalled = true + return &ledgerv1.PostResponse{JournalEntryRef: "entry-transfer"}, nil + }, + }, + } + + out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_1_2_ledger_credit", + StepCode: "edge.1_2.ledger.credit", + Action: model.RailOperationExternalCredit, + Rail: model.RailLedger, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_1_2_ledger_credit", + StepCode: "edge.1_2.ledger.credit", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if postReq == nil { + t.Fatal("expected external credit request") + } + if transferCalled { + t.Fatal("expected external credit to skip transfer") + } + if got, want := postReq.GetMoney().GetAmount(), "1.000000"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := postReq.GetMoney().GetCurrency(), "USDT"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } + if got, want := postReq.GetRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING; got != want { + t.Fatalf("role mismatch: got=%v want=%v", got, want) + } + if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 1 { + t.Fatalf("expected one external ref, got=%d", len(out.StepExecution.ExternalRefs)) + } + if got, want := out.StepExecution.ExternalRefs[0].Ref, "entry-ext-credit"; got != want { + t.Fatalf("external ref value mismatch: got=%q want=%q", got, want) + } +} + +func TestGatewayLedgerExecutor_ExecuteLedger_ExternalDebitUsesPostDebitWithCharges(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var ( + postReq *ledgerv1.PostDebitRequest + transferCalled bool + ) + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + PostExternalDebitWithChargesFn: func(_ context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + postReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-ext-debit"}, nil + }, + TransferInternalFn: func(_ context.Context, _ *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferCalled = true + return &ledgerv1.PostResponse{JournalEntryRef: "entry-transfer"}, nil + }, + }, + } + + out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_3_4_ledger_debit", + StepCode: "edge.3_4.ledger.debit", + Action: model.RailOperationExternalDebit, + Rail: model.RailLedger, + Metadata: map[string]string{ + "mode": "finalize_debit", + }, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_3_4_ledger_debit", + StepCode: "edge.3_4.ledger.debit", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if postReq == nil { + t.Fatal("expected external debit request") + } + if transferCalled { + t.Fatal("expected external debit to skip transfer") + } + if got, want := postReq.GetMoney().GetAmount(), "76.5"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := postReq.GetMoney().GetCurrency(), "RUB"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } + if got, want := postReq.GetRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD; got != want { + t.Fatalf("role mismatch: got=%v want=%v", got, want) + } + if got, want := postReq.GetMetadata()[ledgerMetadataMode], "finalize_debit"; got != want { + t.Fatalf("mode metadata mismatch: got=%q want=%q", got, want) + } + if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 1 { + t.Fatalf("expected one external ref, got=%d", len(out.StepExecution.ExternalRefs)) + } + if got, want := out.StepExecution.ExternalRefs[0].Ref, "entry-ext-debit"; got != want { + t.Fatalf("external ref value mismatch: got=%q want=%q", got, want) + } +} + func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSettlementAmount(t *testing.T) { orgID := bson.NewObjectID() payment := testLedgerExecutorPayment(orgID) diff --git a/api/payments/quotation/internal/server/internal/discovery.go b/api/payments/quotation/internal/server/internal/discovery.go index 7275a0ab..ad80b257 100644 --- a/api/payments/quotation/internal/server/internal/discovery.go +++ b/api/payments/quotation/internal/server/internal/discovery.go @@ -52,7 +52,7 @@ func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) { announce := discovery.Announcement{ Service: "PAYMENTS_QUOTATION", - Operations: []string{"payment.quote", "payment.multiquote"}, + Operations: []string{discovery.OperationPaymentQuote, discovery.OperationPaymentMultiQuote}, InvokeURI: invokeURI, Version: appversion.Create().Short(), } diff --git a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go index 736b9d77..3f992603 100644 --- a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go +++ b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go @@ -197,18 +197,6 @@ func (r *managedWalletNetworkResolver) listDiscoveredGatewayCandidates(ctx conte return candidates, nil } -func managedWalletNetworkFromResponse(resp *chainv1.GetManagedWalletResponse) (string, error) { - asset, err := managedWalletAssetFromResponse(resp) - if err != nil { - return "", err - } - network := strings.ToUpper(strings.TrimSpace(asset.GetChain())) - if network == "" || network == "UNSPECIFIED" { - return "", merrors.NoData("managed wallet network is missing") - } - return network, nil -} - func managedWalletAssetFromResponse(resp *chainv1.GetManagedWalletResponse) (*paymenttypes.Asset, error) { wallet := resp.GetWallet() if wallet == nil || wallet.GetAsset() == nil { diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go index f7f16fd5..098a60ec 100644 --- a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go @@ -8,7 +8,3 @@ import ( func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { return plan.RailFromEndpoint(endpoint, attrs, isSource) } - -func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { - return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork) -} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index 192823d6..0327c942 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -152,7 +152,8 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { if !s.deps.fees.available() { - return &feesv1.PrecomputeFeesResponse{}, nil + s.logger.Warn("Fees precompute failed: fee engine unavailable") + return nil, merrors.Internal("fees_precompute_failed") } intent := req.GetIntent() amount := cloneProtoMoney(baseAmount) @@ -188,7 +189,8 @@ func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quoteReques func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { if !s.deps.fees.available() { - return &feesv1.PrecomputeFeesResponse{}, nil + s.logger.Warn("Conversion fee precompute failed: fee engine unavailable") + return nil, merrors.Internal("fees_precompute_failed") } intent := req.GetIntent() amount := cloneProtoMoney(baseAmount) diff --git a/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go b/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go index 020939af..695e7677 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go @@ -2,6 +2,7 @@ package quotation import ( "context" + "errors" "strings" "testing" @@ -152,9 +153,89 @@ func TestBuildPaymentQuote_DoesNotRequestConversionFeesForManagedWalletToLedger( } } +func TestBuildPaymentQuote_ReturnsErrorWhenFeeEngineUnavailable(t *testing.T) { + svc := NewService(zap.NewNop(), nil) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToCardIntent(), + } + + _, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInternal) { + t.Fatalf("expected internal error, got: %v", err) + } + if !strings.Contains(err.Error(), "fees_precompute_failed") { + t.Fatalf("expected fees_precompute_failed error, got: %v", err) + } +} + +func TestBuildPaymentQuote_ReturnsErrorWhenBaseFeePrecomputeFails(t *testing.T) { + feeClient := &stubFeeEngineClient{ + precomputeErrByOrigin: map[string]error{ + "payments.orchestrator.quote": merrors.Internal("billing_fees_unreachable"), + }, + } + + svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0)) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToLedgerIntent(), + } + + _, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInternal) { + t.Fatalf("expected internal error, got: %v", err) + } + if !strings.Contains(err.Error(), "fees_precompute_failed") { + t.Fatalf("expected fees_precompute_failed error, got: %v", err) + } +} + +func TestBuildPaymentQuote_ReturnsErrorWhenConversionFeePrecomputeFails(t *testing.T) { + feeClient := &stubFeeEngineClient{ + precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{ + "payments.orchestrator.quote": { + Lines: []*feesv1.DerivedPostingLine{ + testFeeLine("1.00", "USDT"), + }, + }, + }, + precomputeErrByOrigin: map[string]error{ + "payments.orchestrator.conversion_quote": merrors.Internal("billing_fees_unreachable"), + }, + } + + svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0)) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToCardIntent(), + } + + _, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInternal) { + t.Fatalf("expected internal error, got: %v", err) + } + if !strings.Contains(err.Error(), "fees_precompute_failed") { + t.Fatalf("expected fees_precompute_failed error, got: %v", err) + } +} + type stubFeeEngineClient struct { - precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse - precomputeReqs []*feesv1.PrecomputeFeesRequest + precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse + precomputeErrByOrigin map[string]error + precomputeReqs []*feesv1.PrecomputeFeesRequest } func (s *stubFeeEngineClient) QuoteFees(context.Context, *feesv1.QuoteFeesRequest, ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) { @@ -177,6 +258,9 @@ func (s *stubFeeEngineClient) PrecomputeFees(_ context.Context, in *feesv1.Preco } originType := strings.TrimSpace(in.GetIntent().GetOriginType()) + if err := s.precomputeErrByOrigin[originType]; err != nil { + return nil, err + } resp, ok := s.precomputeByOrigin[originType] if !ok || resp == nil { return &feesv1.PrecomputeFeesResponse{}, nil diff --git a/api/pkg/discovery/operations.go b/api/pkg/discovery/operations.go new file mode 100644 index 00000000..44b323b7 --- /dev/null +++ b/api/pkg/discovery/operations.go @@ -0,0 +1,71 @@ +package discovery + +import "strings" + +const ( + OperationDiscoveryLookup = "discovery.lookup" + + OperationDocumentsBatchResolve = "documents.batch_resolve" + OperationDocumentsGet = "documents.get" + OperationFeeCalc = "fee.calc" + OperationNotifySend = "notify.send" + OperationFXQuote = "fx.quote" + OperationFXIngest = "fx.ingest" + + OperationPaymentInitiate = "payment.initiate" + OperationPaymentQuote = "payment.quote" + OperationPaymentMultiQuote = "payment.multiquote" + OperationPaymentMethodsManage = "payment_methods.manage" + OperationPaymentMethodsRead = "payment_methods.read" + + OperationBalanceRead = "balance.read" + + OperationLedgerDebit = "ledger.debit" + OperationLedgerCredit = "ledger.credit" + OperationLedgerTransfer = "ledger.transfer" + OperationLedgerFX = "ledger.fx" + OperationExternalDebit = "external.debit" + OperationExternalCredit = "external.credit" + + OperationSend = "send" + OperationFee = "fee" + OperationObserveConfirm = "observe.confirm" + OperationFXConvert = "fx.convert" +) + +// NormalizeOperation canonicalizes an operation string for comparisons. +func NormalizeOperation(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +// HasAnyOperation reports whether ops contains any of required operations. +func HasAnyOperation(ops []string, required []string) bool { + if len(required) == 0 { + return true + } + for _, op := range ops { + normalized := NormalizeOperation(op) + if normalized == "" { + continue + } + for _, target := range required { + if normalized == NormalizeOperation(target) { + return true + } + } + } + return false +} + +// LedgerServiceOperations returns canonical operations announced by ledger. +func LedgerServiceOperations() []string { + return []string{ + OperationBalanceRead, + OperationLedgerDebit, + OperationLedgerCredit, + OperationLedgerTransfer, + OperationLedgerFX, + OperationExternalCredit, + OperationExternalDebit, + } +} diff --git a/api/pkg/discovery/rail_vocab.go b/api/pkg/discovery/rail_vocab.go index c0249802..22eaf59c 100644 --- a/api/pkg/discovery/rail_vocab.go +++ b/api/pkg/discovery/rail_vocab.go @@ -104,14 +104,20 @@ func ExpandRailOperation(value string) []string { } switch strings.ToLower(strings.TrimSpace(value)) { + case OperationExternalDebit, "external_debit": + return []string{RailOperationExternalDebit} + case OperationExternalCredit, "external_credit": + return []string{RailOperationExternalCredit} case "payin", "payin.crypto", "payin.fiat", "payin.card": return []string{RailOperationExternalDebit} case "payout", "payout.crypto", "payout.fiat", "payout.card": return []string{RailOperationExternalCredit, RailOperationSend} - case "fee.send", "fees.send": + case "fee.send", "fees.send", OperationFee: return []string{RailOperationFee} - case "observe.confirm", "observe_confirm": + case OperationObserveConfirm, "observe_confirm": return []string{RailOperationObserveConfirm} + case OperationFXConvert, "fx_convert": + return []string{RailOperationFXConvert} case "funds.block", "hold.balance", "block": return []string{RailOperationBlock} case "funds.release", "release", "unblock": @@ -147,6 +153,13 @@ func NormalizeRailOperations(values []string) []string { // CryptoRailGatewayOperations returns canonical operations for crypto rail gateways. func CryptoRailGatewayOperations() []string { return []string{ + OperationBalanceRead, + OperationSend, + OperationExternalDebit, + OperationExternalCredit, + OperationFee, + OperationObserveConfirm, + // Legacy rail tokens retained for backward compatibility. RailOperationSend, RailOperationExternalDebit, RailOperationExternalCredit, @@ -158,6 +171,10 @@ func CryptoRailGatewayOperations() []string { // CardPayoutRailGatewayOperations returns canonical operations for card payout gateways. func CardPayoutRailGatewayOperations() []string { return []string{ + OperationSend, + OperationExternalCredit, + OperationObserveConfirm, + // Legacy rail tokens retained for backward compatibility. RailOperationSend, RailOperationExternalCredit, RailOperationObserveConfirm, @@ -167,6 +184,9 @@ func CardPayoutRailGatewayOperations() []string { // ProviderSettlementRailGatewayOperations returns canonical operations for settlement gateways. func ProviderSettlementRailGatewayOperations() []string { return []string{ + OperationFXConvert, + OperationObserveConfirm, + // Legacy rail tokens retained for backward compatibility. RailOperationFXConvert, RailOperationObserveConfirm, } diff --git a/api/pkg/discovery/rail_vocab_test.go b/api/pkg/discovery/rail_vocab_test.go index 7bb0cf4f..96eb1ce9 100644 --- a/api/pkg/discovery/rail_vocab_test.go +++ b/api/pkg/discovery/rail_vocab_test.go @@ -7,14 +7,16 @@ func TestNormalizeRailOperations(t *testing.T) { "send", "payout.crypto", "observe.confirm", + "external.credit", + "fx.convert", "unknown", - "EXTERNAL_CREDIT", }) want := []string{ RailOperationSend, RailOperationExternalCredit, RailOperationObserveConfirm, + RailOperationFXConvert, } if len(got) != len(want) { t.Fatalf("unexpected operations count: got=%d want=%d", len(got), len(want)) diff --git a/api/server/internal/api/discovery_resolver.go b/api/server/internal/api/discovery_resolver.go index bd946e0e..93952478 100644 --- a/api/server/internal/api/discovery_resolver.go +++ b/api/server/internal/api/discovery_resolver.go @@ -19,15 +19,8 @@ import ( 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" - paymentMethodsReadOperation = "payment_methods.read" ) var ( @@ -123,7 +116,7 @@ func (a *APIImp) resolveChainGatewayAddress(gateways []discovery.GatewaySummary) endpoint, selected, ok := selectGatewayEndpoint( gateways, cfg.DefaultAsset.Chain, - []string{gatewayReadBalanceOperation}, + []string{discovery.OperationBalanceRead}, ) if !ok { return @@ -146,7 +139,7 @@ func (a *APIImp) resolveLedgerAddress(services []discovery.ServiceSummary) { endpoint, selected, ok := selectServiceEndpoint( services, ledgerDiscoveryServiceNames, - []string{ledgerDebitOperation, ledgerCreditOperation}, + discovery.LedgerServiceOperations(), ) if !ok { return @@ -170,7 +163,7 @@ func (a *APIImp) resolvePaymentOrchestratorAddress(services []discovery.ServiceS endpoint, selected, ok := selectServiceEndpoint( services, paymentOrchestratorDiscoveryServiceNames, - []string{paymentInitiateOperation}, + []string{discovery.OperationPaymentInitiate}, ) if !ok { return false, discoveryEndpoint{} @@ -196,7 +189,7 @@ func (a *APIImp) resolvePaymentQuotationAddress(services []discovery.ServiceSumm endpoint, selected, ok := selectServiceEndpoint( services, paymentQuotationDiscoveryServiceNames, - []string{paymentQuoteOperation}, + []string{discovery.OperationPaymentQuote}, ) if !ok { cfg := a.config.PaymentQuotation @@ -229,7 +222,7 @@ func (a *APIImp) resolvePaymentMethodsAddress(services []discovery.ServiceSummar endpoint, selected, ok := selectServiceEndpoint( services, paymentMethodsDiscoveryServiceNames, - []string{paymentMethodsReadOperation}, + []string{discovery.OperationPaymentMethodsRead}, ) if !ok { return @@ -269,7 +262,7 @@ func selectServiceEndpoint(services []discovery.ServiceSummary, serviceNames []s selections = append(selections, serviceSelection{ service: svc, endpoint: endpoint, - opMatch: hasAnyOperation(svc.Ops, requiredOps), + opMatch: discovery.HasAnyOperation(svc.Ops, requiredOps), nameRank: nameRank, }) } @@ -302,7 +295,7 @@ func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork if !gateway.Healthy { continue } - if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discoveryGatewayRailCrypto) { + if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discovery.RailCrypto) { continue } if strings.TrimSpace(gateway.InvokeURI) == "" { @@ -316,7 +309,7 @@ func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork gateway: gateway, endpoint: endpoint, networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork), - opMatch: hasAnyOperation(gateway.Ops, requiredOps), + opMatch: discovery.HasAnyOperation(gateway.Ops, requiredOps), }) } if len(selections) == 0 { @@ -412,24 +405,6 @@ func serviceRank(service string, names []string) (int, bool) { 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 diff --git a/api/server/internal/server/paymentapiimp/documents.go b/api/server/internal/server/paymentapiimp/documents.go index 8c074ad8..909863be 100644 --- a/api/server/internal/server/paymentapiimp/documents.go +++ b/api/server/internal/server/paymentapiimp/documents.go @@ -27,7 +27,7 @@ import ( const ( documentsServiceName = "BILLING_DOCUMENTS" - documentsOperationGet = "documents.get" + documentsOperationGet = discovery.OperationDocumentsGet documentsDialTimeout = 5 * time.Second documentsCallTimeout = 10 * time.Second ) diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart index 7b67dda2..dc5c0550 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart @@ -7,7 +7,7 @@ import 'package:pshared/models/payment/type.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/asset_type_field.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/description.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/employees_loading_indicator.dart'; -import 'package:pweb/pages/dashboard/buttons/balance/add/ledger_fields.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/add/ledger/fields.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/managed_wallet_fields.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/name.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/owner.dart'; diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/currency_item.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/currency_item.dart new file mode 100644 index 00000000..2c203d97 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/currency_item.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/currency.dart'; +import 'package:pshared/utils/currency.dart'; + + +DropdownMenuItem currencyItem(Currency currency) => DropdownMenuItem( + value: currency, + child: Text(currencyCodeToString(currency)), +); \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/fields.dart similarity index 81% rename from frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart rename to frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/fields.dart index 13afdadd..191da740 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/fields.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/currency.dart'; -import 'package:pshared/utils/currency.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/constants.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/add/ledger/currency_item.dart'; import 'package:pweb/utils/text_field_styles.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -24,10 +24,8 @@ class LedgerFields extends StatelessWidget { initialValue: currency, decoration: getInputDecoration(context, AppLocalizations.of(context)!.currency, true), items: [ - DropdownMenuItem( - value: ledgerCurrencyDefault, - child: Text(currencyCodeToString(ledgerCurrencyDefault)), - ), + currencyItem(ledgerCurrencyDefault), + currencyItem(managedCurrencyDefault), ], onChanged: onCurrencyChanged, ); -- 2.49.1 From b4b5616de06a79dfb899e052eba9c9123bbf0a5d Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 16:32:10 +0100 Subject: [PATCH 15/23] removed dead mntx dependency + dead settings --- api/payments/orchestrator/config.dev.yml | 5 --- api/payments/orchestrator/config.yml | 5 --- .../internal/server/internal/builders.go | 16 --------- .../internal/server/internal/config.go | 34 ------------------- 4 files changed, 60 deletions(-) diff --git a/api/payments/orchestrator/config.dev.yml b/api/payments/orchestrator/config.dev.yml index 310f97e5..2239b23b 100644 --- a/api/payments/orchestrator/config.dev.yml +++ b/api/payments/orchestrator/config.dev.yml @@ -38,15 +38,10 @@ messaging: # Retain quote records after expiry to allow long-running payments to complete. quote_retention_hours: 72 -max_fx_quote_ttl_ms: 600000 - # Service endpoints are sourced from discovery; no static overrides. card_gateways: mcards: funding_address: "TUaWaCkiXwYPKm5qjcB27Lhwv976vPvedE" fee_wallet_ref: "697a062a248dc785125ccb9e" -fee_ledger_accounts: - mcards: "697a15cc72e95c92d4c5db01" - # Gateway instances and capabilities are sourced from service discovery. diff --git a/api/payments/orchestrator/config.yml b/api/payments/orchestrator/config.yml index 0f5e3f07..e7682871 100644 --- a/api/payments/orchestrator/config.yml +++ b/api/payments/orchestrator/config.yml @@ -38,15 +38,10 @@ messaging: # Retain quote records after expiry to allow long-running payments to complete. quote_retention_hours: 72 -max_fx_quote_ttl_ms: 600000 - # Service endpoints are sourced from discovery; no static overrides. card_gateways: mcards: funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF" fee_wallet_ref: "694c124ed76f9f811ac57133" -fee_ledger_accounts: - mcards: "ledger:fees:monetix" - # Gateway instances and capabilities are sourced from service discovery. diff --git a/api/payments/orchestrator/internal/server/internal/builders.go b/api/payments/orchestrator/internal/server/internal/builders.go index abb65aa7..9cf701e6 100644 --- a/api/payments/orchestrator/internal/server/internal/builders.go +++ b/api/payments/orchestrator/internal/server/internal/builders.go @@ -27,22 +27,6 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or return result } -func buildFeeLedgerAccounts(src map[string]string) map[string]string { - if len(src) == 0 { - return nil - } - result := make(map[string]string, len(src)) - for key, account := range src { - k := strings.ToLower(strings.TrimSpace(key)) - v := strings.TrimSpace(account) - if k == "" || v == "" { - continue - } - result[k] = v - } - return result -} - func buildGatewayRegistry(logger mlogger.Logger, src []gatewayInstanceConfig, registry *discovery.Registry) orchestrator.GatewayRegistry { if logger != nil { logger = logger.Named("gateway_registry") diff --git a/api/payments/orchestrator/internal/server/internal/config.go b/api/payments/orchestrator/internal/server/internal/config.go index 5ca78f2c..504797dc 100644 --- a/api/payments/orchestrator/internal/server/internal/config.go +++ b/api/payments/orchestrator/internal/server/internal/config.go @@ -13,24 +13,9 @@ 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"` - MaxFXQuoteTTLMs int64 `yaml:"max_fx_quote_ttl_ms"` -} - -type clientConfig struct { - Address string `yaml:"address"` - DialTimeoutSecs int `yaml:"dial_timeout_seconds"` - CallTimeoutSecs int `yaml:"call_timeout_seconds"` - InsecureTransport bool `yaml:"insecure"` } type cardGatewayRouteConfig struct { @@ -79,18 +64,6 @@ type limitsOverrideCfg struct { MaxOps int `yaml:"max_ops"` } -const ( - defaultMaxFXQuoteTTL = 10 * time.Minute - defaultMaxFXQuoteTTLMillis = int64(defaultMaxFXQuoteTTL / time.Millisecond) -) - -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 @@ -98,13 +71,6 @@ func (c *config) quoteRetention() time.Duration { return time.Duration(c.QuoteRetentionHrs) * time.Hour } -func (c *config) maxFXQuoteTTLMillis() int64 { - if c == nil || c.MaxFXQuoteTTLMs <= 0 { - return defaultMaxFXQuoteTTLMillis - } - return c.MaxFXQuoteTTLMs -} - func (i *Imp) loadConfig() (*config, error) { data, err := os.ReadFile(i.file) if err != nil { -- 2.49.1 From 0f95f898a844d32b90caf0c676d8d81fb842d892 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 16:59:09 +0100 Subject: [PATCH 16/23] gRPC error translation: invalid argument support --- .../internal/service/orchestrator/service.go | 2 +- .../service/orchestrator/service_v2.go | 32 ++++++-- .../service/orchestrator/service_v2_test.go | 78 ++++++------------- .../server/paymentapiimp/grpc_error.go | 41 ++++++++++ .../internal/server/paymentapiimp/list.go | 2 +- .../internal/server/paymentapiimp/pay.go | 4 +- .../internal/server/paymentapiimp/paybatch.go | 2 +- .../internal/server/paymentapiimp/quote.go | 4 +- 8 files changed, 99 insertions(+), 66 deletions(-) create mode 100644 api/server/internal/server/paymentapiimp/grpc_error.go diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 0d4d1b1b..7237537e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -72,7 +72,7 @@ func (s *Service) Register(router routers.GRPC) error { return nil } return router.Register(func(reg grpc.ServiceRegistrar) { - orchestrationv2.RegisterPaymentOrchestratorServiceServer(reg, newV2GRPCServer(s.v2)) + orchestrationv2.RegisterPaymentOrchestratorServiceServer(reg, newV2GRPCServer(s.v2, s.logger)) }) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go index 639b7c9f..53c1df88 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "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" @@ -145,21 +146,40 @@ func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) (p type v2GRPCServer struct { orchestrationv2.UnimplementedPaymentOrchestratorServiceServer - svc psvc.Service + svc psvc.Service + logger mlogger.Logger } -func newV2GRPCServer(svc psvc.Service) *v2GRPCServer { - return &v2GRPCServer{svc: svc} +func newV2GRPCServer(svc psvc.Service, logger mlogger.Logger) *v2GRPCServer { + if logger == nil { + logger = zap.NewNop() + } + return &v2GRPCServer{ + svc: svc, + logger: logger.Named("grpc"), + } } func (s *v2GRPCServer) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { - return s.svc.ExecutePayment(ctx, req) + resp, err := s.svc.ExecutePayment(ctx, req) + if err != nil { + return gsresponse.Execute(ctx, gsresponse.Auto[orchestrationv2.ExecutePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)) + } + return resp, nil } func (s *v2GRPCServer) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { - return s.svc.GetPayment(ctx, req) + resp, err := s.svc.GetPayment(ctx, req) + if err != nil { + return gsresponse.Execute(ctx, gsresponse.Auto[orchestrationv2.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)) + } + return resp, nil } func (s *v2GRPCServer) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { - return s.svc.ListPayments(ctx, req) + resp, err := s.svc.ListPayments(ctx, req) + if err != nil { + return gsresponse.Execute(ctx, gsresponse.Auto[orchestrationv2.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)) + } + return resp, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go index 9eed5915..cac2220d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go @@ -5,75 +5,47 @@ import ( "strings" "testing" - ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/storage" - quotestorage "github.com/tech/sendico/payments/storage/quote" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/pkg/merrors" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) -func TestNewOrchestrationV2Service_FailsWhenLedgerClientMissing(t *testing.T) { - svc, repo, err := newOrchestrationV2Service(zap.NewNop(), fakeStorageRepo{}, v2RuntimeDeps{}) +func TestV2GRPCServerExecutePayment_MapsInvalidArgument(t *testing.T) { + srv := newV2GRPCServer(fakeV2Service{ + executeErr: merrors.InvalidArgument("intent_ref is required for batch quotation"), + }, zap.NewNop()) + + _, err := srv.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{}) if err == nil { t.Fatal("expected error") } - if !strings.Contains(err.Error(), "ledger client is required") { - t.Fatalf("unexpected error: %v", err) + if got, want := status.Code(err), codes.InvalidArgument; got != want { + t.Fatalf("unexpected grpc status code: got=%s want=%s", got, want) } - if svc != nil { - t.Fatal("expected nil service") - } - if repo != nil { - t.Fatal("expected nil payment repo") + if got := status.Convert(err).Message(); !strings.Contains(got, "intent_ref is required for batch quotation") { + t.Fatalf("unexpected grpc status message: %q", got) } } -func TestNewOrchestrationV2Service_FailsWhenLedgerClientUnavailable(t *testing.T) { - ledger := unavailableLedgerClient{Fake: &ledgerclient.Fake{}} - svc, repo, err := newOrchestrationV2Service(zap.NewNop(), fakeStorageRepo{}, v2RuntimeDeps{ - LedgerClient: ledger, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "ledger client is unavailable") { - t.Fatalf("unexpected error: %v", err) - } - if svc != nil { - t.Fatal("expected nil service") - } - if repo != nil { - t.Fatal("expected nil payment repo") - } +type fakeV2Service struct { + executeErr error } -type unavailableLedgerClient struct { - *ledgerclient.Fake +func (f fakeV2Service) ExecutePayment(context.Context, *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + return nil, f.executeErr } -func (u unavailableLedgerClient) Available() bool { - return false +func (fakeV2Service) GetPayment(context.Context, *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { + return &orchestrationv2.GetPaymentResponse{}, nil } -type fakeStorageRepo struct{} - -func (fakeStorageRepo) Ping(context.Context) error { - return nil +func (fakeV2Service) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { + return &orchestrationv2.ListPaymentsResponse{}, nil } -func (fakeStorageRepo) Payments() storage.PaymentsStore { - return nil +func (fakeV2Service) ReconcileExternal(context.Context, psvc.ReconcileExternalInput) (*psvc.ReconcileExternalOutput, error) { + return &psvc.ReconcileExternalOutput{}, nil } - -func (fakeStorageRepo) PaymentMethods() storage.PaymentMethodsStore { - return nil -} - -func (fakeStorageRepo) Quotes() quotestorage.QuotesStore { - return nil -} - -func (fakeStorageRepo) Routes() storage.RoutesStore { - return nil -} - -var _ storage.Repository = fakeStorageRepo{} diff --git a/api/server/internal/server/paymentapiimp/grpc_error.go b/api/server/internal/server/paymentapiimp/grpc_error.go new file mode 100644 index 00000000..7d058ece --- /dev/null +++ b/api/server/internal/server/paymentapiimp/grpc_error.go @@ -0,0 +1,41 @@ +package paymentapiimp + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func grpcErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc { + statusErr, ok := status.FromError(err) + if !ok { + return response.Internal(logger, source, err) + } + + switch statusErr.Code() { + case codes.InvalidArgument: + return response.BadRequest(logger, source, "invalid_argument", statusErr.Message()) + case codes.NotFound: + return response.NotFound(logger, source, statusErr.Message()) + case codes.PermissionDenied: + return response.AccessDenied(logger, source, statusErr.Message()) + case codes.Unauthenticated: + return response.Unauthorized(logger, source, statusErr.Message()) + case codes.AlreadyExists, codes.Aborted: + return response.DataConflict(logger, source, statusErr.Message()) + case codes.Unimplemented: + return response.NotImplemented(logger, source, statusErr.Message()) + case codes.FailedPrecondition: + return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message()) + case codes.DeadlineExceeded: + return response.Error(logger, source, http.StatusGatewayTimeout, "deadline_exceeded", statusErr.Message()) + case codes.Unavailable: + return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message()) + default: + return response.Internal(logger, source, err) + } +} diff --git a/api/server/internal/server/paymentapiimp/list.go b/api/server/internal/server/paymentapiimp/list.go index b1362823..158b3965 100644 --- a/api/server/internal/server/paymentapiimp/list.go +++ b/api/server/internal/server/paymentapiimp/list.go @@ -80,7 +80,7 @@ func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token resp, err := a.execution.ListPayments(ctx, req) if err != nil { a.logger.Warn("Failed to list payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) - return response.Auto(a.logger, a.Name(), err) + return grpcErrorResponse(a.logger, a.Name(), err) } return sresponse.PaymentsListResponse(a.logger, resp, token) diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go index f5e680c5..42e98468 100644 --- a/api/server/internal/server/paymentapiimp/pay.go +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -76,7 +76,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to }) if qErr != nil { a.logger.Warn("Failed to quote payment before execution", zap.Error(qErr), mzap.ObjRef("organization_ref", orgRef)) - return response.Auto(a.logger, a.Name(), qErr) + return grpcErrorResponse(a.logger, a.Name(), qErr) } quotationRef = strings.TrimSpace(quoteResp.GetQuote().GetQuoteRef()) if quotationRef == "" { @@ -97,7 +97,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to resp, err := a.execution.ExecutePayment(ctx, req) if err != nil { a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) - return response.Auto(a.logger, a.Name(), err) + return grpcErrorResponse(a.logger, a.Name(), err) } return sresponse.PaymentResponse(a.logger, resp.GetPayment(), token) diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go index 7b1fe533..27db08e1 100644 --- a/api/server/internal/server/paymentapiimp/paybatch.go +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -49,7 +49,7 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc resp, err := a.execution.ExecutePayment(ctx, req) if err != nil { a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) - return response.Auto(a.logger, a.Name(), err) + return grpcErrorResponse(a.logger, a.Name(), err) } payments := make([]*orchestrationv2.Payment, 0, 1) diff --git a/api/server/internal/server/paymentapiimp/quote.go b/api/server/internal/server/paymentapiimp/quote.go index 56cf1119..f9b91bcd 100644 --- a/api/server/internal/server/paymentapiimp/quote.go +++ b/api/server/internal/server/paymentapiimp/quote.go @@ -62,7 +62,7 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token resp, err := a.quotation.QuotePayment(ctx, req) if err != nil { a.logger.Warn("Failed to quote payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) - return response.Auto(a.logger, a.Name(), err) + return grpcErrorResponse(a.logger, a.Name(), err) } return sresponse.PaymentQuoteResponse(a.logger, resp.GetIdempotencyKey(), resp.GetQuote(), token) @@ -118,7 +118,7 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke resp, err := a.quotation.QuotePayments(ctx, req) if err != nil { a.logger.Warn("Failed to quote payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) - return response.Auto(a.logger, a.Name(), err) + return grpcErrorResponse(a.logger, a.Name(), err) } return sresponse.PaymentQuotesResponse(a.logger, resp, token) -- 2.49.1 From 766103886847f79edae1ec0e27e41db315135f1e Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 18:43:44 +0100 Subject: [PATCH 17/23] intent reference generation + propagation --- .../service/orchestrationv2/psvc/execute.go | 11 +- .../orchestrationv2/psvc/service_e2e_test.go | 54 ++++ api/server/interface/api/srequest/payment.go | 37 ++- .../api/srequest/payment_validate_test.go | 94 ++++++ api/server/interface/api/sresponse/payment.go | 18 +- .../interface/api/sresponse/payment_test.go | 17 ++ .../internal/server/paymentapiimp/paybatch.go | 100 ++++++- .../server/paymentapiimp/paybatch_test.go | 268 ++++++++++++++++++ .../internal/server/paymentapiimp/service.go | 88 ++++++ .../service_legacy_fallback_test.go | 82 ++++++ .../requests/payment/initiate_payments.dart | 11 +- .../lib/data/dto/payment/payment_quote.dart | 9 +- .../lib/data/mapper/payment/quote.dart | 2 + .../lib/models/payment/quote/quote.dart | 2 + .../provider/payment/multiple/provider.dart | 5 +- .../pshared/lib/service/payment/multiple.dart | 5 +- .../test/payment/request_dto_format_test.dart | 21 ++ .../pweb/lib/providers/multiple_payouts.dart | 16 ++ 18 files changed, 816 insertions(+), 24 deletions(-) create mode 100644 api/server/internal/server/paymentapiimp/paybatch_test.go create mode 100644 api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index dd0c555e..f4d4b7e3 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -205,7 +205,7 @@ func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsna IntentRef: requestCtx.IntentRef, }) if err != nil { - return nil, nil, err + return nil, nil, remapResolveError(err) } graph, err := s.planner.Compile(xplan.Input{ IntentSnapshot: resolved.IntentSnapshot, @@ -269,6 +269,15 @@ func remapIdempotencyError(err error) error { return err } +func remapResolveError(err error) error { + switch { + case errors.Is(err, qsnap.ErrIntentRefRequired), errors.Is(err, qsnap.ErrIntentRefNotFound): + return merrors.InvalidArgument(err.Error()) + default: + return err + } +} + func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string { if idemSvc == nil || requestCtx == nil { return "" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 82a3f38f..1bbed458 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -106,6 +106,27 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) { } } +func TestExecutePayment_BatchQuoteRequiresIntentRef(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableBatchQuote(env.orgID, "quote-batch", []string{"intent-a", "intent-b"}, buildLedgerRoute())) + + _, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-batch"), + QuotationRef: "quote-batch", + ClientPaymentRef: "client-batch", + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for missing intent_ref, got %v", err) + } + if got := err.Error(); !strings.Contains(got, "intent_ref is required for batch quotation") { + t.Fatalf("unexpected error message: %q", got) + } +} + func TestExecutePayment_RetryThenSuccess(t *testing.T) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { if req.StepExecution.Attempt == 1 { @@ -627,6 +648,39 @@ func newExecutableQuote(orgRef bson.ObjectID, quoteRef, intentRef string, route } } +func newExecutableBatchQuote(orgRef bson.ObjectID, quoteRef string, intentRefs []string, route *paymenttypes.QuoteRouteSpecification) *model.PaymentQuoteRecord { + now := time.Now().UTC() + items := make([]*model.PaymentQuoteItemV2, 0, len(intentRefs)) + for _, intentRef := range intentRefs { + items = append(items, &model.PaymentQuoteItemV2{ + Intent: &model.PaymentIntent{ + Ref: intentRef, + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: testLedgerEndpoint("ledger-dst"), + Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + SettlementCurrency: "USD", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + Route: route, + }, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }) + } + return &model.PaymentQuoteRecord{ + Base: modelBase(now), + OrganizationBoundBase: pm.OrganizationBoundBase{ + OrganizationRef: orgRef, + }, + QuoteRef: quoteRef, + RequestShape: model.QuoteRequestShapeBatch, + Items: items, + ExpiresAt: now.Add(1 * time.Hour), + } +} + func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification { return &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index 9b835318..d8e2ae5f 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -107,15 +107,48 @@ func (r InitiatePayment) Validate() error { type InitiatePayments struct { PaymentBase `json:",inline"` - QuoteRef string `json:"quoteRef,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` + IntentRef string `json:"intentRef,omitempty"` + IntentRefs []string `json:"intentRefs,omitempty"` } -func (r InitiatePayments) Validate() error { +func (r *InitiatePayments) Validate() error { + if r == nil { + return merrors.InvalidArgument("request is required") + } if err := r.PaymentBase.Validate(); err != nil { return err } + r.QuoteRef = strings.TrimSpace(r.QuoteRef) + r.IntentRef = strings.TrimSpace(r.IntentRef) + hasIntentRefsField := r.IntentRefs != nil + + normalizedIntentRefs := make([]string, 0, len(r.IntentRefs)) + seen := make(map[string]struct{}, len(r.IntentRefs)) + for _, value := range r.IntentRefs { + intentRef := strings.TrimSpace(value) + if intentRef == "" { + return merrors.InvalidArgument("intentRefs must not contain empty values", "intentRefs") + } + if _, exists := seen[intentRef]; exists { + return merrors.InvalidArgument("intentRefs must contain unique values", "intentRefs") + } + seen[intentRef] = struct{}{} + normalizedIntentRefs = append(normalizedIntentRefs, intentRef) + } + if hasIntentRefsField && len(normalizedIntentRefs) == 0 { + return merrors.InvalidArgument("intentRefs must not be empty", "intentRefs") + } + r.IntentRefs = normalizedIntentRefs + if len(r.IntentRefs) == 0 { + r.IntentRefs = nil + } + if r.QuoteRef == "" { return merrors.InvalidArgument("quoteRef is required", "quoteRef") } + if r.IntentRef != "" && len(r.IntentRefs) > 0 { + return merrors.DataConflict("intentRef and intentRefs are mutually exclusive") + } return nil } diff --git a/api/server/interface/api/srequest/payment_validate_test.go b/api/server/interface/api/srequest/payment_validate_test.go index 3a801cef..9517f127 100644 --- a/api/server/interface/api/srequest/payment_validate_test.go +++ b/api/server/interface/api/srequest/payment_validate_test.go @@ -27,3 +27,97 @@ func TestValidateQuoteIdempotency(t *testing.T) { } }) } + +func TestInitiatePaymentsValidateIntentSelectors(t *testing.T) { + t.Run("accepts explicit intentRef", func(t *testing.T) { + req := &InitiatePayments{ + PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, + QuoteRef: "quote-1", + IntentRef: " intent-a ", + } + if err := req.Validate(); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got, want := req.IntentRef, "intent-a"; got != want { + t.Fatalf("intentRef mismatch: got=%q want=%q", got, want) + } + if req.IntentRefs != nil { + t.Fatalf("expected nil intentRefs, got %#v", req.IntentRefs) + } + }) + + t.Run("accepts explicit intentRefs", func(t *testing.T) { + req := &InitiatePayments{ + PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, + QuoteRef: " quote-1 ", + IntentRefs: []string{" intent-a ", "intent-b"}, + } + if err := req.Validate(); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got, want := req.QuoteRef, "quote-1"; got != want { + t.Fatalf("quoteRef mismatch: got=%q want=%q", got, want) + } + if got, want := len(req.IntentRefs), 2; got != want { + t.Fatalf("intentRefs length mismatch: got=%d want=%d", got, want) + } + if got, want := req.IntentRefs[0], "intent-a"; got != want { + t.Fatalf("intentRefs[0] mismatch: got=%q want=%q", got, want) + } + }) + + t.Run("rejects both intentRef and intentRefs", func(t *testing.T) { + req := &InitiatePayments{ + PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, + QuoteRef: "quote-1", + IntentRef: "intent-a", + IntentRefs: []string{"intent-b"}, + } + if err := req.Validate(); err == nil { + t.Fatal("expected error") + } + }) + + t.Run("rejects empty intentRefs item", func(t *testing.T) { + req := &InitiatePayments{ + PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, + QuoteRef: "quote-1", + IntentRefs: []string{"intent-a", " "}, + } + if err := req.Validate(); err == nil { + t.Fatal("expected error") + } + }) + + t.Run("rejects empty intentRefs list", func(t *testing.T) { + req := &InitiatePayments{ + PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, + QuoteRef: "quote-1", + IntentRefs: []string{}, + } + if err := req.Validate(); err == nil { + t.Fatal("expected error") + } + }) + + t.Run("rejects duplicate intentRefs", func(t *testing.T) { + req := &InitiatePayments{ + PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, + QuoteRef: "quote-1", + IntentRefs: []string{"intent-a", " intent-a "}, + } + if err := req.Validate(); err == nil { + t.Fatal("expected error") + } + }) + + t.Run("accepts no selectors for backward compatibility", func(t *testing.T) { + req := &InitiatePayments{ + PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, + QuoteRef: "quote-1", + } + if err := req.Validate(); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 1aa4c4a7..e8ca3e83 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -41,10 +41,11 @@ type FxQuote struct { } type PaymentQuote struct { - QuoteRef string `json:"quoteRef,omitempty"` - Amounts *QuoteAmounts `json:"amounts,omitempty"` - Fees *QuoteFees `json:"fees,omitempty"` - FxQuote *FxQuote `json:"fxQuote,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` + IntentRef string `json:"intentRef,omitempty"` + Amounts *QuoteAmounts `json:"amounts,omitempty"` + Fees *QuoteFees `json:"fees,omitempty"` + FxQuote *FxQuote `json:"fxQuote,omitempty"` } type QuoteAmounts struct { @@ -211,10 +212,11 @@ func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote { amounts := toQuoteAmounts(q) fees := toQuoteFees(q.GetFeeLines()) return &PaymentQuote{ - QuoteRef: q.GetQuoteRef(), - Amounts: amounts, - Fees: fees, - FxQuote: toFxQuote(q.GetFxQuote()), + QuoteRef: q.GetQuoteRef(), + IntentRef: strings.TrimSpace(q.GetIntentRef()), + Amounts: amounts, + Fees: fees, + FxQuote: toFxQuote(q.GetFxQuote()), } } diff --git a/api/server/interface/api/sresponse/payment_test.go b/api/server/interface/api/sresponse/payment_test.go index 12883537..a6de4f37 100644 --- a/api/server/interface/api/sresponse/payment_test.go +++ b/api/server/interface/api/sresponse/payment_test.go @@ -4,6 +4,7 @@ import ( "testing" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) @@ -117,3 +118,19 @@ func TestToPaymentIgnoresHiddenFailures(t *testing.T) { t.Fatalf("expected no visible operations, got=%d", len(dto.Operations)) } } + +func TestToPaymentQuote_MapsIntentRef(t *testing.T) { + dto := toPaymentQuote("ationv2.PaymentQuote{ + QuoteRef: "quote-1", + IntentRef: "intent-1", + }) + if dto == nil { + t.Fatal("expected non-nil quote dto") + } + if got, want := dto.QuoteRef, "quote-1"; got != want { + t.Fatalf("quote_ref mismatch: got=%q want=%q", got, want) + } + if got, want := dto.IntentRef, "intent-1"; got != want { + t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) + } +} diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go index 27db08e1..b98d9b44 100644 --- a/api/server/internal/server/paymentapiimp/paybatch.go +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -1,6 +1,8 @@ package paymentapiimp import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "net/http" "strings" @@ -16,6 +18,11 @@ import ( "go.uber.org/zap" ) +const ( + fanoutIdempotencyHashLen = 16 + maxExecuteIdempotencyKey = 256 +) + func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { orgRef, err := a.oph.GetRef(r) if err != nil { @@ -39,26 +46,100 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc return response.BadPayload(a.logger, a.Name(), err) } - req := &orchestrationv2.ExecutePaymentRequest{ - Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), - QuotationRef: strings.TrimSpace(payload.QuoteRef), - IntentRef: metadataValue(payload.Metadata, "intent_ref"), - ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"), + intentSelectors, err := resolveExecutionIntentSelectors(payload, a.isLegacyMetadataIntentRefFallbackAllowed()) + if err != nil { + return response.BadPayload(a.logger, a.Name(), err) + } + clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref") + baseIdempotencyKey := strings.TrimSpace(payload.IdempotencyKey) + quotationRef := strings.TrimSpace(payload.QuoteRef) + + executeOne := func(idempotencyKey, intentRef string) (*orchestrationv2.Payment, error) { + req := &orchestrationv2.ExecutePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), idempotencyKey), + QuotationRef: quotationRef, + IntentRef: strings.TrimSpace(intentRef), + ClientPaymentRef: clientPaymentRef, + } + resp, executeErr := a.execution.ExecutePayment(ctx, req) + if executeErr != nil { + return nil, executeErr + } + return resp.GetPayment(), nil } - resp, err := a.execution.ExecutePayment(ctx, req) + payments := make([]*orchestrationv2.Payment, 0, max(1, len(intentSelectors))) + if len(payload.IntentRefs) > 0 { + for _, intentRef := range payload.IntentRefs { + payment, executeErr := executeOne(deriveFanoutIdempotencyKey(baseIdempotencyKey, intentRef), intentRef) + if executeErr != nil { + a.logger.Warn("Failed to initiate batch payments", zap.Error(executeErr), zap.String("organization_ref", orgRef.Hex())) + return grpcErrorResponse(a.logger, a.Name(), executeErr) + } + if payment != nil { + payments = append(payments, payment) + } + } + return sresponse.PaymentsResponse(a.logger, payments, token) + } + + intentRef := "" + if len(intentSelectors) > 0 { + intentRef = intentSelectors[0] + } + payment, err := executeOne(baseIdempotencyKey, intentRef) if err != nil { a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) return grpcErrorResponse(a.logger, a.Name(), err) } - - payments := make([]*orchestrationv2.Payment, 0, 1) - if payment := resp.GetPayment(); payment != nil { + if payment != nil { payments = append(payments, payment) } return sresponse.PaymentsResponse(a.logger, payments, token) } +func resolveExecutionIntentSelectors(payload *srequest.InitiatePayments, allowLegacyMetadataIntentRef bool) ([]string, error) { + if payload == nil { + return nil, nil + } + if len(payload.IntentRefs) > 0 { + return append([]string(nil), payload.IntentRefs...), nil + } + if intentRef := strings.TrimSpace(payload.IntentRef); intentRef != "" { + return []string{intentRef}, nil + } + legacy := metadataValue(payload.Metadata, "intent_ref") + if legacy == "" { + return nil, nil + } + if allowLegacyMetadataIntentRef { + return []string{legacy}, nil + } + return nil, merrors.InvalidArgument("metadata.intent_ref is no longer supported; use intentRef or intentRefs", "metadata.intent_ref") +} + +func deriveFanoutIdempotencyKey(baseIdempotencyKey, intentRef string) string { + baseIdempotencyKey = strings.TrimSpace(baseIdempotencyKey) + intentRef = strings.TrimSpace(intentRef) + if baseIdempotencyKey == "" || intentRef == "" { + return baseIdempotencyKey + } + sum := sha256.Sum256([]byte(intentRef)) + hash := hex.EncodeToString(sum[:]) + if len(hash) > fanoutIdempotencyHashLen { + hash = hash[:fanoutIdempotencyHashLen] + } + suffix := ":i:" + hash + if len(baseIdempotencyKey)+len(suffix) <= maxExecuteIdempotencyKey { + return baseIdempotencyKey + suffix + } + if len(suffix) >= maxExecuteIdempotencyKey { + return suffix[:maxExecuteIdempotencyKey] + } + prefixLen := maxExecuteIdempotencyKey - len(suffix) + return baseIdempotencyKey[:prefixLen] + suffix +} + func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) { defer r.Body.Close() @@ -68,6 +149,7 @@ func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, } payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey) payload.QuoteRef = strings.TrimSpace(payload.QuoteRef) + payload.IntentRef = strings.TrimSpace(payload.IntentRef) if err := payload.Validate(); err != nil { return nil, err diff --git a/api/server/internal/server/paymentapiimp/paybatch_test.go b/api/server/internal/server/paymentapiimp/paybatch_test.go new file mode 100644 index 00000000..bfdadfa2 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/paybatch_test.go @@ -0,0 +1,268 @@ +package paymentapiimp + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "github.com/tech/sendico/server/interface/api/srequest" + "github.com/tech/sendico/server/interface/api/sresponse" + mutil "github.com/tech/sendico/server/internal/mutil/param" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func TestInitiatePaymentsByQuote_FansOutByIntentRefs(t *testing.T) { + orgRef := bson.NewObjectID() + exec := &fakeExecutionClientForBatch{} + api := newBatchAPI(exec) + + body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}` + rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) + if got, want := rr.Code, http.StatusOK; got != want { + t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) + } + + if got, want := len(exec.executeReqs), 2; got != want { + t.Fatalf("execute calls mismatch: got=%d want=%d", got, want) + } + if got, want := exec.executeReqs[0].GetIntentRef(), "intent-a"; got != want { + t.Fatalf("intent_ref[0] mismatch: got=%q want=%q", got, want) + } + if got, want := exec.executeReqs[1].GetIntentRef(), "intent-b"; got != want { + t.Fatalf("intent_ref[1] mismatch: got=%q want=%q", got, want) + } + if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), deriveFanoutIdempotencyKey("idem-batch", "intent-a"); got != want { + t.Fatalf("idempotency[0] mismatch: got=%q want=%q", got, want) + } + if got, want := exec.executeReqs[1].GetMeta().GetTrace().GetIdempotencyKey(), deriveFanoutIdempotencyKey("idem-batch", "intent-b"); got != want { + t.Fatalf("idempotency[1] mismatch: got=%q want=%q", got, want) + } +} + +func TestInitiatePaymentsByQuote_UsesExplicitIntentRef(t *testing.T) { + orgRef := bson.NewObjectID() + exec := &fakeExecutionClientForBatch{} + api := newBatchAPI(exec) + + body := `{"idempotencyKey":"idem-single","quoteRef":"quote-1","intentRef":"intent-x"}` + rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) + if got, want := rr.Code, http.StatusOK; got != want { + t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) + } + + if got, want := len(exec.executeReqs), 1; got != want { + t.Fatalf("execute calls mismatch: got=%d want=%d", got, want) + } + if got, want := exec.executeReqs[0].GetIntentRef(), "intent-x"; got != want { + t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) + } + if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-single"; got != want { + t.Fatalf("idempotency mismatch: got=%q want=%q", got, want) + } +} + +func TestInitiatePaymentsByQuote_UsesLegacyMetadataIntentRefFallback(t *testing.T) { + orgRef := bson.NewObjectID() + exec := &fakeExecutionClientForBatch{} + api := newBatchAPIWithLegacyFallback(exec, true, time.Time{}) + + body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}` + rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) + if got, want := rr.Code, http.StatusOK; got != want { + t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) + } + + if got, want := len(exec.executeReqs), 1; got != want { + t.Fatalf("execute calls mismatch: got=%d want=%d", got, want) + } + if got, want := exec.executeReqs[0].GetIntentRef(), "intent-legacy"; got != want { + t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefFallbackByDefault(t *testing.T) { + orgRef := bson.NewObjectID() + exec := &fakeExecutionClientForBatch{} + api := newBatchAPI(exec) + + body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}` + rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) + if got, want := rr.Code, http.StatusBadRequest; got != want { + t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) + } + if got := len(exec.executeReqs); got != 0 { + t.Fatalf("expected no execute calls, got=%d", got) + } +} + +func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefWhenDateGateExpired(t *testing.T) { + orgRef := bson.NewObjectID() + exec := &fakeExecutionClientForBatch{} + now := time.Date(2026, time.January, 10, 12, 0, 0, 0, time.UTC) + api := newBatchAPIWithLegacyFallback(exec, true, now.Add(-time.Minute)) + api.clock = func() time.Time { return now } + + body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}` + rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) + if got, want := rr.Code, http.StatusBadRequest; got != want { + t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) + } + if got := len(exec.executeReqs); got != 0 { + t.Fatalf("expected no execute calls, got=%d", got) + } +} + +func TestDeriveFanoutIdempotencyKey_IsDeterministicAndBounded(t *testing.T) { + a := deriveFanoutIdempotencyKey("idem-1", "intent-a") + b := deriveFanoutIdempotencyKey("idem-1", "intent-a") + if got, want := a, b; got != want { + t.Fatalf("determinism mismatch: got=%q want=%q", got, want) + } + if a == "idem-1" { + t.Fatalf("expected derived key to differ from base") + } + + c := deriveFanoutIdempotencyKey("idem-1", "intent-b") + if c == a { + t.Fatalf("expected different derived keys for different intents") + } + + longBase := strings.Repeat("a", 400) + long := deriveFanoutIdempotencyKey(longBase, "intent-a") + if got, want := len(long), maxExecuteIdempotencyKey; got != want { + t.Fatalf("length mismatch: got=%d want=%d", got, want) + } +} + +func TestResolveExecutionIntentSelectors_PrefersExplicitSelectors(t *testing.T) { + payload := &srequest.InitiatePayments{ + IntentRefs: []string{"intent-a", "intent-b"}, + IntentRef: "intent-single", + PaymentBase: srequest.PaymentBase{ + Metadata: map[string]string{"intent_ref": "intent-legacy"}, + }, + } + got, err := resolveExecutionIntentSelectors(payload, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || len(got) != 2 { + t.Fatalf("unexpected selectors: %#v", got) + } + if got[0] != "intent-a" || got[1] != "intent-b" { + t.Fatalf("unexpected selectors order/value: %#v", got) + } +} + +func TestResolveExecutionIntentSelectors_RejectsLegacyMetadataSelectorWhenDisabled(t *testing.T) { + payload := &srequest.InitiatePayments{ + PaymentBase: srequest.PaymentBase{ + Metadata: map[string]string{"intent_ref": "intent-legacy"}, + }, + } + _, err := resolveExecutionIntentSelectors(payload, false) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } +} + +func TestResolveExecutionIntentSelectors_UsesLegacyMetadataSelectorWhenEnabled(t *testing.T) { + payload := &srequest.InitiatePayments{ + PaymentBase: srequest.PaymentBase{ + Metadata: map[string]string{"intent_ref": "intent-legacy"}, + }, + } + got, err := resolveExecutionIntentSelectors(payload, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || len(got) != 1 || got[0] != "intent-legacy" { + t.Fatalf("unexpected selectors: %#v", got) + } +} + +func newBatchAPI(exec executionClient) *PaymentAPI { + return newBatchAPIWithLegacyFallback(exec, false, time.Time{}) +} + +func newBatchAPIWithLegacyFallback(exec executionClient, enabled bool, until time.Time) *PaymentAPI { + return &PaymentAPI{ + logger: mlogger.Logger(zap.NewNop()), + execution: exec, + enf: fakeEnforcerForBatch{allowed: true}, + oph: mutil.CreatePH(mservice.Organizations), + permissionRef: bson.NewObjectID(), + legacyMetadataIntentRefFallbackEnabled: enabled, + legacyMetadataIntentRefFallbackUntil: until, + clock: time.Now, + } +} + +func invokeInitiatePaymentsByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/by-multiquote", bytes.NewBufferString(body)) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("organizations_ref", orgRef.Hex()) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) + + rr := httptest.NewRecorder() + handler := api.initiatePaymentsByQuote(req, &model.Account{}, &sresponse.TokenData{ + Token: "token", + Expiration: time.Now().UTC().Add(time.Hour), + }) + handler.ServeHTTP(rr, req) + return rr +} + +type fakeExecutionClientForBatch struct { + executeReqs []*orchestrationv2.ExecutePaymentRequest +} + +func (f *fakeExecutionClientForBatch) ExecutePayment(_ context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + f.executeReqs = append(f.executeReqs, req) + return &orchestrationv2.ExecutePaymentResponse{ + Payment: &orchestrationv2.Payment{PaymentRef: bson.NewObjectID().Hex()}, + }, nil +} + +func (*fakeExecutionClientForBatch) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { + return &orchestrationv2.ListPaymentsResponse{}, nil +} + +func (*fakeExecutionClientForBatch) Close() error { return nil } + +type fakeEnforcerForBatch struct { + allowed bool +} + +func (f fakeEnforcerForBatch) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) { + return f.allowed, nil +} + +func (fakeEnforcerForBatch) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) { + return nil, nil +} + +func (fakeEnforcerForBatch) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) { + return nil, nil +} + +func (fakeEnforcerForBatch) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) { + return nil, nil, nil +} + +var _ auth.Enforcer = (*fakeEnforcerForBatch)(nil) diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index 1bdacbc5..beb9ea42 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "fmt" "os" + "strconv" "strings" "sync" "time" @@ -29,6 +30,11 @@ import ( "google.golang.org/grpc/credentials/insecure" ) +const ( + envLegacyMetadataIntentRefFallbackEnabled = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK" + envLegacyMetadataIntentRefFallbackUntil = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK_UNTIL" +) + type executionClient interface { ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) @@ -52,6 +58,10 @@ type PaymentAPI struct { refreshMu sync.RWMutex refreshEvent *discovery.RefreshEvent + legacyMetadataIntentRefFallbackEnabled bool + legacyMetadataIntentRefFallbackUntil time.Time + clock func() time.Time + permissionRef bson.ObjectID } @@ -82,6 +92,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { logger: apiCtx.Logger().Named(mservice.Payments), enf: apiCtx.Permissions().Enforcer(), oph: mutil.CreatePH(mservice.Organizations), + clock: time.Now, } desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments) @@ -95,6 +106,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err)) return nil, err } + p.configureLegacyMetadataIntentRefFallback() if err := p.initDiscoveryClient(apiCtx.Config()); err != nil { p.logger.Warn("Failed to initialize discovery client", zap.Error(err)) } @@ -290,3 +302,79 @@ func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error { }() return nil } + +func (a *PaymentAPI) configureLegacyMetadataIntentRefFallback() { + enabled := false + enabledRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackEnabled)) + if enabledRaw != "" { + parsed, err := strconv.ParseBool(enabledRaw) + if err != nil { + a.logger.Warn("Invalid legacy metadata intent_ref fallback flag, disabling fallback", + zap.String("env", envLegacyMetadataIntentRefFallbackEnabled), + zap.String("value", enabledRaw), + zap.Error(err), + ) + } else { + enabled = parsed + } + } + + until := time.Time{} + untilRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackUntil)) + if untilRaw != "" { + parsed, err := parseLegacyMetadataIntentRefFallbackDeadline(untilRaw) + if err != nil { + a.logger.Warn("Invalid legacy metadata intent_ref fallback deadline, ignoring deadline", + zap.String("env", envLegacyMetadataIntentRefFallbackUntil), + zap.String("value", untilRaw), + zap.Error(err), + ) + } else { + until = parsed + } + } + + a.legacyMetadataIntentRefFallbackEnabled = enabled + a.legacyMetadataIntentRefFallbackUntil = until + + if !enabled { + return + } + fields := []zap.Field{ + zap.String("env_flag", envLegacyMetadataIntentRefFallbackEnabled), + zap.String("env_until", envLegacyMetadataIntentRefFallbackUntil), + } + if !until.IsZero() { + fields = append(fields, zap.Time("until_utc", until.UTC())) + } + a.logger.Warn("Legacy metadata.intent_ref fallback is enabled for /by-multiquote", fields...) +} + +func (a *PaymentAPI) isLegacyMetadataIntentRefFallbackAllowed() bool { + if a == nil || !a.legacyMetadataIntentRefFallbackEnabled { + return false + } + if a.legacyMetadataIntentRefFallbackUntil.IsZero() { + return true + } + now := time.Now().UTC() + if a.clock != nil { + now = a.clock().UTC() + } + return now.Before(a.legacyMetadataIntentRefFallbackUntil.UTC()) +} + +func parseLegacyMetadataIntentRefFallbackDeadline(value string) (time.Time, error) { + raw := strings.TrimSpace(value) + if raw == "" { + return time.Time{}, merrors.InvalidArgument("deadline is required") + } + if ts, err := time.Parse(time.RFC3339, raw); err == nil { + return ts.UTC(), nil + } + if date, err := time.Parse("2006-01-02", raw); err == nil { + // Date-only values are treated as inclusive; disable fallback at the next UTC midnight. + return date.UTC().Add(24 * time.Hour), nil + } + return time.Time{}, merrors.InvalidArgument("deadline must be RFC3339 or YYYY-MM-DD") +} diff --git a/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go b/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go new file mode 100644 index 00000000..9a06da07 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go @@ -0,0 +1,82 @@ +package paymentapiimp + +import ( + "testing" + "time" +) + +func TestParseLegacyMetadataIntentRefFallbackDeadline(t *testing.T) { + t.Run("parses RFC3339", func(t *testing.T) { + got, err := parseLegacyMetadataIntentRefFallbackDeadline("2026-02-26T12:00:00Z") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := time.Date(2026, time.February, 26, 12, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("deadline mismatch: got=%s want=%s", got.UTC(), want.UTC()) + } + }) + + t.Run("parses date-only as inclusive UTC day", func(t *testing.T) { + got, err := parseLegacyMetadataIntentRefFallbackDeadline("2026-02-26") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := time.Date(2026, time.February, 27, 0, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("deadline mismatch: got=%s want=%s", got.UTC(), want.UTC()) + } + }) + + t.Run("rejects invalid format", func(t *testing.T) { + if _, err := parseLegacyMetadataIntentRefFallbackDeadline("26-02-2026"); err == nil { + t.Fatal("expected error") + } + }) +} + +func TestIsLegacyMetadataIntentRefFallbackAllowed(t *testing.T) { + now := time.Date(2026, time.February, 26, 12, 0, 0, 0, time.UTC) + + t.Run("disabled", func(t *testing.T) { + api := &PaymentAPI{ + legacyMetadataIntentRefFallbackEnabled: false, + clock: func() time.Time { return now }, + } + if api.isLegacyMetadataIntentRefFallbackAllowed() { + t.Fatal("expected disabled fallback") + } + }) + + t.Run("enabled without deadline", func(t *testing.T) { + api := &PaymentAPI{ + legacyMetadataIntentRefFallbackEnabled: true, + clock: func() time.Time { return now }, + } + if !api.isLegacyMetadataIntentRefFallbackAllowed() { + t.Fatal("expected enabled fallback") + } + }) + + t.Run("enabled with future deadline", func(t *testing.T) { + api := &PaymentAPI{ + legacyMetadataIntentRefFallbackEnabled: true, + legacyMetadataIntentRefFallbackUntil: now.Add(time.Minute), + clock: func() time.Time { return now }, + } + if !api.isLegacyMetadataIntentRefFallbackAllowed() { + t.Fatal("expected enabled fallback before deadline") + } + }) + + t.Run("enabled with past deadline", func(t *testing.T) { + api := &PaymentAPI{ + legacyMetadataIntentRefFallbackEnabled: true, + legacyMetadataIntentRefFallbackUntil: now.Add(-time.Minute), + clock: func() time.Time { return now }, + } + if api.isLegacyMetadataIntentRefFallbackAllowed() { + t.Fatal("expected disabled fallback after deadline") + } + }) +} diff --git a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart index ed4349ca..3e628c9b 100644 --- a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart +++ b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart @@ -1,13 +1,16 @@ import 'package:pshared/api/requests/payment/base.dart'; - class InitiatePaymentsRequest extends PaymentBaseRequest { final String quoteRef; + final String? intentRef; + final List? intentRefs; const InitiatePaymentsRequest({ required super.idempotencyKey, super.metadata, required this.quoteRef, + this.intentRef, + this.intentRefs, }); factory InitiatePaymentsRequest.fromJson(Map json) { @@ -17,6 +20,10 @@ class InitiatePaymentsRequest extends PaymentBaseRequest { (key, value) => MapEntry(key, value as String), ), quoteRef: json['quoteRef'] as String, + intentRef: json['intentRef'] as String?, + intentRefs: (json['intentRefs'] as List?) + ?.map((value) => value as String) + .toList(), ); } @@ -26,6 +33,8 @@ class InitiatePaymentsRequest extends PaymentBaseRequest { 'idempotencyKey': idempotencyKey, 'metadata': metadata, 'quoteRef': quoteRef, + if (intentRef != null) 'intentRef': intentRef, + if (intentRefs != null) 'intentRefs': intentRefs, }; } } diff --git a/frontend/pshared/lib/data/dto/payment/payment_quote.dart b/frontend/pshared/lib/data/dto/payment/payment_quote.dart index 326b979a..e125368e 100644 --- a/frontend/pshared/lib/data/dto/payment/payment_quote.dart +++ b/frontend/pshared/lib/data/dto/payment/payment_quote.dart @@ -9,11 +9,18 @@ part 'payment_quote.g.dart'; @JsonSerializable() class PaymentQuoteDTO { final String? quoteRef; + final String? intentRef; final QuoteAmountsDTO? amounts; final QuoteFeesDTO? fees; final FxQuoteDTO? fxQuote; - const PaymentQuoteDTO({this.quoteRef, this.amounts, this.fees, this.fxQuote}); + const PaymentQuoteDTO({ + this.quoteRef, + this.intentRef, + this.amounts, + this.fees, + this.fxQuote, + }); factory PaymentQuoteDTO.fromJson(Map json) => _$PaymentQuoteDTOFromJson(json); diff --git a/frontend/pshared/lib/data/mapper/payment/quote.dart b/frontend/pshared/lib/data/mapper/payment/quote.dart index e3decf77..e5f76298 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote.dart @@ -7,6 +7,7 @@ import 'package:pshared/models/payment/quote/quote.dart'; extension PaymentQuoteDTOMapper on PaymentQuoteDTO { PaymentQuote toDomain({String? idempotencyKey}) => PaymentQuote( quoteRef: quoteRef, + intentRef: intentRef, idempotencyKey: idempotencyKey, amounts: amounts?.toDomain(), fees: fees?.toDomain(), @@ -17,6 +18,7 @@ extension PaymentQuoteDTOMapper on PaymentQuoteDTO { extension PaymentQuoteMapper on PaymentQuote { PaymentQuoteDTO toDTO() => PaymentQuoteDTO( quoteRef: quoteRef, + intentRef: intentRef, amounts: amounts?.toDTO(), fees: fees?.toDTO(), fxQuote: fxQuote?.toDTO(), diff --git a/frontend/pshared/lib/models/payment/quote/quote.dart b/frontend/pshared/lib/models/payment/quote/quote.dart index ecf3e5bb..5ebde9c2 100644 --- a/frontend/pshared/lib/models/payment/quote/quote.dart +++ b/frontend/pshared/lib/models/payment/quote/quote.dart @@ -4,6 +4,7 @@ import 'package:pshared/models/payment/quote/fees.dart'; class PaymentQuote { final String? quoteRef; + final String? intentRef; final String? idempotencyKey; final QuoteAmounts? amounts; final QuoteFees? fees; @@ -11,6 +12,7 @@ class PaymentQuote { const PaymentQuote({ required this.quoteRef, + required this.intentRef, required this.idempotencyKey, required this.amounts, required this.fees, diff --git a/frontend/pshared/lib/provider/payment/multiple/provider.dart b/frontend/pshared/lib/provider/payment/multiple/provider.dart index 9c0cd15c..59614155 100644 --- a/frontend/pshared/lib/provider/payment/multiple/provider.dart +++ b/frontend/pshared/lib/provider/payment/multiple/provider.dart @@ -7,7 +7,6 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/multiple.dart'; import 'package:pshared/utils/exception.dart'; - class MultiPaymentProvider extends ChangeNotifier { late OrganizationsProvider _organization; late MultiQuotationProvider _quotation; @@ -31,6 +30,8 @@ class MultiPaymentProvider extends ChangeNotifier { Future> pay({ String? idempotencyKey, Map? metadata, + String? intentRef, + List? intentRefs, }) async { if (!_organization.isOrganizationSet) { throw StateError('Organization is not set'); @@ -53,6 +54,8 @@ class MultiPaymentProvider extends ChangeNotifier { quoteRef, idempotencyKey: idempotencyKey, metadata: metadata, + intentRef: intentRef, + intentRefs: intentRefs, ); _setResource( diff --git a/frontend/pshared/lib/service/payment/multiple.dart b/frontend/pshared/lib/service/payment/multiple.dart index a181e68d..7c3ea44d 100644 --- a/frontend/pshared/lib/service/payment/multiple.dart +++ b/frontend/pshared/lib/service/payment/multiple.dart @@ -13,7 +13,6 @@ import 'package:pshared/models/payment/quote/quotes.dart'; import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; - class MultiplePaymentsService { static final _logger = Logger('service.payment.multiple'); static const String _objectType = Services.payments; @@ -38,6 +37,8 @@ class MultiplePaymentsService { String quoteRef, { String? idempotencyKey, Map? metadata, + String? intentRef, + List? intentRefs, }) async { _logger.fine( 'Executing multiple payments for quote $quoteRef in $organizationRef', @@ -46,6 +47,8 @@ class MultiplePaymentsService { idempotencyKey: idempotencyKey ?? const Uuid().v4(), quoteRef: quoteRef, metadata: metadata, + intentRef: intentRef, + intentRefs: intentRefs, ); final response = await AuthorizationService.getPOSTResponse( diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 9d6ce40e..38eb231b 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -114,6 +114,7 @@ void main() { 'idempotencyKey': 'idem-1', 'quote': { 'quoteRef': 'q-1', + 'intentRef': 'intent-1', 'amounts': { 'sourcePrincipal': {'amount': '10', 'currency': 'USDT'}, 'sourceDebitTotal': {'amount': '10.75', 'currency': 'USDT'}, @@ -148,6 +149,7 @@ void main() { }); expect(response.quote.fxQuote?.pricedAtUnixMs, equals(1771945907000)); + expect(response.quote.intentRef, equals('intent-1')); expect(response.quote.amounts?.sourceDebitTotal?.amount, equals('10.75')); expect(response.quote.fees?.lines?.length, equals(1)); }); @@ -174,16 +176,35 @@ void main() { final request = InitiatePaymentsRequest( idempotencyKey: 'idem-3', quoteRef: 'q-2', + intentRefs: const ['intent-a', 'intent-b'], metadata: const {'client_payment_ref': 'cp-1'}, ); final json = request.toJson(); expect(json['idempotencyKey'], equals('idem-3')); expect(json['quoteRef'], equals('q-2')); + expect(json['intentRefs'], equals(const ['intent-a', 'intent-b'])); expect( (json['metadata'] as Map)['client_payment_ref'], equals('cp-1'), ); }); + + test( + 'initiate multi payments request supports single intentRef selector', + () { + final request = InitiatePaymentsRequest( + idempotencyKey: 'idem-4', + quoteRef: 'q-2', + intentRef: 'intent-single', + ); + + final json = request.toJson(); + expect(json['idempotencyKey'], equals('idem-4')); + expect(json['quoteRef'], equals('q-2')); + expect(json['intentRef'], equals('intent-single')); + expect(json.containsKey('intentRefs'), isFalse); + }, + ); }); } diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index b497b8f6..1957e64a 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -188,6 +188,7 @@ class MultiplePayoutsProvider extends ChangeNotifier { try { _setState(MultiplePayoutsState.sending); _error = null; + final intentRefs = _quotedIntentRefs(); final result = await payment.pay( metadata: { @@ -197,6 +198,7 @@ class MultiplePayoutsProvider extends ChangeNotifier { 'upload_rows': _rows.length.toString(), ...?_uploadAmountMetadata(), }, + intentRefs: intentRefs.isEmpty ? null : intentRefs, ); _sentCount = result.length; @@ -272,6 +274,20 @@ class MultiplePayoutsProvider extends ChangeNotifier { List _quoteItems() => _quotation?.quotation?.items ?? const []; + List _quotedIntentRefs() { + final seen = {}; + final intentRefs = []; + for (final quote in _quoteItems()) { + final intentRef = (quote.intentRef ?? '').trim(); + if (intentRef.isEmpty || seen.contains(intentRef)) { + continue; + } + seen.add(intentRef); + intentRefs.add(intentRef); + } + return intentRefs; + } + @override void dispose() { _quotation?.removeListener(_onQuotationChanged); -- 2.49.1 From 4949c4ffe0b5eed673885dfc587229f2b0d8c0ad Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 22:12:32 +0100 Subject: [PATCH 18/23] Batch payment execution + got rid of intent references --- api/payments/orchestrator/client/client.go | 8 + api/payments/orchestrator/client/fake.go | 16 +- .../orchestrationv2/psvc/execute_batch.go | 262 ++++++++++++++++++ .../psvc/execute_batch_test.go | 185 +++++++++++++ .../service/orchestrationv2/psvc/module.go | 3 + .../service/orchestrationv2/psvc/service.go | 10 + .../service/orchestrationv2/qsnap/module.go | 20 ++ .../orchestrationv2/qsnap/resolve_all_test.go | 246 ++++++++++++++++ .../service/orchestrationv2/qsnap/service.go | 85 ++++++ .../orchestrator/external_runtime_test.go | 4 + .../orchestrator/service_registration_test.go | 4 + .../service/orchestrator/service_v2.go | 8 + .../service/orchestrator/service_v2_test.go | 4 + .../internal/server/paymentapiimp/paybatch.go | 59 ++-- .../server/paymentapiimp/paybatch_test.go | 54 ++-- .../internal/server/paymentapiimp/service.go | 1 + 16 files changed, 891 insertions(+), 78 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_all_test.go diff --git a/api/payments/orchestrator/client/client.go b/api/payments/orchestrator/client/client.go index f0536176..b1b404b4 100644 --- a/api/payments/orchestrator/client/client.go +++ b/api/payments/orchestrator/client/client.go @@ -17,6 +17,7 @@ import ( // Client exposes typed helpers around the payment orchestration and quotation gRPC APIs. type Client interface { ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) Close() error @@ -24,6 +25,7 @@ type Client interface { type grpcOrchestratorClient interface { ExecutePayment(ctx context.Context, in *orchestrationv2.ExecutePaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.ExecutePaymentResponse, error) + ExecuteBatchPayment(ctx context.Context, in *orchestrationv2.ExecuteBatchPaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.ExecuteBatchPaymentResponse, error) GetPayment(ctx context.Context, in *orchestrationv2.GetPaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.GetPaymentResponse, error) ListPayments(ctx context.Context, in *orchestrationv2.ListPaymentsRequest, opts ...grpc.CallOption) (*orchestrationv2.ListPaymentsResponse, error) } @@ -99,6 +101,12 @@ func (c *orchestratorClient) ExecutePayment(ctx context.Context, req *orchestrat return c.client.ExecutePayment(ctx, req) } +func (c *orchestratorClient) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) { + ctx, cancel := c.callContext(ctx) + defer cancel() + return c.client.ExecuteBatchPayment(ctx, req) +} + func (c *orchestratorClient) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() diff --git a/api/payments/orchestrator/client/fake.go b/api/payments/orchestrator/client/fake.go index 9edde998..6ade3672 100644 --- a/api/payments/orchestrator/client/fake.go +++ b/api/payments/orchestrator/client/fake.go @@ -8,10 +8,11 @@ import ( // Fake implements Client for tests. type Fake struct { - ExecutePaymentFn func(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) - GetPaymentFn func(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) - ListPaymentsFn func(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) - CloseFn func() error + ExecutePaymentFn func(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + ExecuteBatchPaymentFn func(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) + GetPaymentFn func(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) + ListPaymentsFn func(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) + CloseFn func() error } func (f *Fake) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { @@ -21,6 +22,13 @@ func (f *Fake) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecuteP return &orchestrationv2.ExecutePaymentResponse{}, nil } +func (f *Fake) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) { + if f.ExecuteBatchPaymentFn != nil { + return f.ExecuteBatchPaymentFn(ctx, req) + } + return &orchestrationv2.ExecuteBatchPaymentResponse{}, nil +} + func (f *Fake) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { if f.GetPaymentFn != nil { return f.GetPaymentFn(ctx, req) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go new file mode 100644 index 00000000..c97919e5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go @@ -0,0 +1,262 @@ +package psvc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "sort" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/opagg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" +) + +func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (resp *orchestrationv2.ExecuteBatchPaymentResponse, err error) { + logger := s.logger + orgRef := "" + if req != nil && req.GetMeta() != nil { + orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) + } + logger.Debug("Starting ExecuteBatchPayment", + zap.String("organization_ref", orgRef), + zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())), + zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if resp != nil { + fields = append(fields, zap.Int("payments_count", len(resp.GetPayments()))) + } + if err != nil { + logger.Warn("Failed to execute batch payment", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed ExecuteBatchPayment", fields...) + }(time.Now()) + + requestCtx, err := s.prepareBatchExecute(req) + if err != nil { + return nil, err + } + + resolved, err := s.quote.ResolveAll(ctx, s.quoteStore, qsnap.ResolveAllInput{ + OrganizationID: requestCtx.OrganizationID, + QuotationRef: requestCtx.QuotationRef, + }) + if err != nil { + return nil, remapResolveError(err) + } + if resolved == nil || len(resolved.Items) == 0 { + return nil, merrors.InvalidArgument("quotation has no executable items") + } + + aggItems := make([]opagg.Item, 0, len(resolved.Items)) + for _, item := range resolved.Items { + aggItems = append(aggItems, opagg.Item{ + IntentRef: item.IntentRef, + IntentSnapshot: item.IntentSnapshot, + QuoteSnapshot: item.QuoteSnapshot, + }) + } + aggOutput, err := s.aggregator.Aggregate(opagg.Input{Items: aggItems}) + if err != nil { + return nil, err + } + + payments := make([]*agg.Payment, 0, len(aggOutput.Groups)) + for _, group := range aggOutput.Groups { + payment, err := s.executeGroup(ctx, requestCtx, resolved.QuotationRef, group) + if err != nil { + return nil, err + } + payments = append(payments, payment) + } + + protoPayments, err := s.mapPayments(payments) + if err != nil { + return nil, err + } + return &orchestrationv2.ExecuteBatchPaymentResponse{Payments: protoPayments}, nil +} + +func (s *svc) prepareBatchExecute(req *orchestrationv2.ExecuteBatchPaymentRequest) (*reqval.Ctx, error) { + return s.validator.Validate(mapBatchExecuteReq(req)) +} + +func mapBatchExecuteReq(req *orchestrationv2.ExecuteBatchPaymentRequest) *reqval.Req { + if req == nil { + return nil + } + out := &reqval.Req{ + QuotationRef: req.GetQuotationRef(), + ClientPaymentRef: req.GetClientPaymentRef(), + } + meta := req.GetMeta() + if meta == nil { + return out + } + out.Meta = &reqval.Meta{OrganizationRef: meta.GetOrganizationRef()} + if meta.GetTrace() != nil { + out.Meta.Trace = &reqval.Trace{IdempotencyKey: meta.GetTrace().GetIdempotencyKey()} + } + return out +} + +func (s *svc) executeGroup(ctx context.Context, requestCtx *reqval.Ctx, quotationRef string, group opagg.Group) (*agg.Payment, error) { + normalizedIntentRefs := normalizeIntentRefs(group.IntentRefs) + if len(normalizedIntentRefs) == 0 { + return nil, merrors.InvalidArgument("aggregated group has no intent refs") + } + groupIdempotencyKey := deriveGroupIdempotencyKey(requestCtx.IdempotencyKey, normalizedIntentRefs) + intentRefJoined := strings.Join(normalizedIntentRefs, ",") + + fingerprint, err := s.idempotency.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: requestCtx.QuotationRef, + IntentRef: intentRefJoined, + ClientPaymentRef: requestCtx.ClientPaymentRef, + }) + if err != nil { + return nil, err + } + + existing, err := s.repository.GetByIdempotencyKey(ctx, requestCtx.OrganizationID, groupIdempotencyKey) + if err != nil && !errors.Is(err, prepo.ErrPaymentNotFound) && !errors.Is(err, merrors.ErrNoData) { + return nil, err + } + var payment *agg.Payment + if existing != nil { + existingFP, err := s.idempotency.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: existing.QuotationRef, + IntentRef: strings.TrimSpace(existing.IntentSnapshot.Ref), + ClientPaymentRef: existing.ClientPaymentRef, + }) + if err != nil { + return nil, err + } + if strings.TrimSpace(existingFP) != strings.TrimSpace(fingerprint) { + return nil, idem.ErrIdempotencyParamMismatch + } + payment = existing + } else { + payment, err = s.createGroupPayment(ctx, requestCtx, quotationRef, groupIdempotencyKey, intentRefJoined, fingerprint, group) + if err != nil { + return nil, remapIdempotencyError(err) + } + } + + payment, err = s.runRuntime(ctx, payment) + if err != nil { + return nil, err + } + return payment, nil +} + +func (s *svc) createGroupPayment( + ctx context.Context, + requestCtx *reqval.Ctx, + quotationRef string, + groupIdempotencyKey string, + intentRefJoined string, + expectedFingerprint string, + group opagg.Group, +) (*agg.Payment, error) { + intentSnapshot := group.IntentSnapshot + intentSnapshot.Ref = intentRefJoined + + graph, err := s.planner.Compile(xplan.Input{ + IntentSnapshot: intentSnapshot, + QuoteSnapshot: group.QuoteSnapshot, + }) + if err != nil { + return nil, err + } + + payment, err := s.aggregate.Create(agg.Input{ + OrganizationRef: requestCtx.OrganizationID, + IdempotencyKey: groupIdempotencyKey, + QuotationRef: quotationRef, + ClientPaymentRef: requestCtx.ClientPaymentRef, + IntentSnapshot: intentSnapshot, + QuoteSnapshot: group.QuoteSnapshot, + Steps: toStepShells(graph), + }) + if err != nil { + return nil, err + } + + if err := s.repository.Create(ctx, payment); err != nil { + if !errors.Is(err, prepo.ErrDuplicatePayment) { + return nil, err + } + existing, getErr := s.repository.GetByIdempotencyKey(ctx, requestCtx.OrganizationID, groupIdempotencyKey) + if getErr != nil { + return nil, getErr + } + if existing != nil { + existingFP, fpErr := s.idempotency.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: existing.QuotationRef, + IntentRef: strings.TrimSpace(existing.IntentSnapshot.Ref), + ClientPaymentRef: existing.ClientPaymentRef, + }) + if fpErr != nil { + return nil, fpErr + } + if strings.TrimSpace(existingFP) != strings.TrimSpace(expectedFingerprint) { + return nil, idem.ErrIdempotencyParamMismatch + } + return existing, nil + } + return nil, err + } + + if err := s.recordPaymentCreated(ctx, payment, graph); err != nil { + return nil, err + } + return payment, nil +} + +func deriveGroupIdempotencyKey(baseKey string, intentRefs []string) string { + normalized := normalizeIntentRefs(intentRefs) + h := sha256.New() + h.Write([]byte(strings.TrimSpace(baseKey))) + h.Write([]byte{0x1f}) + h.Write([]byte(strings.Join(normalized, ","))) + return hex.EncodeToString(h.Sum(nil)) +} + +func normalizeIntentRefs(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for i := range values { + token := strings.TrimSpace(values[i]) + if token == "" { + continue + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + out = append(out, token) + } + sort.Strings(out) + if len(out) == 0 { + return nil + } + return out +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go new file mode 100644 index 00000000..70abf825 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go @@ -0,0 +1,185 @@ +package psvc + +import ( + "context" + "testing" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/storage/model" + pm "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestExecuteBatchPayment_SameDestinationMerges(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + + quote := newExecutableBatchQuote(env.orgID, "quote-batch-merge", []string{"intent-a", "intent-b"}, buildLedgerRoute()) + env.quotes.Put(quote) + + resp, err := env.svc.ExecuteBatchPayment(context.Background(), &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: testMeta(env.orgID, "idem-batch-merge"), + QuotationRef: "quote-batch-merge", + ClientPaymentRef: "client-batch-merge", + }) + if err != nil { + t.Fatalf("ExecuteBatchPayment returned error: %v", err) + } + if resp == nil { + t.Fatal("expected response") + } + if got, want := len(resp.GetPayments()), 1; got != want { + t.Fatalf("expected %d payment(s) for same-destination merge, got=%d", want, got) + } + if got, want := resp.GetPayments()[0].GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got, want) + } +} + +func TestExecuteBatchPayment_DifferentDestinationsCreatesSeparate(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + + quote := newExecutableBatchQuoteDiffDest(env.orgID, "quote-batch-diff") + env.quotes.Put(quote) + + resp, err := env.svc.ExecuteBatchPayment(context.Background(), &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: testMeta(env.orgID, "idem-batch-diff"), + QuotationRef: "quote-batch-diff", + ClientPaymentRef: "client-batch-diff", + }) + if err != nil { + t.Fatalf("ExecuteBatchPayment returned error: %v", err) + } + if got, want := len(resp.GetPayments()), 2; got != want { + t.Fatalf("expected %d payments for different destinations, got=%d", want, got) + } + for i, p := range resp.GetPayments() { + if got, want := p.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("payments[%d] state mismatch: got=%s want=%s", i, got, want) + } + } +} + +func TestExecuteBatchPayment_IdempotentRetry(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + + quote := newExecutableBatchQuote(env.orgID, "quote-batch-idem", []string{"intent-a", "intent-b"}, buildLedgerRoute()) + env.quotes.Put(quote) + + req := &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: testMeta(env.orgID, "idem-batch-idem"), + QuotationRef: "quote-batch-idem", + ClientPaymentRef: "client-batch-idem", + } + resp1, err := env.svc.ExecuteBatchPayment(context.Background(), req) + if err != nil { + t.Fatalf("first ExecuteBatchPayment returned error: %v", err) + } + + resp2, err := env.svc.ExecuteBatchPayment(context.Background(), req) + if err != nil { + t.Fatalf("second ExecuteBatchPayment returned error: %v", err) + } + + if got, want := len(resp2.GetPayments()), len(resp1.GetPayments()); got != want { + t.Fatalf("expected same number of payments on retry: got=%d want=%d", got, want) + } + if got, want := resp2.GetPayments()[0].GetPaymentRef(), resp1.GetPayments()[0].GetPaymentRef(); got != want { + t.Fatalf("expected same payment_ref on retry: got=%q want=%q", got, want) + } +} + +func TestExecuteBatchPayment_EmptyQuotationRefFails(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + + _, err := env.svc.ExecuteBatchPayment(context.Background(), &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: testMeta(env.orgID, "idem-empty"), + QuotationRef: "", + }) + if err == nil { + t.Fatal("expected error for empty quotation_ref") + } +} + +func TestExecuteBatchPayment_QuoteNotFoundFails(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + + _, err := env.svc.ExecuteBatchPayment(context.Background(), &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: testMeta(env.orgID, "idem-notfound"), + QuotationRef: "nonexistent-quote", + }) + if err == nil { + t.Fatal("expected error for non-existent quote") + } +} + +func newExecutableBatchQuoteDiffDest(orgRef bson.ObjectID, quoteRef string) *model.PaymentQuoteRecord { + now := time.Now().UTC() + route := buildLedgerRoute() + return &model.PaymentQuoteRecord{ + Base: modelBase(now), + OrganizationBoundBase: pm.OrganizationBoundBase{ + OrganizationRef: orgRef, + }, + QuoteRef: quoteRef, + RequestShape: model.QuoteRequestShapeBatch, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{ + Ref: "intent-a", + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: testLedgerEndpoint("ledger-dst-1"), + Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + SettlementCurrency: "USD", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + Route: route, + }, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{ + Ref: "intent-b", + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: testLedgerEndpoint("ledger-dst-2"), + Amount: &paymenttypes.Money{Amount: "15", Currency: "USD"}, + SettlementCurrency: "USD", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "15", Currency: "USD"}, + Route: route, + }, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + ExpiresAt: now.Add(1 * time.Hour), + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go index d3ce501a..f7e9f493 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/opagg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" @@ -24,6 +25,7 @@ import ( // Service orchestrates execute/query/reconcile payment runtime operations. type Service interface { ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) @@ -51,6 +53,7 @@ type Dependencies struct { Validator reqval.Validator Idempotency idem.Service Quote qsnap.Resolver + Aggregator opagg.Aggregator Aggregate agg.Factory Planner xplan.Compiler State ostate.StateMachine diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go index c6e46403..02c4f68e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/opagg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" @@ -34,6 +35,7 @@ type svc struct { validator reqval.Validator idempotency idem.Service quote qsnap.Resolver + aggregator opagg.Aggregator aggregate agg.Factory planner xplan.Compiler state ostate.StateMachine @@ -93,6 +95,7 @@ func newService(deps Dependencies) (Service, error) { validator: firstValidator(deps.Validator, logger), idempotency: firstIdempotency(deps.Idempotency, logger), quote: firstQuoteResolver(deps.Quote, logger), + aggregator: firstAggregator(deps.Aggregator, logger), aggregate: firstAggregateFactory(deps.Aggregate, logger), planner: firstPlanCompiler(deps.Planner, logger), state: firstStateMachine(deps.State, logger), @@ -148,6 +151,13 @@ func firstAggregateFactory(v agg.Factory, logger mlogger.Logger) agg.Factory { return agg.New(agg.Dependencies{Logger: logger}) } +func firstAggregator(v opagg.Aggregator, logger mlogger.Logger) opagg.Aggregator { + if v != nil { + return v + } + return opagg.New(opagg.Dependencies{Logger: logger}) +} + func firstPlanCompiler(v xplan.Compiler, logger mlogger.Logger) xplan.Compiler { if v != nil { return v diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index 1bf26c96..a3d65a3e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -18,6 +18,7 @@ type Store interface { // Resolver resolves a quotation reference into canonical execution snapshots. type Resolver interface { Resolve(ctx context.Context, store Store, in Input) (*Output, error) + ResolveAll(ctx context.Context, store Store, in ResolveAllInput) (*ResolveAllOutput, error) } // Input defines lookup scope for quotation resolution. @@ -35,6 +36,25 @@ type Output struct { QuoteSnapshot *model.PaymentQuoteSnapshot } +// ResolveAllInput defines lookup scope for resolving all items in a batch quotation. +type ResolveAllInput struct { + OrganizationID bson.ObjectID + QuotationRef string +} + +// ResolveAllOutput contains all resolved items from a batch quotation. +type ResolveAllOutput struct { + QuotationRef string + Items []ResolvedItem +} + +// ResolvedItem is one resolved intent-quote pair from a batch quotation. +type ResolvedItem struct { + IntentRef string + IntentSnapshot model.PaymentIntent + QuoteSnapshot *model.PaymentQuoteSnapshot +} + // Dependencies configures quote resolver integrations. type Dependencies struct { Logger mlogger.Logger diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_all_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_all_test.go new file mode 100644 index 00000000..4341a793 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_all_test.go @@ -0,0 +1,246 @@ +package qsnap + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func TestResolveAll_BatchReturnsAllItems(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + orgID := bson.NewObjectID() + + record := &model.PaymentQuoteRecord{ + QuoteRef: "batch-quote-ref", + RequestShape: model.QuoteRequestShapeBatch, + ExpiresAt: now.Add(time.Minute), + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-a", Kind: model.PaymentKindPayout, Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"}}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "100", Currency: "USDT"}}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{Ref: "intent-b", Kind: model.PaymentKindPayout, Amount: &paymenttypes.Money{Amount: "200", Currency: "USDT"}}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "batch-quote-ref", DebitAmount: &paymenttypes.Money{Amount: "200", Currency: "USDT"}}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + } + + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) + + out, err := resolver.ResolveAll(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return record, nil + }, + }, ResolveAllInput{ + OrganizationID: orgID, + QuotationRef: "batch-quote-ref", + }) + if err != nil { + t.Fatalf("ResolveAll returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if got, want := out.QuotationRef, "batch-quote-ref"; got != want { + t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want) + } + if got, want := len(out.Items), 2; got != want { + t.Fatalf("items count mismatch: got=%d want=%d", got, want) + } + if got, want := out.Items[0].IntentRef, "intent-a"; got != want { + t.Fatalf("items[0].intent_ref mismatch: got=%q want=%q", got, want) + } + if got, want := out.Items[1].IntentRef, "intent-b"; got != want { + t.Fatalf("items[1].intent_ref mismatch: got=%q want=%q", got, want) + } + if got, want := out.Items[0].QuoteSnapshot.DebitAmount.Amount, "100"; got != want { + t.Fatalf("items[0].quote debit amount mismatch: got=%q want=%q", got, want) + } + if got, want := out.Items[1].QuoteSnapshot.DebitAmount.Amount, "200"; got != want { + t.Fatalf("items[1].quote debit amount mismatch: got=%q want=%q", got, want) + } +} + +func TestResolveAll_SingleShapeReturnsOneItem(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + orgID := bson.NewObjectID() + + record := &model.PaymentQuoteRecord{ + QuoteRef: "single-quote-ref", + RequestShape: model.QuoteRequestShapeSingle, + ExpiresAt: now.Add(time.Minute), + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout, Amount: &paymenttypes.Money{Amount: "50", Currency: "USD"}}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "single-quote-ref"}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + } + + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) + + out, err := resolver.ResolveAll(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return record, nil + }, + }, ResolveAllInput{ + OrganizationID: orgID, + QuotationRef: "single-quote-ref", + }) + if err != nil { + t.Fatalf("ResolveAll returned error: %v", err) + } + if got, want := len(out.Items), 1; got != want { + t.Fatalf("items count mismatch: got=%d want=%d", got, want) + } + if got, want := out.Items[0].IntentRef, "intent-1"; got != want { + t.Fatalf("items[0].intent_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestResolveAll_NonExecutableItemFails(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + orgID := bson.NewObjectID() + + record := &model.PaymentQuoteRecord{ + QuoteRef: "batch-mixed", + RequestShape: model.QuoteRequestShapeBatch, + ExpiresAt: now.Add(time.Minute), + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-ok", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{Ref: "intent-blocked", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateBlocked, BlockReason: model.QuoteBlockReasonInsufficientLiquidity}, + }, + }, + } + + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) + + _, err := resolver.ResolveAll(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return record, nil + }, + }, ResolveAllInput{ + OrganizationID: orgID, + QuotationRef: "batch-mixed", + }) + if err == nil { + t.Fatal("expected error for non-executable item") + } + if !errors.Is(err, ErrQuoteNotExecutable) { + t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) + } +} + +func TestResolveAll_ExpiredQuoteFails(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + orgID := bson.NewObjectID() + + record := &model.PaymentQuoteRecord{ + QuoteRef: "expired-quote", + RequestShape: model.QuoteRequestShapeBatch, + ExpiresAt: now.Add(-time.Minute), + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + } + + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) + + _, err := resolver.ResolveAll(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return record, nil + }, + }, ResolveAllInput{ + OrganizationID: orgID, + QuotationRef: "expired-quote", + }) + if err == nil { + t.Fatal("expected error for expired quote") + } + if !errors.Is(err, ErrQuoteExpired) { + t.Fatalf("expected ErrQuoteExpired, got %v", err) + } +} + +func TestResolveAll_EmptyQuotationRefFails(t *testing.T) { + resolver := New(Dependencies{Logger: zap.NewNop()}) + + _, err := resolver.ResolveAll(context.Background(), &fakeStore{}, ResolveAllInput{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "", + }) + if err == nil { + t.Fatal("expected error for empty quotation_ref") + } +} + +func TestResolveAll_QuoteNotFoundFails(t *testing.T) { + resolver := New(Dependencies{Logger: zap.NewNop()}) + + _, err := resolver.ResolveAll(context.Background(), &fakeStore{}, ResolveAllInput{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "nonexistent", + }) + if err == nil { + t.Fatal("expected error for not-found quote") + } + if !errors.Is(err, ErrQuoteNotFound) { + t.Fatalf("expected ErrQuoteNotFound, got %v", err) + } +} + +func TestResolveAll_SetsQuoteRefWhenEmpty(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + orgID := bson.NewObjectID() + + record := &model.PaymentQuoteRecord{ + QuoteRef: "batch-ref", + RequestShape: model.QuoteRequestShapeBatch, + ExpiresAt: now.Add(time.Minute), + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout}, + Quote: &model.PaymentQuoteSnapshot{QuoteRef: ""}, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + } + + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) + + out, err := resolver.ResolveAll(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return record, nil + }, + }, ResolveAllInput{ + OrganizationID: orgID, + QuotationRef: "batch-ref", + }) + if err != nil { + t.Fatalf("ResolveAll returned error: %v", err) + } + if got, want := out.Items[0].QuoteSnapshot.QuoteRef, "batch-ref"; got != want { + t.Fatalf("quote_ref should be back-filled: got=%q want=%q", got, want) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go index 6adbf5bc..1b9413b1 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go @@ -102,6 +102,91 @@ func (s *svc) Resolve( return out, nil } +func (s *svc) ResolveAll( + ctx context.Context, + store Store, + in ResolveAllInput, +) (out *ResolveAllOutput, err error) { + logger := s.logger + logger.Debug("Starting ResolveAll", + zap.String("organization_ref", in.OrganizationID.Hex()), + zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.String("quotation_ref", strings.TrimSpace(out.QuotationRef)), + zap.Int("items_count", len(out.Items)), + ) + } + if err != nil { + logger.Warn("Failed to resolve all", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed ResolveAll", fields...) + }(time.Now()) + + if store == nil { + return nil, merrors.InvalidArgument("quotes store is required") + } + if in.OrganizationID.IsZero() { + return nil, merrors.InvalidArgument("organization_id is required") + } + quoteRef := strings.TrimSpace(in.QuotationRef) + if quoteRef == "" { + return nil, merrors.InvalidArgument("quotation_ref is required") + } + + record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef) + if err != nil { + if errors.Is(err, quotestorage.ErrQuoteNotFound) || errors.Is(err, merrors.ErrNoData) { + return nil, ErrQuoteNotFound + } + return nil, err + } + if record == nil { + return nil, ErrQuoteNotFound + } + if len(record.Items) == 0 { + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "items are empty") + } + + outputRef := strings.TrimSpace(record.QuoteRef) + if outputRef == "" { + outputRef = quoteRef + } + + items := make([]ResolvedItem, 0, len(record.Items)) + for i, item := range record.Items { + if item == nil { + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "items[%d] is nil", i) + } + if err := ensureExecutable(record, item.Status, s.now().UTC()); err != nil { + return nil, err + } + resolved, err := resolveItem(item, "") + if err != nil { + return nil, xerr.Wrapf(err, "items[%d]", i) + } + intentRef := strings.TrimSpace(resolved.Intent.Ref) + if resolved.Quote != nil && strings.TrimSpace(resolved.Quote.QuoteRef) == "" { + resolved.Quote.QuoteRef = outputRef + } + items = append(items, ResolvedItem{ + IntentRef: intentRef, + IntentSnapshot: resolved.Intent, + QuoteSnapshot: resolved.Quote, + }) + } + + out = &ResolveAllOutput{ + QuotationRef: outputRef, + Items: items, + } + return out, nil +} + func ensureExecutable( record *model.PaymentQuoteRecord, status *model.QuoteStatusV2, diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go index eca5fb22..f3902af0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go @@ -247,6 +247,10 @@ func (f *fakeExternalRuntimeV2) ReconcileExternal(_ context.Context, in psvc.Rec }, nil } +func (f *fakeExternalRuntimeV2) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) { + return nil, errors.New("not implemented") +} + func TestMapTransferStatus(t *testing.T) { cases := []struct { status chainv1.TransferStatus diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go index 0d3ffdb2..336bdd52 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go @@ -46,6 +46,10 @@ func (fakeOrchestrationV2Service) ReconcileExternal(context.Context, psvc.Reconc return &psvc.ReconcileExternalOutput{}, nil } +func (fakeOrchestrationV2Service) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) { + return &orchestrationv2.ExecuteBatchPaymentResponse{}, nil +} + type grpcCaptureRouterV2 struct { server *grpc.Server done chan error diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go index 53c1df88..b633abd9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -168,6 +168,14 @@ func (s *v2GRPCServer) ExecutePayment(ctx context.Context, req *orchestrationv2. return resp, nil } +func (s *v2GRPCServer) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) { + resp, err := s.svc.ExecuteBatchPayment(ctx, req) + if err != nil { + return gsresponse.Execute(ctx, gsresponse.Auto[orchestrationv2.ExecuteBatchPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)) + } + return resp, nil +} + func (s *v2GRPCServer) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { resp, err := s.svc.GetPayment(ctx, req) if err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go index cac2220d..7002542a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go @@ -49,3 +49,7 @@ func (fakeV2Service) ListPayments(context.Context, *orchestrationv2.ListPayments func (fakeV2Service) ReconcileExternal(context.Context, psvc.ReconcileExternalInput) (*psvc.ReconcileExternalOutput, error) { return &psvc.ReconcileExternalOutput{}, nil } + +func (fakeV2Service) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) { + return &orchestrationv2.ExecuteBatchPaymentResponse{}, nil +} diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go index b98d9b44..9a3cbc69 100644 --- a/api/server/internal/server/paymentapiimp/paybatch.go +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -1,8 +1,6 @@ package paymentapiimp import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "net/http" "strings" @@ -18,11 +16,6 @@ import ( "go.uber.org/zap" ) -const ( - fanoutIdempotencyHashLen = 16 - maxExecuteIdempotencyKey = 256 -) - func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { orgRef, err := a.oph.GetRef(r) if err != nil { @@ -68,18 +61,30 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc return resp.GetPayment(), nil } + executeBatch := func(idempotencyKey string) ([]*orchestrationv2.Payment, error) { + req := &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: requestMeta(orgRef.Hex(), idempotencyKey), + QuotationRef: quotationRef, + ClientPaymentRef: clientPaymentRef, + } + resp, executeErr := a.execution.ExecuteBatchPayment(ctx, req) + if executeErr != nil { + return nil, executeErr + } + if resp == nil { + return nil, nil + } + return resp.GetPayments(), nil + } + payments := make([]*orchestrationv2.Payment, 0, max(1, len(intentSelectors))) if len(payload.IntentRefs) > 0 { - for _, intentRef := range payload.IntentRefs { - payment, executeErr := executeOne(deriveFanoutIdempotencyKey(baseIdempotencyKey, intentRef), intentRef) - if executeErr != nil { - a.logger.Warn("Failed to initiate batch payments", zap.Error(executeErr), zap.String("organization_ref", orgRef.Hex())) - return grpcErrorResponse(a.logger, a.Name(), executeErr) - } - if payment != nil { - payments = append(payments, payment) - } + executed, executeErr := executeBatch(baseIdempotencyKey) + if executeErr != nil { + a.logger.Warn("Failed to initiate batch payments", zap.Error(executeErr), zap.String("organization_ref", orgRef.Hex())) + return grpcErrorResponse(a.logger, a.Name(), executeErr) } + payments = append(payments, executed...) return sresponse.PaymentsResponse(a.logger, payments, token) } @@ -118,28 +123,6 @@ func resolveExecutionIntentSelectors(payload *srequest.InitiatePayments, allowLe return nil, merrors.InvalidArgument("metadata.intent_ref is no longer supported; use intentRef or intentRefs", "metadata.intent_ref") } -func deriveFanoutIdempotencyKey(baseIdempotencyKey, intentRef string) string { - baseIdempotencyKey = strings.TrimSpace(baseIdempotencyKey) - intentRef = strings.TrimSpace(intentRef) - if baseIdempotencyKey == "" || intentRef == "" { - return baseIdempotencyKey - } - sum := sha256.Sum256([]byte(intentRef)) - hash := hex.EncodeToString(sum[:]) - if len(hash) > fanoutIdempotencyHashLen { - hash = hash[:fanoutIdempotencyHashLen] - } - suffix := ":i:" + hash - if len(baseIdempotencyKey)+len(suffix) <= maxExecuteIdempotencyKey { - return baseIdempotencyKey + suffix - } - if len(suffix) >= maxExecuteIdempotencyKey { - return suffix[:maxExecuteIdempotencyKey] - } - prefixLen := maxExecuteIdempotencyKey - len(suffix) - return baseIdempotencyKey[:prefixLen] + suffix -} - func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) { defer r.Body.Close() diff --git a/api/server/internal/server/paymentapiimp/paybatch_test.go b/api/server/internal/server/paymentapiimp/paybatch_test.go index bfdadfa2..d21ba552 100644 --- a/api/server/internal/server/paymentapiimp/paybatch_test.go +++ b/api/server/internal/server/paymentapiimp/paybatch_test.go @@ -6,7 +6,6 @@ import ( "errors" "net/http" "net/http/httptest" - "strings" "testing" "time" @@ -24,7 +23,7 @@ import ( "go.uber.org/zap" ) -func TestInitiatePaymentsByQuote_FansOutByIntentRefs(t *testing.T) { +func TestInitiatePaymentsByQuote_PassesIntentRefsInSingleExecuteCall(t *testing.T) { orgRef := bson.NewObjectID() exec := &fakeExecutionClientForBatch{} api := newBatchAPI(exec) @@ -35,20 +34,17 @@ func TestInitiatePaymentsByQuote_FansOutByIntentRefs(t *testing.T) { t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) } - if got, want := len(exec.executeReqs), 2; got != want { - t.Fatalf("execute calls mismatch: got=%d want=%d", got, want) + if got, want := len(exec.executeBatchReqs), 1; got != want { + t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want) } - if got, want := exec.executeReqs[0].GetIntentRef(), "intent-a"; got != want { - t.Fatalf("intent_ref[0] mismatch: got=%q want=%q", got, want) + if got := len(exec.executeReqs); got != 0 { + t.Fatalf("expected no execute calls, got=%d", got) } - if got, want := exec.executeReqs[1].GetIntentRef(), "intent-b"; got != want { - t.Fatalf("intent_ref[1] mismatch: got=%q want=%q", got, want) + if got, want := exec.executeBatchReqs[0].GetQuotationRef(), "quote-1"; got != want { + t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want) } - if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), deriveFanoutIdempotencyKey("idem-batch", "intent-a"); got != want { - t.Fatalf("idempotency[0] mismatch: got=%q want=%q", got, want) - } - if got, want := exec.executeReqs[1].GetMeta().GetTrace().GetIdempotencyKey(), deriveFanoutIdempotencyKey("idem-batch", "intent-b"); got != want { - t.Fatalf("idempotency[1] mismatch: got=%q want=%q", got, want) + if got, want := exec.executeBatchReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-batch"; got != want { + t.Fatalf("idempotency mismatch: got=%q want=%q", got, want) } } @@ -125,28 +121,6 @@ func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefWhenDateGateExpir } } -func TestDeriveFanoutIdempotencyKey_IsDeterministicAndBounded(t *testing.T) { - a := deriveFanoutIdempotencyKey("idem-1", "intent-a") - b := deriveFanoutIdempotencyKey("idem-1", "intent-a") - if got, want := a, b; got != want { - t.Fatalf("determinism mismatch: got=%q want=%q", got, want) - } - if a == "idem-1" { - t.Fatalf("expected derived key to differ from base") - } - - c := deriveFanoutIdempotencyKey("idem-1", "intent-b") - if c == a { - t.Fatalf("expected different derived keys for different intents") - } - - longBase := strings.Repeat("a", 400) - long := deriveFanoutIdempotencyKey(longBase, "intent-a") - if got, want := len(long), maxExecuteIdempotencyKey; got != want { - t.Fatalf("length mismatch: got=%d want=%d", got, want) - } -} - func TestResolveExecutionIntentSelectors_PrefersExplicitSelectors(t *testing.T) { payload := &srequest.InitiatePayments{ IntentRefs: []string{"intent-a", "intent-b"}, @@ -229,7 +203,8 @@ func invokeInitiatePaymentsByQuote(t *testing.T, api *PaymentAPI, orgRef bson.Ob } type fakeExecutionClientForBatch struct { - executeReqs []*orchestrationv2.ExecutePaymentRequest + executeReqs []*orchestrationv2.ExecutePaymentRequest + executeBatchReqs []*orchestrationv2.ExecuteBatchPaymentRequest } func (f *fakeExecutionClientForBatch) ExecutePayment(_ context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { @@ -239,6 +214,13 @@ func (f *fakeExecutionClientForBatch) ExecutePayment(_ context.Context, req *orc }, nil } +func (f *fakeExecutionClientForBatch) ExecuteBatchPayment(_ context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) { + f.executeBatchReqs = append(f.executeBatchReqs, req) + return &orchestrationv2.ExecuteBatchPaymentResponse{ + Payments: []*orchestrationv2.Payment{{PaymentRef: bson.NewObjectID().Hex()}}, + }, nil +} + func (*fakeExecutionClientForBatch) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { return &orchestrationv2.ListPaymentsResponse{}, nil } diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index beb9ea42..ea2d2415 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -37,6 +37,7 @@ const ( type executionClient interface { ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) Close() error } -- 2.49.1 From e8d763eb156a52333a3fcc397448b4ebf1d35838 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 22:20:54 +0100 Subject: [PATCH 19/23] removed intent_ref from frontend --- .../service/orchestrationv2/psvc/execute.go | 16 ++- .../orchestrationv2/psvc/execute_batch.go | 4 +- .../orchestrationv2/psvc/service_e2e_test.go | 33 +++-- .../service/orchestrationv2/qsnap/errors.go | 11 +- .../service/orchestrationv2/qsnap/module.go | 2 - .../qsnap/resolve_errors_test.go | 1 - .../qsnap/resolve_shapes_test.go | 97 +------------- .../service/orchestrationv2/qsnap/service.go | 73 ++--------- .../service/orchestrationv2/reqval/module.go | 2 - .../orchestrationv2/reqval/validator.go | 9 -- .../orchestrationv2/reqval/validator_test.go | 30 ----- .../service/orchestrator/service_v2_test.go | 4 +- api/server/interface/api/srequest/payment.go | 30 +---- .../api/srequest/payment_validate_test.go | 76 +---------- .../internal/server/paymentapiimp/pay.go | 8 +- .../internal/server/paymentapiimp/pay_test.go | 68 ++++++++++ .../internal/server/paymentapiimp/paybatch.go | 87 ++----------- .../server/paymentapiimp/paybatch_test.go | 118 +++--------------- .../internal/server/paymentapiimp/service.go | 88 ------------- .../service_legacy_fallback_test.go | 82 ------------ .../requests/payment/initiate_payments.dart | 34 ++--- .../provider/payment/multiple/provider.dart | 4 - .../pshared/lib/service/payment/multiple.dart | 4 - .../test/payment/request_dto_format_test.dart | 27 +--- .../pweb/lib/providers/multiple_payouts.dart | 16 --- 25 files changed, 174 insertions(+), 750 deletions(-) create mode 100644 api/server/internal/server/paymentapiimp/pay_test.go delete mode 100644 api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index f4d4b7e3..fdd35346 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -25,10 +25,9 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa if req != nil && req.GetMeta() != nil { orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) } - logger.Debug("Starting Execute payment", + logger.Debug("Starting payment execution", zap.String("organization_ref", orgRef), zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())), - zap.String("intent_ref", strings.TrimSpace(req.GetIntentRef())), zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""), ) defer func(start time.Time) { @@ -44,9 +43,13 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa logger.Warn("Failed to execute payment", append(fields, zap.Error(err))...) return } - logger.Debug("Completed Execute payment", fields...) + logger.Debug("Completed payment execution", fields...) }(time.Now()) + if req != nil && strings.TrimSpace(req.GetIntentRef()) != "" { + return nil, merrors.InvalidArgument("intent_ref is no longer supported for ExecutePayment") + } + requestCtx, fingerprint, err := s.prepareExecute(req) if err != nil { return nil, err @@ -104,7 +107,6 @@ func (s *svc) prepareExecute(req *orchestrationv2.ExecutePaymentRequest) (*reqva fingerprint, err := s.idempotency.Fingerprint(idem.FPInput{ OrganizationRef: requestCtx.OrganizationRef, QuotationRef: requestCtx.QuotationRef, - IntentRef: requestCtx.IntentRef, ClientPaymentRef: requestCtx.ClientPaymentRef, }) if err != nil { @@ -119,7 +121,6 @@ func mapExecuteReq(req *orchestrationv2.ExecutePaymentRequest) *reqval.Req { } out := &reqval.Req{ QuotationRef: req.GetQuotationRef(), - IntentRef: req.GetIntentRef(), ClientPaymentRef: req.GetClientPaymentRef(), } meta := req.GetMeta() @@ -148,7 +149,6 @@ func (s *svc) tryReuse(ctx context.Context, requestCtx *reqval.Ctx, requestFinge existingFingerprint, err := s.idempotency.Fingerprint(idem.FPInput{ OrganizationRef: requestCtx.OrganizationRef, QuotationRef: existing.QuotationRef, - IntentRef: existing.IntentSnapshot.Ref, ClientPaymentRef: existing.ClientPaymentRef, }) if err != nil { @@ -202,7 +202,6 @@ func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsna resolved, err := s.quote.Resolve(ctx, s.quoteStore, qsnap.Input{ OrganizationID: requestCtx.OrganizationID, QuotationRef: requestCtx.QuotationRef, - IntentRef: requestCtx.IntentRef, }) if err != nil { return nil, nil, remapResolveError(err) @@ -271,7 +270,7 @@ func remapIdempotencyError(err error) error { func remapResolveError(err error) error { switch { - case errors.Is(err, qsnap.ErrIntentRefRequired), errors.Is(err, qsnap.ErrIntentRefNotFound): + case errors.Is(err, qsnap.ErrBatchQuoteUnsupported): return merrors.InvalidArgument(err.Error()) default: return err @@ -285,7 +284,6 @@ func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string { value, err := idemSvc.Fingerprint(idem.FPInput{ OrganizationRef: requestCtx.OrganizationRef, QuotationRef: requestCtx.QuotationRef, - IntentRef: requestCtx.IntentRef, ClientPaymentRef: requestCtx.ClientPaymentRef, }) if err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go index c97919e5..22cc2e9d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go @@ -27,7 +27,7 @@ func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.Exec if req != nil && req.GetMeta() != nil { orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) } - logger.Debug("Starting ExecuteBatchPayment", + logger.Debug("Starting batch payment execution", zap.String("organization_ref", orgRef), zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())), zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""), @@ -41,7 +41,7 @@ func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.Exec logger.Warn("Failed to execute batch payment", append(fields, zap.Error(err))...) return } - logger.Debug("Completed ExecuteBatchPayment", fields...) + logger.Debug("Completed batch payment execution", fields...) }(time.Now()) requestCtx, err := s.prepareBatchExecute(req) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 1bbed458..64564630 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -41,7 +41,6 @@ func TestExecutePayment_EndToEndSyncSettled(t *testing.T) { Meta: testMeta(env.orgID, "idem-sync"), QuotationRef: "quote-sync", ClientPaymentRef: "client-1", - IntentRef: "intent-sync", }) if err != nil { t.Fatalf("ExecutePayment returned error: %v", err) @@ -89,7 +88,6 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) { Meta: testMeta(env.orgID, "idem-shared"), QuotationRef: "quote-idem", ClientPaymentRef: "client-a", - IntentRef: "intent-idem", }) if err != nil { t.Fatalf("first ExecutePayment returned error: %v", err) @@ -99,14 +97,35 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) { Meta: testMeta(env.orgID, "idem-shared"), QuotationRef: "quote-idem", ClientPaymentRef: "client-b", - IntentRef: "intent-idem", }) if !errors.Is(err, merrors.ErrInvalidArg) { t.Fatalf("expected invalid argument for mismatch, got %v", err) } } -func TestExecutePayment_BatchQuoteRequiresIntentRef(t *testing.T) { +func TestExecutePayment_RejectsDeprecatedIntentRef(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-sync", "intent-sync", buildLedgerRoute())) + + _, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-intent-ref"), + QuotationRef: "quote-sync", + ClientPaymentRef: "client-1", + IntentRef: "intent-sync", + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for deprecated intent_ref, got %v", err) + } + if got := err.Error(); !strings.Contains(got, "intent_ref is no longer supported for ExecutePayment") { + t.Fatalf("unexpected error message: %q", got) + } +} + +func TestExecutePayment_BatchQuoteRequiresBatchExecution(t *testing.T) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { step := req.StepExecution step.State = agg.StepStateCompleted @@ -120,9 +139,9 @@ func TestExecutePayment_BatchQuoteRequiresIntentRef(t *testing.T) { ClientPaymentRef: "client-batch", }) if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument for missing intent_ref, got %v", err) + t.Fatalf("expected invalid argument for unsupported batch quote, got %v", err) } - if got := err.Error(); !strings.Contains(got, "intent_ref is required for batch quotation") { + if got := err.Error(); !strings.Contains(got, "batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment") { t.Fatalf("unexpected error message: %q", got) } } @@ -142,7 +161,6 @@ func TestExecutePayment_RetryThenSuccess(t *testing.T) { Meta: testMeta(env.orgID, "idem-retry"), QuotationRef: "quote-retry", ClientPaymentRef: "client-retry", - IntentRef: "intent-retry", }) if err != nil { t.Fatalf("ExecutePayment returned error: %v", err) @@ -186,7 +204,6 @@ func TestReconcileExternal_AdvancesAsyncPaymentToSettled(t *testing.T) { Meta: testMeta(env.orgID, "idem-async"), QuotationRef: "quote-async", ClientPaymentRef: "client-async", - IntentRef: "intent-async", }) if err != nil { t.Fatalf("ExecutePayment returned error: %v", err) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go index e451e1ac..0bf10199 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go @@ -3,10 +3,9 @@ package qsnap import "errors" var ( - ErrQuoteNotFound = errors.New("quotation_ref not found") - ErrQuoteExpired = errors.New("quotation_ref expired") - ErrQuoteNotExecutable = errors.New("quotation_ref is not executable") - ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch") - ErrIntentRefRequired = errors.New("intent_ref is required for batch quotation") - ErrIntentRefNotFound = errors.New("intent_ref not found in quotation") + ErrQuoteNotFound = errors.New("quotation_ref not found") + ErrQuoteExpired = errors.New("quotation_ref expired") + ErrQuoteNotExecutable = errors.New("quotation_ref is not executable") + ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch") + ErrBatchQuoteUnsupported = errors.New("batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment") ) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index a3d65a3e..4ed3886a 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -25,13 +25,11 @@ type Resolver interface { type Input struct { OrganizationID bson.ObjectID QuotationRef string - IntentRef string } // Output contains extracted canonical snapshots for execution. type Output struct { QuotationRef string - IntentRef string IntentSnapshot model.PaymentIntent QuoteSnapshot *model.PaymentQuoteSnapshot } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go index 4f03aff1..a868ac93 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go @@ -142,7 +142,6 @@ func TestResolve_ShapeMismatch(t *testing.T) { }, Input{ OrganizationID: bson.NewObjectID(), QuotationRef: "quote-ref", - IntentRef: "intent-1", }) if !errors.Is(err, ErrQuoteShapeMismatch) { t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go index a6b65fcb..0512fd96 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go @@ -45,7 +45,6 @@ func TestResolve_SingleShapeOK(t *testing.T) { }, Input{ OrganizationID: orgID, QuotationRef: "stored-quote-ref", - IntentRef: "intent-1", }) if err != nil { t.Fatalf("Resolve returned error: %v", err) @@ -59,9 +58,6 @@ func TestResolve_SingleShapeOK(t *testing.T) { if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want { t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want) } - if got, want := out.IntentRef, "intent-1"; got != want { - t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) - } if out.QuoteSnapshot == nil { t.Fatal("expected quote snapshot") } @@ -108,9 +104,6 @@ func TestResolve_ArrayShapeOK(t *testing.T) { if out == nil { t.Fatal("expected output") } - if got, want := out.IntentRef, "intent-1"; got != want { - t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) - } if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want { t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want) } @@ -122,7 +115,7 @@ func TestResolve_ArrayShapeOK(t *testing.T) { } } -func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) { +func TestResolve_MultiShapeRejectedForSingleExecution(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) orgID := bson.NewObjectID() @@ -153,91 +146,11 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) { }, Input{ OrganizationID: orgID, QuotationRef: "batch-quote-ref", - IntentRef: "intent-b", }) - if err != nil { - t.Fatalf("Resolve returned error: %v", err) + if out != nil { + t.Fatalf("expected nil output, got %#v", out) } - if out == nil { - t.Fatal("expected output") - } - if got, want := out.IntentSnapshot.Ref, "intent-b"; got != want { - t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want) - } - if got, want := out.IntentRef, "intent-b"; got != want { - t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) - } - if out.QuoteSnapshot == nil || out.QuoteSnapshot.DebitAmount == nil { - t.Fatal("expected quote snapshot with debit amount") - } - if got, want := out.QuoteSnapshot.DebitAmount.Amount, "15"; got != want { - t.Fatalf("selected quote mismatch: got=%q want=%q", got, want) - } -} - -func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - RequestShape: model.QuoteRequestShapeBatch, - Items: []*model.PaymentQuoteItemV2{ - { - Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout}, - Quote: &model.PaymentQuoteSnapshot{}, - Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, - }, - { - Intent: &model.PaymentIntent{Ref: "intent-2", Kind: model.PaymentKindPayout}, - Quote: &model.PaymentQuoteSnapshot{}, - Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, - }, - }, - ExpiresAt: now.Add(time.Minute), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrIntentRefRequired) { - t.Fatalf("expected ErrIntentRefRequired, got %v", err) - } -} - -func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - RequestShape: model.QuoteRequestShapeBatch, - Items: []*model.PaymentQuoteItemV2{ - { - Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout}, - Quote: &model.PaymentQuoteSnapshot{}, - Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, - }, - { - Intent: &model.PaymentIntent{Ref: "intent-2", Kind: model.PaymentKindPayout}, - Quote: &model.PaymentQuoteSnapshot{}, - Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, - }, - }, - ExpiresAt: now.Add(time.Minute), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - IntentRef: "intent-3", - }) - if !errors.Is(err, ErrIntentRefNotFound) { - t.Fatalf("expected ErrIntentRefNotFound, got %v", err) + if !errors.Is(err, ErrBatchQuoteUnsupported) { + t.Fatalf("expected ErrBatchQuoteUnsupported, got %v", err) } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go index 1b9413b1..6fc89177 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go @@ -35,14 +35,12 @@ func (s *svc) Resolve( logger.Debug("Starting Resolve", zap.String("organization_ref", in.OrganizationID.Hex()), zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), - zap.String("intent_ref", strings.TrimSpace(in.IntentRef)), ) defer func(start time.Time) { fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} if out != nil { fields = append(fields, zap.String("quotation_ref", strings.TrimSpace(out.QuotationRef)), - zap.String("intent_ref", strings.TrimSpace(out.IntentRef)), ) } if err != nil { @@ -63,7 +61,6 @@ func (s *svc) Resolve( if quoteRef == "" { return nil, merrors.InvalidArgument("quotation_ref is required") } - intentRef := strings.TrimSpace(in.IntentRef) record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef) if err != nil { @@ -76,7 +73,7 @@ func (s *svc) Resolve( return nil, ErrQuoteNotFound } - item, err := resolveRecordItem(record, intentRef) + item, err := resolveRecordItem(record) if err != nil { return nil, err } @@ -95,7 +92,6 @@ func (s *svc) Resolve( out = &Output{ QuotationRef: outputRef, - IntentRef: firstNonEmpty(strings.TrimSpace(item.Intent.Ref), intentRef), IntentSnapshot: item.Intent, QuoteSnapshot: item.Quote, } @@ -165,7 +161,7 @@ func (s *svc) ResolveAll( if err := ensureExecutable(record, item.Status, s.now().UTC()); err != nil { return nil, err } - resolved, err := resolveItem(item, "") + resolved, err := resolveItem(item) if err != nil { return nil, xerr.Wrapf(err, "items[%d]", i) } @@ -225,7 +221,7 @@ func ensureExecutable( } } -func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { +func resolveRecordItem(record *model.PaymentQuoteRecord) (*resolvedQuoteItem, error) { if record == nil { return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil") } @@ -238,42 +234,18 @@ func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*res if len(record.Items) != 1 { return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "single shape requires exactly one item") } - return resolveItem(record.Items[0], intentRef) + return resolveItem(record.Items[0]) case model.QuoteRequestShapeBatch: - index, err := resolveBatchItemIndex(record.Items, intentRef) - if err != nil { - return nil, err + if len(record.Items) != 1 { + return nil, ErrBatchQuoteUnsupported } - return resolveItem(record.Items[index], intentRef) + return resolveItem(record.Items[0]) default: return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "request shape is invalid") } } -func resolveBatchItemIndex(items []*model.PaymentQuoteItemV2, intentRef string) (int, error) { - if len(items) == 1 { - if intentRef == "" { - return 0, nil - } - item := items[0] - if item == nil || item.Intent == nil || strings.TrimSpace(item.Intent.Ref) != intentRef { - return -1, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) - } - return 0, nil - } - - if intentRef == "" { - return -1, ErrIntentRefRequired - } - - index, found := findItemIndex(items, intentRef) - if !found { - return -1, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) - } - return index, nil -} - -func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuoteItem, error) { +func resolveItem(item *model.PaymentQuoteItemV2) (*resolvedQuoteItem, error) { if item == nil { return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "item is nil") } @@ -286,9 +258,6 @@ func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuo if item.Status == nil { return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil") } - if intentRef != "" && strings.TrimSpace(item.Intent.Ref) != intentRef { - return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) - } intentSnapshot, err := cloneIntentSnapshot(*item.Intent) if err != nil { @@ -310,32 +279,6 @@ func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuo }, nil } -func findItemIndex(items []*model.PaymentQuoteItemV2, targetRef string) (int, bool) { - target := strings.TrimSpace(targetRef) - if target == "" { - return -1, false - } - for idx := range items { - item := items[idx] - if item == nil || item.Intent == nil { - continue - } - if strings.TrimSpace(item.Intent.Ref) == target { - return idx, true - } - } - return -1, false -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) { var dst model.PaymentIntent if err := bsonClone(src, &dst); err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go index 242b2d7f..faf40b5d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go @@ -16,7 +16,6 @@ type Validator interface { type Req struct { Meta *Meta QuotationRef string - IntentRef string ClientPaymentRef string } @@ -37,7 +36,6 @@ type Ctx struct { OrganizationID bson.ObjectID IdempotencyKey string QuotationRef string - IntentRef string ClientPaymentRef string } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go index cf323994..b0693849 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go @@ -14,7 +14,6 @@ import ( const ( maxIdempotencyKeyLen = 256 maxQuotationRefLen = 128 - maxIntentRefLen = 128 maxClientRefLen = 128 ) @@ -33,7 +32,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) { logger.Debug("Starting Validate", zap.String("organization_ref", orgRefIn), zap.String("quotation_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.QuotationRef }))), - zap.String("intent_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.IntentRef }))), zap.Bool("has_idempotency_key", strings.TrimSpace(traceKey(req)) != ""), ) defer func(start time.Time) { @@ -42,7 +40,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) { fields = append(fields, zap.String("organization_ref", out.OrganizationRef), zap.String("quotation_ref", out.QuotationRef), - zap.String("intent_ref", out.IntentRef), zap.Bool("has_client_payment_ref", out.ClientPaymentRef != ""), ) } @@ -83,11 +80,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) { return nil, err } - intentRef, err := validateRefToken("intent_ref", req.IntentRef, maxIntentRefLen, false) - if err != nil { - return nil, err - } - clientPaymentRef, err := validateRefToken("client_payment_ref", req.ClientPaymentRef, maxClientRefLen, false) if err != nil { return nil, err @@ -98,7 +90,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) { OrganizationID: orgID, IdempotencyKey: idempotencyKey, QuotationRef: quotationRef, - IntentRef: intentRef, ClientPaymentRef: clientPaymentRef, } return out, nil diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go index b5676074..f6d27a43 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go @@ -19,7 +19,6 @@ func TestValidate_OK(t *testing.T) { }, }, QuotationRef: " quote-ref-1 ", - IntentRef: " intent-ref-1 ", ClientPaymentRef: " client.ref-1 ", }) if err != nil { @@ -40,9 +39,6 @@ func TestValidate_OK(t *testing.T) { if got, want := ctx.QuotationRef, "quote-ref-1"; got != want { t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want) } - if got, want := ctx.IntentRef, "intent-ref-1"; got != want { - t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) - } if got, want := ctx.ClientPaymentRef, "client.ref-1"; got != want { t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want) } @@ -70,16 +66,12 @@ func TestValidate_ClientPaymentRefOptional(t *testing.T) { if ctx.ClientPaymentRef != "" { t.Fatalf("expected empty client_payment_ref, got %q", ctx.ClientPaymentRef) } - if ctx.IntentRef != "" { - t.Fatalf("expected empty intent_ref, got %q", ctx.IntentRef) - } } func TestValidate_Errors(t *testing.T) { orgID := bson.NewObjectID().Hex() tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen) tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen) - tooLongIntent := "i" + strings.Repeat("a", maxIntentRefLen) tooLongClient := "c" + strings.Repeat("a", maxClientRefLen) tests := []struct { @@ -193,28 +185,6 @@ func TestValidate_Errors(t *testing.T) { ClientPaymentRef: tooLongClient, }, }, - { - name: "too long intent ref", - req: &Req{ - Meta: &Meta{ - OrganizationRef: orgID, - Trace: &Trace{IdempotencyKey: "idem-1"}, - }, - QuotationRef: "quote-1", - IntentRef: tooLongIntent, - }, - }, - { - name: "bad intent ref shape", - req: &Req{ - Meta: &Meta{ - OrganizationRef: orgID, - Trace: &Trace{IdempotencyKey: "idem-1"}, - }, - QuotationRef: "quote-1", - IntentRef: "intent ref", - }, - }, { name: "bad client payment ref shape", req: &Req{ diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go index 7002542a..3739ac6a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go @@ -15,7 +15,7 @@ import ( func TestV2GRPCServerExecutePayment_MapsInvalidArgument(t *testing.T) { srv := newV2GRPCServer(fakeV2Service{ - executeErr: merrors.InvalidArgument("intent_ref is required for batch quotation"), + executeErr: merrors.InvalidArgument("batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment"), }, zap.NewNop()) _, err := srv.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{}) @@ -25,7 +25,7 @@ func TestV2GRPCServerExecutePayment_MapsInvalidArgument(t *testing.T) { if got, want := status.Code(err), codes.InvalidArgument; got != want { t.Fatalf("unexpected grpc status code: got=%s want=%s", got, want) } - if got := status.Convert(err).Message(); !strings.Contains(got, "intent_ref is required for batch quotation") { + if got := status.Convert(err).Message(); !strings.Contains(got, "batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment") { t.Fatalf("unexpected grpc status message: %q", got) } } diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index d8e2ae5f..b7c51c8b 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -107,9 +107,7 @@ func (r InitiatePayment) Validate() error { type InitiatePayments struct { PaymentBase `json:",inline"` - QuoteRef string `json:"quoteRef,omitempty"` - IntentRef string `json:"intentRef,omitempty"` - IntentRefs []string `json:"intentRefs,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` } func (r *InitiatePayments) Validate() error { @@ -120,35 +118,9 @@ func (r *InitiatePayments) Validate() error { return err } r.QuoteRef = strings.TrimSpace(r.QuoteRef) - r.IntentRef = strings.TrimSpace(r.IntentRef) - hasIntentRefsField := r.IntentRefs != nil - - normalizedIntentRefs := make([]string, 0, len(r.IntentRefs)) - seen := make(map[string]struct{}, len(r.IntentRefs)) - for _, value := range r.IntentRefs { - intentRef := strings.TrimSpace(value) - if intentRef == "" { - return merrors.InvalidArgument("intentRefs must not contain empty values", "intentRefs") - } - if _, exists := seen[intentRef]; exists { - return merrors.InvalidArgument("intentRefs must contain unique values", "intentRefs") - } - seen[intentRef] = struct{}{} - normalizedIntentRefs = append(normalizedIntentRefs, intentRef) - } - if hasIntentRefsField && len(normalizedIntentRefs) == 0 { - return merrors.InvalidArgument("intentRefs must not be empty", "intentRefs") - } - r.IntentRefs = normalizedIntentRefs - if len(r.IntentRefs) == 0 { - r.IntentRefs = nil - } if r.QuoteRef == "" { return merrors.InvalidArgument("quoteRef is required", "quoteRef") } - if r.IntentRef != "" && len(r.IntentRefs) > 0 { - return merrors.DataConflict("intentRef and intentRefs are mutually exclusive") - } return nil } diff --git a/api/server/interface/api/srequest/payment_validate_test.go b/api/server/interface/api/srequest/payment_validate_test.go index 9517f127..52d569e2 100644 --- a/api/server/interface/api/srequest/payment_validate_test.go +++ b/api/server/interface/api/srequest/payment_validate_test.go @@ -28,29 +28,11 @@ func TestValidateQuoteIdempotency(t *testing.T) { }) } -func TestInitiatePaymentsValidateIntentSelectors(t *testing.T) { - t.Run("accepts explicit intentRef", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRef: " intent-a ", - } - if err := req.Validate(); err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got, want := req.IntentRef, "intent-a"; got != want { - t.Fatalf("intentRef mismatch: got=%q want=%q", got, want) - } - if req.IntentRefs != nil { - t.Fatalf("expected nil intentRefs, got %#v", req.IntentRefs) - } - }) - - t.Run("accepts explicit intentRefs", func(t *testing.T) { +func TestInitiatePaymentsValidate(t *testing.T) { + t.Run("accepts quoteRef", func(t *testing.T) { req := &InitiatePayments{ PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, QuoteRef: " quote-1 ", - IntentRefs: []string{" intent-a ", "intent-b"}, } if err := req.Validate(); err != nil { t.Fatalf("expected no error, got %v", err) @@ -58,66 +40,14 @@ func TestInitiatePaymentsValidateIntentSelectors(t *testing.T) { if got, want := req.QuoteRef, "quote-1"; got != want { t.Fatalf("quoteRef mismatch: got=%q want=%q", got, want) } - if got, want := len(req.IntentRefs), 2; got != want { - t.Fatalf("intentRefs length mismatch: got=%d want=%d", got, want) - } - if got, want := req.IntentRefs[0], "intent-a"; got != want { - t.Fatalf("intentRefs[0] mismatch: got=%q want=%q", got, want) - } }) - t.Run("rejects both intentRef and intentRefs", func(t *testing.T) { + t.Run("rejects missing quoteRef", func(t *testing.T) { req := &InitiatePayments{ PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRef: "intent-a", - IntentRefs: []string{"intent-b"}, } if err := req.Validate(); err == nil { t.Fatal("expected error") } }) - - t.Run("rejects empty intentRefs item", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRefs: []string{"intent-a", " "}, - } - if err := req.Validate(); err == nil { - t.Fatal("expected error") - } - }) - - t.Run("rejects empty intentRefs list", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRefs: []string{}, - } - if err := req.Validate(); err == nil { - t.Fatal("expected error") - } - }) - - t.Run("rejects duplicate intentRefs", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRefs: []string{"intent-a", " intent-a "}, - } - if err := req.Validate(); err == nil { - t.Fatal("expected error") - } - }) - - t.Run("accepts no selectors for backward compatibility", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - } - if err := req.Validate(); err != nil { - t.Fatalf("expected no error, got %v", err) - } - }) } diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go index 42e98468..f74ddb76 100644 --- a/api/server/internal/server/paymentapiimp/pay.go +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -61,7 +61,9 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to } quotationRef := strings.TrimSpace(payload.QuoteRef) - intentRef := metadataValue(payload.Metadata, "intent_ref") + if metadataValue(payload.Metadata, "intent_ref") != "" { + return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("metadata.intent_ref is no longer supported", "metadata.intent_ref")) + } if payload.Intent != nil { applyCustomerIP(payload.Intent, r.RemoteAddr) intent, err := mapQuoteIntent(payload.Intent) @@ -82,15 +84,11 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to if quotationRef == "" { return response.Auto(a.logger, a.Name(), merrors.DataConflict("quotation service returned empty quote_ref")) } - if derived := strings.TrimSpace(quoteResp.GetQuote().GetIntentRef()); derived != "" { - intentRef = derived - } } req := &orchestrationv2.ExecutePaymentRequest{ Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), QuotationRef: quotationRef, - IntentRef: intentRef, ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"), } diff --git a/api/server/internal/server/paymentapiimp/pay_test.go b/api/server/internal/server/paymentapiimp/pay_test.go new file mode 100644 index 00000000..7b5b7e0f --- /dev/null +++ b/api/server/internal/server/paymentapiimp/pay_test.go @@ -0,0 +1,68 @@ +package paymentapiimp + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/server/interface/api/sresponse" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestInitiateByQuote_DoesNotUseIntentRef(t *testing.T) { + orgRef := bson.NewObjectID() + exec := &fakeExecutionClientForBatch{} + api := newBatchAPI(exec) + + body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}` + rr := invokeInitiateByQuote(t, api, orgRef, body) + if got, want := rr.Code, http.StatusOK; got != want { + t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) + } + if got, want := len(exec.executeReqs), 1; got != want { + t.Fatalf("execute calls mismatch: got=%d want=%d", got, want) + } + if got, want := exec.executeReqs[0].GetIntentRef(), ""; got != want { + t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) + } + if got, want := exec.executeReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want { + t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestInitiateByQuote_RejectsMetadataIntentRef(t *testing.T) { + orgRef := bson.NewObjectID() + exec := &fakeExecutionClientForBatch{} + api := newBatchAPI(exec) + + body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"intent_ref":"legacy-intent"}}` + rr := invokeInitiateByQuote(t, api, orgRef, body) + if got, want := rr.Code, http.StatusBadRequest; got != want { + t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) + } + if got := len(exec.executeReqs); got != 0 { + t.Fatalf("expected no execute calls, got=%d", got) + } +} + +func invokeInitiateByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/by-quote", bytes.NewBufferString(body)) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("organizations_ref", orgRef.Hex()) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) + + rr := httptest.NewRecorder() + handler := api.initiateByQuote(req, &model.Account{}, &sresponse.TokenData{ + Token: "token", + Expiration: time.Now().UTC().Add(time.Hour), + }) + handler.ServeHTTP(rr, req) + return rr +} diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go index 9a3cbc69..2b01d42c 100644 --- a/api/server/internal/server/paymentapiimp/paybatch.go +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -39,100 +39,39 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc return response.BadPayload(a.logger, a.Name(), err) } - intentSelectors, err := resolveExecutionIntentSelectors(payload, a.isLegacyMetadataIntentRefFallbackAllowed()) - if err != nil { - return response.BadPayload(a.logger, a.Name(), err) - } clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref") - baseIdempotencyKey := strings.TrimSpace(payload.IdempotencyKey) + idempotencyKey := strings.TrimSpace(payload.IdempotencyKey) quotationRef := strings.TrimSpace(payload.QuoteRef) - executeOne := func(idempotencyKey, intentRef string) (*orchestrationv2.Payment, error) { - req := &orchestrationv2.ExecutePaymentRequest{ - Meta: requestMeta(orgRef.Hex(), idempotencyKey), - QuotationRef: quotationRef, - IntentRef: strings.TrimSpace(intentRef), - ClientPaymentRef: clientPaymentRef, - } - resp, executeErr := a.execution.ExecutePayment(ctx, req) - if executeErr != nil { - return nil, executeErr - } - return resp.GetPayment(), nil + req := &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: requestMeta(orgRef.Hex(), idempotencyKey), + QuotationRef: quotationRef, + ClientPaymentRef: clientPaymentRef, } - - executeBatch := func(idempotencyKey string) ([]*orchestrationv2.Payment, error) { - req := &orchestrationv2.ExecuteBatchPaymentRequest{ - Meta: requestMeta(orgRef.Hex(), idempotencyKey), - QuotationRef: quotationRef, - ClientPaymentRef: clientPaymentRef, - } - resp, executeErr := a.execution.ExecuteBatchPayment(ctx, req) - if executeErr != nil { - return nil, executeErr - } - if resp == nil { - return nil, nil - } - return resp.GetPayments(), nil - } - - payments := make([]*orchestrationv2.Payment, 0, max(1, len(intentSelectors))) - if len(payload.IntentRefs) > 0 { - executed, executeErr := executeBatch(baseIdempotencyKey) - if executeErr != nil { - a.logger.Warn("Failed to initiate batch payments", zap.Error(executeErr), zap.String("organization_ref", orgRef.Hex())) - return grpcErrorResponse(a.logger, a.Name(), executeErr) - } - payments = append(payments, executed...) - return sresponse.PaymentsResponse(a.logger, payments, token) - } - - intentRef := "" - if len(intentSelectors) > 0 { - intentRef = intentSelectors[0] - } - payment, err := executeOne(baseIdempotencyKey, intentRef) + resp, err := a.execution.ExecuteBatchPayment(ctx, req) if err != nil { a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) return grpcErrorResponse(a.logger, a.Name(), err) } - if payment != nil { - payments = append(payments, payment) + + payments := make([]*orchestrationv2.Payment, 0) + if resp != nil { + payments = append(payments, resp.GetPayments()...) } return sresponse.PaymentsResponse(a.logger, payments, token) } -func resolveExecutionIntentSelectors(payload *srequest.InitiatePayments, allowLegacyMetadataIntentRef bool) ([]string, error) { - if payload == nil { - return nil, nil - } - if len(payload.IntentRefs) > 0 { - return append([]string(nil), payload.IntentRefs...), nil - } - if intentRef := strings.TrimSpace(payload.IntentRef); intentRef != "" { - return []string{intentRef}, nil - } - legacy := metadataValue(payload.Metadata, "intent_ref") - if legacy == "" { - return nil, nil - } - if allowLegacyMetadataIntentRef { - return []string{legacy}, nil - } - return nil, merrors.InvalidArgument("metadata.intent_ref is no longer supported; use intentRef or intentRefs", "metadata.intent_ref") -} - func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) { defer r.Body.Close() payload := &srequest.InitiatePayments{} - if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(payload); err != nil { return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) } payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey) payload.QuoteRef = strings.TrimSpace(payload.QuoteRef) - payload.IntentRef = strings.TrimSpace(payload.IntentRef) if err := payload.Validate(); err != nil { return nil, err diff --git a/api/server/internal/server/paymentapiimp/paybatch_test.go b/api/server/internal/server/paymentapiimp/paybatch_test.go index d21ba552..886f12e6 100644 --- a/api/server/internal/server/paymentapiimp/paybatch_test.go +++ b/api/server/internal/server/paymentapiimp/paybatch_test.go @@ -3,7 +3,6 @@ package paymentapiimp import ( "bytes" "context" - "errors" "net/http" "net/http/httptest" "testing" @@ -11,24 +10,22 @@ import ( "github.com/go-chi/chi/v5" "github.com/tech/sendico/pkg/auth" - "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" - "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) -func TestInitiatePaymentsByQuote_PassesIntentRefsInSingleExecuteCall(t *testing.T) { +func TestInitiatePaymentsByQuote_ExecutesBatchPayment(t *testing.T) { orgRef := bson.NewObjectID() exec := &fakeExecutionClientForBatch{} api := newBatchAPI(exec) - body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}` + body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1"}` rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) if got, want := rr.Code, http.StatusOK; got != want { t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) @@ -48,53 +45,34 @@ func TestInitiatePaymentsByQuote_PassesIntentRefsInSingleExecuteCall(t *testing. } } -func TestInitiatePaymentsByQuote_UsesExplicitIntentRef(t *testing.T) { +func TestInitiatePaymentsByQuote_ForwardsClientPaymentRef(t *testing.T) { orgRef := bson.NewObjectID() exec := &fakeExecutionClientForBatch{} api := newBatchAPI(exec) - body := `{"idempotencyKey":"idem-single","quoteRef":"quote-1","intentRef":"intent-x"}` + body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}` rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) if got, want := rr.Code, http.StatusOK; got != want { t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) } - if got, want := len(exec.executeReqs), 1; got != want { - t.Fatalf("execute calls mismatch: got=%d want=%d", got, want) + if got, want := len(exec.executeBatchReqs), 1; got != want { + t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want) } - if got, want := exec.executeReqs[0].GetIntentRef(), "intent-x"; got != want { - t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) + if got, want := exec.executeBatchReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want { + t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want) } - if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-single"; got != want { - t.Fatalf("idempotency mismatch: got=%q want=%q", got, want) + if got := len(exec.executeReqs); got != 0 { + t.Fatalf("expected no execute calls, got=%d", got) } } -func TestInitiatePaymentsByQuote_UsesLegacyMetadataIntentRefFallback(t *testing.T) { - orgRef := bson.NewObjectID() - exec := &fakeExecutionClientForBatch{} - api := newBatchAPIWithLegacyFallback(exec, true, time.Time{}) - - body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}` - rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) - if got, want := rr.Code, http.StatusOK; got != want { - t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) - } - - if got, want := len(exec.executeReqs), 1; got != want { - t.Fatalf("execute calls mismatch: got=%d want=%d", got, want) - } - if got, want := exec.executeReqs[0].GetIntentRef(), "intent-legacy"; got != want { - t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) - } -} - -func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefFallbackByDefault(t *testing.T) { +func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefField(t *testing.T) { orgRef := bson.NewObjectID() exec := &fakeExecutionClientForBatch{} api := newBatchAPI(exec) - body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}` + body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRef":"intent-legacy"}` rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) if got, want := rr.Code, http.StatusBadRequest; got != want { t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) @@ -104,14 +82,12 @@ func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefFallbackByDefault } } -func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefWhenDateGateExpired(t *testing.T) { +func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefsField(t *testing.T) { orgRef := bson.NewObjectID() exec := &fakeExecutionClientForBatch{} - now := time.Date(2026, time.January, 10, 12, 0, 0, 0, time.UTC) - api := newBatchAPIWithLegacyFallback(exec, true, now.Add(-time.Minute)) - api.clock = func() time.Time { return now } + api := newBatchAPI(exec) - body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}` + body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}` rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body) if got, want := rr.Code, http.StatusBadRequest; got != want { t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String()) @@ -121,67 +97,13 @@ func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefWhenDateGateExpir } } -func TestResolveExecutionIntentSelectors_PrefersExplicitSelectors(t *testing.T) { - payload := &srequest.InitiatePayments{ - IntentRefs: []string{"intent-a", "intent-b"}, - IntentRef: "intent-single", - PaymentBase: srequest.PaymentBase{ - Metadata: map[string]string{"intent_ref": "intent-legacy"}, - }, - } - got, err := resolveExecutionIntentSelectors(payload, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got == nil || len(got) != 2 { - t.Fatalf("unexpected selectors: %#v", got) - } - if got[0] != "intent-a" || got[1] != "intent-b" { - t.Fatalf("unexpected selectors order/value: %#v", got) - } -} - -func TestResolveExecutionIntentSelectors_RejectsLegacyMetadataSelectorWhenDisabled(t *testing.T) { - payload := &srequest.InitiatePayments{ - PaymentBase: srequest.PaymentBase{ - Metadata: map[string]string{"intent_ref": "intent-legacy"}, - }, - } - _, err := resolveExecutionIntentSelectors(payload, false) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} - -func TestResolveExecutionIntentSelectors_UsesLegacyMetadataSelectorWhenEnabled(t *testing.T) { - payload := &srequest.InitiatePayments{ - PaymentBase: srequest.PaymentBase{ - Metadata: map[string]string{"intent_ref": "intent-legacy"}, - }, - } - got, err := resolveExecutionIntentSelectors(payload, true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got == nil || len(got) != 1 || got[0] != "intent-legacy" { - t.Fatalf("unexpected selectors: %#v", got) - } -} - func newBatchAPI(exec executionClient) *PaymentAPI { - return newBatchAPIWithLegacyFallback(exec, false, time.Time{}) -} - -func newBatchAPIWithLegacyFallback(exec executionClient, enabled bool, until time.Time) *PaymentAPI { return &PaymentAPI{ - logger: mlogger.Logger(zap.NewNop()), - execution: exec, - enf: fakeEnforcerForBatch{allowed: true}, - oph: mutil.CreatePH(mservice.Organizations), - permissionRef: bson.NewObjectID(), - legacyMetadataIntentRefFallbackEnabled: enabled, - legacyMetadataIntentRefFallbackUntil: until, - clock: time.Now, + logger: mlogger.Logger(zap.NewNop()), + execution: exec, + enf: fakeEnforcerForBatch{allowed: true}, + oph: mutil.CreatePH(mservice.Organizations), + permissionRef: bson.NewObjectID(), } } diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index ea2d2415..76c18cd5 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "fmt" "os" - "strconv" "strings" "sync" "time" @@ -30,11 +29,6 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -const ( - envLegacyMetadataIntentRefFallbackEnabled = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK" - envLegacyMetadataIntentRefFallbackUntil = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK_UNTIL" -) - type executionClient interface { ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) @@ -59,10 +53,6 @@ type PaymentAPI struct { refreshMu sync.RWMutex refreshEvent *discovery.RefreshEvent - legacyMetadataIntentRefFallbackEnabled bool - legacyMetadataIntentRefFallbackUntil time.Time - clock func() time.Time - permissionRef bson.ObjectID } @@ -93,7 +83,6 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { logger: apiCtx.Logger().Named(mservice.Payments), enf: apiCtx.Permissions().Enforcer(), oph: mutil.CreatePH(mservice.Organizations), - clock: time.Now, } desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments) @@ -107,7 +96,6 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err)) return nil, err } - p.configureLegacyMetadataIntentRefFallback() if err := p.initDiscoveryClient(apiCtx.Config()); err != nil { p.logger.Warn("Failed to initialize discovery client", zap.Error(err)) } @@ -303,79 +291,3 @@ func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error { }() return nil } - -func (a *PaymentAPI) configureLegacyMetadataIntentRefFallback() { - enabled := false - enabledRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackEnabled)) - if enabledRaw != "" { - parsed, err := strconv.ParseBool(enabledRaw) - if err != nil { - a.logger.Warn("Invalid legacy metadata intent_ref fallback flag, disabling fallback", - zap.String("env", envLegacyMetadataIntentRefFallbackEnabled), - zap.String("value", enabledRaw), - zap.Error(err), - ) - } else { - enabled = parsed - } - } - - until := time.Time{} - untilRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackUntil)) - if untilRaw != "" { - parsed, err := parseLegacyMetadataIntentRefFallbackDeadline(untilRaw) - if err != nil { - a.logger.Warn("Invalid legacy metadata intent_ref fallback deadline, ignoring deadline", - zap.String("env", envLegacyMetadataIntentRefFallbackUntil), - zap.String("value", untilRaw), - zap.Error(err), - ) - } else { - until = parsed - } - } - - a.legacyMetadataIntentRefFallbackEnabled = enabled - a.legacyMetadataIntentRefFallbackUntil = until - - if !enabled { - return - } - fields := []zap.Field{ - zap.String("env_flag", envLegacyMetadataIntentRefFallbackEnabled), - zap.String("env_until", envLegacyMetadataIntentRefFallbackUntil), - } - if !until.IsZero() { - fields = append(fields, zap.Time("until_utc", until.UTC())) - } - a.logger.Warn("Legacy metadata.intent_ref fallback is enabled for /by-multiquote", fields...) -} - -func (a *PaymentAPI) isLegacyMetadataIntentRefFallbackAllowed() bool { - if a == nil || !a.legacyMetadataIntentRefFallbackEnabled { - return false - } - if a.legacyMetadataIntentRefFallbackUntil.IsZero() { - return true - } - now := time.Now().UTC() - if a.clock != nil { - now = a.clock().UTC() - } - return now.Before(a.legacyMetadataIntentRefFallbackUntil.UTC()) -} - -func parseLegacyMetadataIntentRefFallbackDeadline(value string) (time.Time, error) { - raw := strings.TrimSpace(value) - if raw == "" { - return time.Time{}, merrors.InvalidArgument("deadline is required") - } - if ts, err := time.Parse(time.RFC3339, raw); err == nil { - return ts.UTC(), nil - } - if date, err := time.Parse("2006-01-02", raw); err == nil { - // Date-only values are treated as inclusive; disable fallback at the next UTC midnight. - return date.UTC().Add(24 * time.Hour), nil - } - return time.Time{}, merrors.InvalidArgument("deadline must be RFC3339 or YYYY-MM-DD") -} diff --git a/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go b/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go deleted file mode 100644 index 9a06da07..00000000 --- a/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package paymentapiimp - -import ( - "testing" - "time" -) - -func TestParseLegacyMetadataIntentRefFallbackDeadline(t *testing.T) { - t.Run("parses RFC3339", func(t *testing.T) { - got, err := parseLegacyMetadataIntentRefFallbackDeadline("2026-02-26T12:00:00Z") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want := time.Date(2026, time.February, 26, 12, 0, 0, 0, time.UTC) - if !got.Equal(want) { - t.Fatalf("deadline mismatch: got=%s want=%s", got.UTC(), want.UTC()) - } - }) - - t.Run("parses date-only as inclusive UTC day", func(t *testing.T) { - got, err := parseLegacyMetadataIntentRefFallbackDeadline("2026-02-26") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want := time.Date(2026, time.February, 27, 0, 0, 0, 0, time.UTC) - if !got.Equal(want) { - t.Fatalf("deadline mismatch: got=%s want=%s", got.UTC(), want.UTC()) - } - }) - - t.Run("rejects invalid format", func(t *testing.T) { - if _, err := parseLegacyMetadataIntentRefFallbackDeadline("26-02-2026"); err == nil { - t.Fatal("expected error") - } - }) -} - -func TestIsLegacyMetadataIntentRefFallbackAllowed(t *testing.T) { - now := time.Date(2026, time.February, 26, 12, 0, 0, 0, time.UTC) - - t.Run("disabled", func(t *testing.T) { - api := &PaymentAPI{ - legacyMetadataIntentRefFallbackEnabled: false, - clock: func() time.Time { return now }, - } - if api.isLegacyMetadataIntentRefFallbackAllowed() { - t.Fatal("expected disabled fallback") - } - }) - - t.Run("enabled without deadline", func(t *testing.T) { - api := &PaymentAPI{ - legacyMetadataIntentRefFallbackEnabled: true, - clock: func() time.Time { return now }, - } - if !api.isLegacyMetadataIntentRefFallbackAllowed() { - t.Fatal("expected enabled fallback") - } - }) - - t.Run("enabled with future deadline", func(t *testing.T) { - api := &PaymentAPI{ - legacyMetadataIntentRefFallbackEnabled: true, - legacyMetadataIntentRefFallbackUntil: now.Add(time.Minute), - clock: func() time.Time { return now }, - } - if !api.isLegacyMetadataIntentRefFallbackAllowed() { - t.Fatal("expected enabled fallback before deadline") - } - }) - - t.Run("enabled with past deadline", func(t *testing.T) { - api := &PaymentAPI{ - legacyMetadataIntentRefFallbackEnabled: true, - legacyMetadataIntentRefFallbackUntil: now.Add(-time.Minute), - clock: func() time.Time { return now }, - } - if api.isLegacyMetadataIntentRefFallbackAllowed() { - t.Fatal("expected disabled fallback after deadline") - } - }) -} diff --git a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart index 3e628c9b..9e4b08a0 100644 --- a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart +++ b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart @@ -1,40 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; import 'package:pshared/api/requests/payment/base.dart'; +part 'initiate_payments.g.dart'; + + +@JsonSerializable() class InitiatePaymentsRequest extends PaymentBaseRequest { final String quoteRef; - final String? intentRef; - final List? intentRefs; const InitiatePaymentsRequest({ required super.idempotencyKey, super.metadata, required this.quoteRef, - this.intentRef, - this.intentRefs, }); - factory InitiatePaymentsRequest.fromJson(Map json) { - return InitiatePaymentsRequest( - idempotencyKey: json['idempotencyKey'] as String, - metadata: (json['metadata'] as Map?)?.map( - (key, value) => MapEntry(key, value as String), - ), - quoteRef: json['quoteRef'] as String, - intentRef: json['intentRef'] as String?, - intentRefs: (json['intentRefs'] as List?) - ?.map((value) => value as String) - .toList(), - ); - } - + factory InitiatePaymentsRequest.fromJson(Map json) => _$InitiatePaymentsRequestFromJson(json); @override - Map toJson() { - return { - 'idempotencyKey': idempotencyKey, - 'metadata': metadata, - 'quoteRef': quoteRef, - if (intentRef != null) 'intentRef': intentRef, - if (intentRefs != null) 'intentRefs': intentRefs, - }; - } + Map toJson() => _$InitiatePaymentsRequestToJson(this); } diff --git a/frontend/pshared/lib/provider/payment/multiple/provider.dart b/frontend/pshared/lib/provider/payment/multiple/provider.dart index 59614155..5d8c228e 100644 --- a/frontend/pshared/lib/provider/payment/multiple/provider.dart +++ b/frontend/pshared/lib/provider/payment/multiple/provider.dart @@ -30,8 +30,6 @@ class MultiPaymentProvider extends ChangeNotifier { Future> pay({ String? idempotencyKey, Map? metadata, - String? intentRef, - List? intentRefs, }) async { if (!_organization.isOrganizationSet) { throw StateError('Organization is not set'); @@ -54,8 +52,6 @@ class MultiPaymentProvider extends ChangeNotifier { quoteRef, idempotencyKey: idempotencyKey, metadata: metadata, - intentRef: intentRef, - intentRefs: intentRefs, ); _setResource( diff --git a/frontend/pshared/lib/service/payment/multiple.dart b/frontend/pshared/lib/service/payment/multiple.dart index 7c3ea44d..1a5350b9 100644 --- a/frontend/pshared/lib/service/payment/multiple.dart +++ b/frontend/pshared/lib/service/payment/multiple.dart @@ -37,8 +37,6 @@ class MultiplePaymentsService { String quoteRef, { String? idempotencyKey, Map? metadata, - String? intentRef, - List? intentRefs, }) async { _logger.fine( 'Executing multiple payments for quote $quoteRef in $organizationRef', @@ -47,8 +45,6 @@ class MultiplePaymentsService { idempotencyKey: idempotencyKey ?? const Uuid().v4(), quoteRef: quoteRef, metadata: metadata, - intentRef: intentRef, - intentRefs: intentRefs, ); final response = await AuthorizationService.getPOSTResponse( diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 38eb231b..e0ea1c5f 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -158,15 +158,15 @@ void main() { final request = InitiatePaymentRequest( idempotencyKey: 'idem-2', quoteRef: 'q-1', - metadata: const {'intent_ref': 'intent-1'}, + metadata: const {'client_payment_ref': 'cp-1'}, ); final json = request.toJson(); expect(json['idempotencyKey'], equals('idem-2')); expect(json['quoteRef'], equals('q-1')); expect( - (json['metadata'] as Map)['intent_ref'], - equals('intent-1'), + (json['metadata'] as Map)['client_payment_ref'], + equals('cp-1'), ); expect(json.containsKey('intent'), isTrue); expect(json['intent'], isNull); @@ -176,35 +176,18 @@ void main() { final request = InitiatePaymentsRequest( idempotencyKey: 'idem-3', quoteRef: 'q-2', - intentRefs: const ['intent-a', 'intent-b'], metadata: const {'client_payment_ref': 'cp-1'}, ); final json = request.toJson(); expect(json['idempotencyKey'], equals('idem-3')); expect(json['quoteRef'], equals('q-2')); - expect(json['intentRefs'], equals(const ['intent-a', 'intent-b'])); expect( (json['metadata'] as Map)['client_payment_ref'], equals('cp-1'), ); + expect(json.containsKey('intentRef'), isFalse); + expect(json.containsKey('intentRefs'), isFalse); }); - - test( - 'initiate multi payments request supports single intentRef selector', - () { - final request = InitiatePaymentsRequest( - idempotencyKey: 'idem-4', - quoteRef: 'q-2', - intentRef: 'intent-single', - ); - - final json = request.toJson(); - expect(json['idempotencyKey'], equals('idem-4')); - expect(json['quoteRef'], equals('q-2')); - expect(json['intentRef'], equals('intent-single')); - expect(json.containsKey('intentRefs'), isFalse); - }, - ); }); } diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index 1957e64a..b497b8f6 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -188,7 +188,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { try { _setState(MultiplePayoutsState.sending); _error = null; - final intentRefs = _quotedIntentRefs(); final result = await payment.pay( metadata: { @@ -198,7 +197,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { 'upload_rows': _rows.length.toString(), ...?_uploadAmountMetadata(), }, - intentRefs: intentRefs.isEmpty ? null : intentRefs, ); _sentCount = result.length; @@ -274,20 +272,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { List _quoteItems() => _quotation?.quotation?.items ?? const []; - List _quotedIntentRefs() { - final seen = {}; - final intentRefs = []; - for (final quote in _quoteItems()) { - final intentRef = (quote.intentRef ?? '').trim(); - if (intentRef.isEmpty || seen.contains(intentRef)) { - continue; - } - seen.add(intentRef); - intentRefs.add(intentRef); - } - return intentRefs; - } - @override void dispose() { _quotation?.removeListener(_onQuotationChanged); -- 2.49.1 From 20ce4485e8d8606239bdaf20029fd5b4f01b0ea0 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 22:29:18 +0100 Subject: [PATCH 20/23] missing proto definition --- api/billing/documents/go.mod | 6 +-- api/billing/documents/go.sum | 12 +++--- api/billing/fees/go.mod | 2 +- api/billing/fees/go.sum | 4 +- api/discovery/go.mod | 2 +- api/discovery/go.sum | 4 +- api/fx/ingestor/go.mod | 2 +- api/fx/ingestor/go.sum | 4 +- api/fx/oracle/go.mod | 2 +- api/fx/oracle/go.sum | 4 +- api/gateway/chain/go.mod | 4 +- api/gateway/chain/go.sum | 8 ++-- api/gateway/mntx/go.mod | 2 +- api/gateway/mntx/go.sum | 4 +- api/gateway/tgsettle/go.mod | 2 +- api/gateway/tgsettle/go.sum | 4 +- api/gateway/tron/go.mod | 4 +- api/gateway/tron/go.sum | 8 ++-- api/ledger/go.mod | 2 +- api/ledger/go.sum | 4 +- api/notification/go.mod | 2 +- api/notification/go.sum | 4 +- api/payments/methods/go.mod | 2 +- api/payments/methods/go.sum | 4 +- api/payments/orchestrator/go.mod | 2 +- api/payments/orchestrator/go.sum | 4 +- .../service/orchestrationv2/psvc/execute.go | 4 -- .../orchestrationv2/psvc/service_e2e_test.go | 22 ----------- api/payments/quotation/go.mod | 2 +- api/payments/quotation/go.sum | 4 +- api/pkg/go.mod | 2 +- api/pkg/go.sum | 4 +- .../orchestration/v2/orchestration.proto | 27 ++++++++++++-- api/server/go.mod | 18 +++++---- api/server/go.sum | 37 ++++++++++--------- .../internal/server/paymentapiimp/pay_test.go | 3 -- 36 files changed, 110 insertions(+), 115 deletions(-) diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index 8fb17fef..ef5dee75 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -8,7 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2 v1.41.2 github.com/aws/aws-sdk-go-v2/config v1.32.10 github.com/aws/aws-sdk-go-v2/credentials v1.19.10 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 github.com/jung-kurt/gofpdf v1.16.2 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 @@ -27,7 +27,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect @@ -61,7 +61,7 @@ require ( 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/net v0.51.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 diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index b814da1f..cc640089 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -24,14 +24,14 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 h1:IJRzQTvdpjHRPItx9gzNcz7Y1F+xqAR+xiy9rr5ZYl8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 h1:giB30dEeoar5bgDnkE0q+z7cFjcHaCjulpmPVmuKR84= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0= github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= @@ -229,8 +229,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index 83fe0602..bd6ebaec 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -46,7 +46,7 @@ require ( 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/net v0.51.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 diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index 49be6d17..34b015ee 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -179,8 +179,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/discovery/go.mod b/api/discovery/go.mod index d37352a2..a95dc140 100644 --- a/api/discovery/go.mod +++ b/api/discovery/go.mod @@ -39,7 +39,7 @@ require ( 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/net v0.51.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 diff --git a/api/discovery/go.sum b/api/discovery/go.sum index 49be6d17..34b015ee 100644 --- a/api/discovery/go.sum +++ b/api/discovery/go.sum @@ -179,8 +179,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index 1b20b7ad..336b940e 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -13,7 +13,7 @@ require ( github.com/tech/sendico/fx/storage v0.0.0 github.com/tech/sendico/pkg v0.1.0 go.uber.org/zap v1.27.1 - golang.org/x/net v0.50.0 + golang.org/x/net v0.51.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index 49be6d17..34b015ee 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -179,8 +179,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/fx/oracle/go.mod b/api/fx/oracle/go.mod index f9beac4f..b02c4f5e 100644 --- a/api/fx/oracle/go.mod +++ b/api/fx/oracle/go.mod @@ -44,7 +44,7 @@ require ( 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/net v0.51.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 diff --git a/api/fx/oracle/go.sum b/api/fx/oracle/go.sum index 49be6d17..34b015ee 100644 --- a/api/fx/oracle/go.sum +++ b/api/fx/oracle/go.sum @@ -179,8 +179,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 20c8580b..80fb8be7 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -38,7 +38,7 @@ require ( github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect - github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -86,7 +86,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/net v0.51.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 diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index cb72ee0f..b79afa98 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -72,8 +72,8 @@ 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/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= -github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= -github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= @@ -322,8 +322,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index 2dbc6da0..84d8b4da 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -47,7 +47,7 @@ require ( 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/net v0.51.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 diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index e8deda76..1279c139 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -181,8 +181,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod index 6207326f..72b1618a 100644 --- a/api/gateway/tgsettle/go.mod +++ b/api/gateway/tgsettle/go.mod @@ -44,7 +44,7 @@ require ( 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/net v0.51.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 diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum index 49be6d17..34b015ee 100644 --- a/api/gateway/tgsettle/go.sum +++ b/api/gateway/tgsettle/go.sum @@ -179,8 +179,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index e863f6ff..6a377626 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -42,7 +42,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set v1.8.0 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect - github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -94,7 +94,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/net v0.51.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 diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index d5911ef0..91490bb2 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -76,8 +76,8 @@ 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/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= -github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= -github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= @@ -339,8 +339,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/ledger/go.mod b/api/ledger/go.mod index 0eef72e4..6e37b5c5 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -45,7 +45,7 @@ require ( 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/net v0.51.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 diff --git a/api/ledger/go.sum b/api/ledger/go.sum index 531bd1b6..beac9979 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -181,8 +181,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/notification/go.mod b/api/notification/go.mod index 530e0bac..c5046909 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -47,7 +47,7 @@ require ( 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/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect diff --git a/api/notification/go.sum b/api/notification/go.sum index dd4b4809..b8f37da5 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -196,8 +196,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/payments/methods/go.mod b/api/payments/methods/go.mod index b167b07a..ce43fea7 100644 --- a/api/payments/methods/go.mod +++ b/api/payments/methods/go.mod @@ -45,7 +45,7 @@ require ( 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/net v0.51.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 diff --git a/api/payments/methods/go.sum b/api/payments/methods/go.sum index 531bd1b6..beac9979 100644 --- a/api/payments/methods/go.sum +++ b/api/payments/methods/go.sum @@ -181,8 +181,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index b9fde247..b6fd70e2 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -60,7 +60,7 @@ require ( 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/net v0.51.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 diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index a1fcaf96..528fb75f 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -182,8 +182,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index fdd35346..d49fb5d4 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -46,10 +46,6 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa logger.Debug("Completed payment execution", fields...) }(time.Now()) - if req != nil && strings.TrimSpace(req.GetIntentRef()) != "" { - return nil, merrors.InvalidArgument("intent_ref is no longer supported for ExecutePayment") - } - requestCtx, fingerprint, err := s.prepareExecute(req) if err != nil { return nil, err diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 64564630..8c3e9275 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -103,28 +103,6 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) { } } -func TestExecutePayment_RejectsDeprecatedIntentRef(t *testing.T) { - env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { - step := req.StepExecution - step.State = agg.StepStateCompleted - return &sexec.ExecuteOutput{StepExecution: step}, nil - }) - env.quotes.Put(newExecutableQuote(env.orgID, "quote-sync", "intent-sync", buildLedgerRoute())) - - _, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ - Meta: testMeta(env.orgID, "idem-intent-ref"), - QuotationRef: "quote-sync", - ClientPaymentRef: "client-1", - IntentRef: "intent-sync", - }) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument for deprecated intent_ref, got %v", err) - } - if got := err.Error(); !strings.Contains(got, "intent_ref is no longer supported for ExecutePayment") { - t.Fatalf("unexpected error message: %q", got) - } -} - func TestExecutePayment_BatchQuoteRequiresBatchExecution(t *testing.T) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { step := req.StepExecution diff --git a/api/payments/quotation/go.mod b/api/payments/quotation/go.mod index ea25e748..05c55ac6 100644 --- a/api/payments/quotation/go.mod +++ b/api/payments/quotation/go.mod @@ -59,7 +59,7 @@ require ( 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/net v0.51.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 diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum index ce1a2bb9..3324a09c 100644 --- a/api/payments/quotation/go.sum +++ b/api/payments/quotation/go.sum @@ -182,8 +182,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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= diff --git a/api/pkg/go.mod b/api/pkg/go.mod index a803f28b..4239d956 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -87,7 +87,7 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/net v0.51.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 diff --git a/api/pkg/go.sum b/api/pkg/go.sum index a8298edd..d4a9d30d 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -218,8 +218,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/api/proto/payments/orchestration/v2/orchestration.proto b/api/proto/payments/orchestration/v2/orchestration.proto index 48c4a808..3ae411d3 100644 --- a/api/proto/payments/orchestration/v2/orchestration.proto +++ b/api/proto/payments/orchestration/v2/orchestration.proto @@ -22,6 +22,9 @@ service PaymentOrchestratorService { // ExecutePayment creates/starts payment execution from an accepted quote. rpc ExecutePayment(ExecutePaymentRequest) returns (ExecutePaymentResponse); + // ExecuteBatchPayment creates/starts execution for all intents in a quoted batch. + rpc ExecuteBatchPayment(ExecuteBatchPaymentRequest) returns (ExecuteBatchPaymentResponse); + // GetPayment returns one payment by reference. rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse); @@ -40,10 +43,6 @@ message ExecutePaymentRequest { // Optional caller-side correlation key. string client_payment_ref = 3; - - // Optional intent selector for batch quotations. - // Must be provided when quotation_ref resolves to multiple intents. - string intent_ref = 4; } // ExecutePaymentResponse returns the created or deduplicated payment. @@ -51,6 +50,26 @@ message ExecutePaymentResponse { Payment payment = 1; } +// ExecuteBatchPaymentRequest starts orchestration for all intents resolved by +// one accepted quotation_ref. +message ExecuteBatchPaymentRequest { + // Organization and trace context; idempotency should be supplied via + // meta.trace.idempotency_key. + payments.shared.v1.RequestMeta meta = 1; + + // Required accepted quotation reference. + string quotation_ref = 2; + + // Optional caller-side correlation key. + string client_payment_ref = 3; +} + +// ExecuteBatchPaymentResponse returns created or deduplicated payments for +// grouped batch execution. +message ExecuteBatchPaymentResponse { + repeated Payment payments = 1; +} + // GetPaymentRequest fetches one payment by payment_ref. message GetPaymentRequest { payments.shared.v1.RequestMeta meta = 1; diff --git a/api/server/go.mod b/api/server/go.mod index cee4b5fb..5fcf53d6 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -18,10 +18,10 @@ require ( github.com/aws/aws-sdk-go-v2 v1.41.2 github.com/aws/aws-sdk-go-v2/config v1.32.10 github.com/aws/aws-sdk-go-v2/credentials v1.19.10 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 - github.com/go-chi/jwtauth/v5 v5.3.3 + github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-chi/metrics v0.1.1 github.com/google/uuid v1.6.0 github.com/mitchellh/mapstructure v1.5.0 @@ -36,7 +36,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 go.mongodb.org/mongo-driver/v2 v2.5.0 go.uber.org/zap v1.27.1 - golang.org/x/net v0.50.0 + golang.org/x/net v0.51.0 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -60,7 +60,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect @@ -90,11 +90,12 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -125,6 +126,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/api/server/go.sum b/api/server/go.sum index 0fb933c3..232641d1 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -26,14 +26,14 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 h1:IJRzQTvdpjHRPItx9gzNcz7Y1F+xqAR+xiy9rr5ZYl8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 h1:giB30dEeoar5bgDnkE0q+z7cFjcHaCjulpmPVmuKR84= -github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0= github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o= @@ -92,8 +92,8 @@ 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-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= -github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= +github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk= github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -137,16 +137,18 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= -github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= -github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= +github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= +github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= 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= @@ -222,7 +224,6 @@ 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -236,6 +237,8 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI 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/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= 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= @@ -308,8 +311,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/api/server/internal/server/paymentapiimp/pay_test.go b/api/server/internal/server/paymentapiimp/pay_test.go index 7b5b7e0f..417853a7 100644 --- a/api/server/internal/server/paymentapiimp/pay_test.go +++ b/api/server/internal/server/paymentapiimp/pay_test.go @@ -27,9 +27,6 @@ func TestInitiateByQuote_DoesNotUseIntentRef(t *testing.T) { if got, want := len(exec.executeReqs), 1; got != want { t.Fatalf("execute calls mismatch: got=%d want=%d", got, want) } - if got, want := exec.executeReqs[0].GetIntentRef(), ""; got != want { - t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) - } if got, want := exec.executeReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want { t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want) } -- 2.49.1 From 947cd7f4c97f78777f0ec33e8a217aad661ad607 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 22:38:31 +0100 Subject: [PATCH 21/23] dto / mapper / model separation of verification purpose --- .../lib/api/requests/verification/login.dart | 15 ++++---- .../lib/data/dto/verification/purpose.dart | 18 ++++++++++ .../lib/data/mapper/verification/purpose.dart | 36 +++++++++++++++++++ .../lib/models/verification/purpose.dart | 9 ----- .../pshared/lib/service/verification.dart | 18 ++++++---- 5 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 frontend/pshared/lib/data/dto/verification/purpose.dart create mode 100644 frontend/pshared/lib/data/mapper/verification/purpose.dart diff --git a/frontend/pshared/lib/api/requests/verification/login.dart b/frontend/pshared/lib/api/requests/verification/login.dart index 7212ef19..0c76e8b5 100644 --- a/frontend/pshared/lib/api/requests/verification/login.dart +++ b/frontend/pshared/lib/api/requests/verification/login.dart @@ -1,24 +1,24 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:pshared/models/verification/purpose.dart'; import 'package:pshared/api/requests/tokens/session_identifier.dart'; +import 'package:pshared/data/dto/verification/purpose.dart'; part 'login.g.dart'; - @JsonSerializable(explicitToJson: true) class LoginVerificationRequest { - final VerificationPurpose purpose; + final VerificationPurposeDTO purpose; final String? target; final String idempotencyKey; const LoginVerificationRequest({ - this.purpose = VerificationPurpose.login, + this.purpose = VerificationPurposeDTO.login, this.target, required this.idempotencyKey, }); - factory LoginVerificationRequest.fromJson(Map json) => _$LoginVerificationRequestFromJson(json); + factory LoginVerificationRequest.fromJson(Map json) => + _$LoginVerificationRequestFromJson(json); Map toJson() => _$LoginVerificationRequestToJson(this); } @@ -28,14 +28,15 @@ class LoginCodeVerifyicationRequest extends LoginVerificationRequest { final SessionIdentifierDTO sessionIdentifier; const LoginCodeVerifyicationRequest({ - super.purpose = VerificationPurpose.login, + super.purpose = VerificationPurposeDTO.login, super.target, required super.idempotencyKey, required this.code, required this.sessionIdentifier, }); - factory LoginCodeVerifyicationRequest.fromJson(Map json) => _$LoginCodeVerifyicationRequestFromJson(json); + factory LoginCodeVerifyicationRequest.fromJson(Map json) => + _$LoginCodeVerifyicationRequestFromJson(json); @override Map toJson() => _$LoginCodeVerifyicationRequestToJson(this); } diff --git a/frontend/pshared/lib/data/dto/verification/purpose.dart b/frontend/pshared/lib/data/dto/verification/purpose.dart new file mode 100644 index 00000000..4fe6487d --- /dev/null +++ b/frontend/pshared/lib/data/dto/verification/purpose.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +enum VerificationPurposeDTO { + @JsonValue('login') + login, + + @JsonValue('payout') + payout, + + @JsonValue('account_activation') + accountActivation, + + @JsonValue('email_change') + emailChange, + + @JsonValue('password_reset') + passwordReset, +} diff --git a/frontend/pshared/lib/data/mapper/verification/purpose.dart b/frontend/pshared/lib/data/mapper/verification/purpose.dart new file mode 100644 index 00000000..849cd5e1 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/verification/purpose.dart @@ -0,0 +1,36 @@ +import 'package:pshared/data/dto/verification/purpose.dart'; +import 'package:pshared/models/verification/purpose.dart'; + +extension VerificationPurposeDTOMapper on VerificationPurposeDTO { + VerificationPurpose toDomain() { + switch (this) { + case VerificationPurposeDTO.login: + return VerificationPurpose.login; + case VerificationPurposeDTO.payout: + return VerificationPurpose.payout; + case VerificationPurposeDTO.accountActivation: + return VerificationPurpose.accountActivation; + case VerificationPurposeDTO.emailChange: + return VerificationPurpose.emailChange; + case VerificationPurposeDTO.passwordReset: + return VerificationPurpose.passwordReset; + } + } +} + +extension VerificationPurposeMapper on VerificationPurpose { + VerificationPurposeDTO toDTO() { + switch (this) { + case VerificationPurpose.login: + return VerificationPurposeDTO.login; + case VerificationPurpose.payout: + return VerificationPurposeDTO.payout; + case VerificationPurpose.accountActivation: + return VerificationPurposeDTO.accountActivation; + case VerificationPurpose.emailChange: + return VerificationPurposeDTO.emailChange; + case VerificationPurpose.passwordReset: + return VerificationPurposeDTO.passwordReset; + } + } +} diff --git a/frontend/pshared/lib/models/verification/purpose.dart b/frontend/pshared/lib/models/verification/purpose.dart index 4d08602f..6f6ac424 100644 --- a/frontend/pshared/lib/models/verification/purpose.dart +++ b/frontend/pshared/lib/models/verification/purpose.dart @@ -1,17 +1,8 @@ -import 'package:json_annotation/json_annotation.dart'; - - /// Targets for confirmation codes. -@JsonEnum(alwaysCreate: true) enum VerificationPurpose { - @JsonValue('login') login, - @JsonValue('payout') payout, - @JsonValue('account_activation') accountActivation, - @JsonValue('email_change') emailChange, - @JsonValue('password_reset') passwordReset, } diff --git a/frontend/pshared/lib/service/verification.dart b/frontend/pshared/lib/service/verification.dart index 99febb82..10fab535 100644 --- a/frontend/pshared/lib/service/verification.dart +++ b/frontend/pshared/lib/service/verification.dart @@ -8,6 +8,7 @@ import 'package:pshared/api/responses/login.dart'; import 'package:pshared/api/responses/verification/response.dart'; import 'package:pshared/data/mapper/account/account.dart'; import 'package:pshared/data/mapper/session_identifier.dart'; +import 'package:pshared/data/mapper/verification/purpose.dart'; import 'package:pshared/models/account/account.dart'; import 'package:pshared/models/auth/pending_login.dart'; import 'package:pshared/models/verification/purpose.dart'; @@ -16,12 +17,14 @@ import 'package:pshared/service/authorization/storage.dart'; import 'package:pshared/service/services.dart'; import 'package:pshared/utils/http/requests.dart'; - class VerificationService { static final _logger = Logger('service.verification'); static const String _objectType = Services.verification; - static Future requestLoginCode(PendingLogin pending, {String? target}) async { + static Future requestLoginCode( + PendingLogin pending, { + String? target, + }) async { _logger.fine('Requesting login confirmation code'); final response = await getPOSTResponse( _objectType, @@ -35,7 +38,10 @@ class VerificationService { return VerificationResponse.fromJson(response); } - static Future resendLoginCode(PendingLogin pending, {String? target}) async { + static Future resendLoginCode( + PendingLogin pending, { + String? target, + }) async { _logger.fine('Resending login confirmation code'); final response = await getPOSTResponse( _objectType, @@ -82,7 +88,7 @@ class VerificationService { _objectType, '', LoginVerificationRequest( - purpose: VerificationPurpose.payout, + purpose: VerificationPurpose.payout.toDTO(), target: target, idempotencyKey: idempotencyKey ?? Uuid().v4(), ).toJson(), @@ -99,7 +105,7 @@ class VerificationService { _objectType, '/resend', LoginVerificationRequest( - purpose: VerificationPurpose.payout, + purpose: VerificationPurpose.payout.toDTO(), target: target, idempotencyKey: idempotencyKey, ).toJson(), @@ -117,7 +123,7 @@ class VerificationService { _objectType, '/verify', LoginCodeVerifyicationRequest( - purpose: VerificationPurpose.payout, + purpose: VerificationPurpose.payout.toDTO(), target: target, idempotencyKey: idempotencyKey, code: code, -- 2.49.1 From fa9e6f47cf30fa67afe8df396a6e2b410c67f815 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 23:15:48 +0100 Subject: [PATCH 22/23] smarter optimizer for batch payments --- .../orchestrationv2/batchmeta/module.go | 296 ++++++++++++++++++ .../orchestrationv2/psvc/execute_batch.go | 148 ++++++++- .../psvc/execute_batch_test.go | 130 +++++++- .../xplan/compile_batch_targets_test.go | 90 ++++++ .../xplan/service_boundaries.go | 102 +++++- .../orchestrator/card_payout_executor.go | 88 +++++- .../orchestrator/card_payout_executor_test.go | 94 ++++++ .../service/orchestrator/ledger_executor.go | 4 + .../orchestrator/ledger_executor_test.go | 47 +++ 9 files changed, 972 insertions(+), 27 deletions(-) create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/batchmeta/module.go create mode 100644 api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_batch_targets_test.go diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/batchmeta/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/batchmeta/module.go new file mode 100644 index 00000000..3ed7be3d --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/batchmeta/module.go @@ -0,0 +1,296 @@ +package batchmeta + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +const ( + // AttrPayoutTargets stores serialized payout target descriptors in intent attributes. + AttrPayoutTargets = "orchestrator.v2.batch_payout_targets" + + MetaPayoutTargetRef = "orchestrator.v2.payout_target_ref" + MetaPayoutTargetIndex = "orchestrator.v2.payout_target_index" + MetaAmount = "orchestrator.v2.amount" + MetaCurrency = "orchestrator.v2.currency" + + MetaCardPan = "orchestrator.v2.card_pan" + MetaCardToken = "orchestrator.v2.card_token" + MetaCardholder = "orchestrator.v2.cardholder" + MetaCardholderSurname = "orchestrator.v2.cardholder_surname" + MetaCardExpMonth = "orchestrator.v2.card_exp_month" + MetaCardExpYear = "orchestrator.v2.card_exp_year" + MetaCardCountry = "orchestrator.v2.card_country" + MetaCardMaskedPan = "orchestrator.v2.card_masked_pan" + MetaCustomerID = "orchestrator.v2.customer_id" + MetaCustomerFirstName = "orchestrator.v2.customer_first_name" + MetaCustomerMiddleName = "orchestrator.v2.customer_middle_name" + MetaCustomerLastName = "orchestrator.v2.customer_last_name" + MetaCustomerIP = "orchestrator.v2.customer_ip" + MetaCustomerZip = "orchestrator.v2.customer_zip" + MetaCustomerCountry = "orchestrator.v2.customer_country" + MetaCustomerState = "orchestrator.v2.customer_state" + MetaCustomerCity = "orchestrator.v2.customer_city" + MetaCustomerAddress = "orchestrator.v2.customer_address" +) + +// PayoutTarget carries one destination-level payout branch payload for batch execution. +type PayoutTarget struct { + TargetRef string `json:"target_ref,omitempty"` + IntentRef []string `json:"intent_refs,omitempty"` + Amount *paymenttypes.Money `json:"amount,omitempty"` + Card *model.CardEndpoint `json:"card,omitempty"` + Customer *model.Customer `json:"customer,omitempty"` +} + +func EncodePayoutTargets(targets []PayoutTarget) (string, error) { + norm := normalizeTargets(targets) + if len(norm) == 0 { + return "", nil + } + data, err := json.Marshal(norm) + if err != nil { + return "", err + } + return string(data), nil +} + +func DecodePayoutTargets(raw string) ([]PayoutTarget, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var targets []PayoutTarget + if err := json.Unmarshal([]byte(raw), &targets); err != nil { + return nil, err + } + return normalizeTargets(targets), nil +} + +func StepMetadataForTarget(target PayoutTarget, index int) map[string]string { + target = normalizeTarget(target, index) + out := map[string]string{} + + if ref := strings.TrimSpace(target.TargetRef); ref != "" { + out[MetaPayoutTargetRef] = ref + } + if index >= 0 { + out[MetaPayoutTargetIndex] = strconv.Itoa(index + 1) + } + if target.Amount != nil { + if amount := strings.TrimSpace(target.Amount.Amount); amount != "" { + out[MetaAmount] = amount + } + if currency := strings.ToUpper(strings.TrimSpace(target.Amount.Currency)); currency != "" { + out[MetaCurrency] = currency + } + } + if target.Card != nil { + appendIfNotEmpty(out, MetaCardPan, target.Card.Pan) + appendIfNotEmpty(out, MetaCardToken, target.Card.Token) + appendIfNotEmpty(out, MetaCardholder, target.Card.Cardholder) + appendIfNotEmpty(out, MetaCardholderSurname, target.Card.CardholderSurname) + appendIfNotEmpty(out, MetaCardCountry, target.Card.Country) + appendIfNotEmpty(out, MetaCardMaskedPan, target.Card.MaskedPan) + if target.Card.ExpMonth != 0 { + out[MetaCardExpMonth] = strconv.FormatUint(uint64(target.Card.ExpMonth), 10) + } + if target.Card.ExpYear != 0 { + out[MetaCardExpYear] = strconv.FormatUint(uint64(target.Card.ExpYear), 10) + } + } + if target.Customer != nil { + appendIfNotEmpty(out, MetaCustomerID, target.Customer.ID) + appendIfNotEmpty(out, MetaCustomerFirstName, target.Customer.FirstName) + appendIfNotEmpty(out, MetaCustomerMiddleName, target.Customer.MiddleName) + appendIfNotEmpty(out, MetaCustomerLastName, target.Customer.LastName) + appendIfNotEmpty(out, MetaCustomerIP, target.Customer.IP) + appendIfNotEmpty(out, MetaCustomerZip, target.Customer.Zip) + appendIfNotEmpty(out, MetaCustomerCountry, target.Customer.Country) + appendIfNotEmpty(out, MetaCustomerState, target.Customer.State) + appendIfNotEmpty(out, MetaCustomerCity, target.Customer.City) + appendIfNotEmpty(out, MetaCustomerAddress, target.Customer.Address) + } + if len(out) == 0 { + return nil + } + return out +} + +func AmountFromMetadata(metadata map[string]string) (*paymenttypes.Money, bool) { + if len(metadata) == 0 { + return nil, false + } + amount := strings.TrimSpace(metadata[MetaAmount]) + currency := strings.ToUpper(strings.TrimSpace(metadata[MetaCurrency])) + if amount == "" || currency == "" { + return nil, false + } + return &paymenttypes.Money{Amount: amount, Currency: currency}, true +} + +func CardFromMetadata(metadata map[string]string) (*model.CardEndpoint, bool) { + if len(metadata) == 0 { + return nil, false + } + card := &model.CardEndpoint{ + Pan: strings.TrimSpace(metadata[MetaCardPan]), + Token: strings.TrimSpace(metadata[MetaCardToken]), + Cardholder: strings.TrimSpace(metadata[MetaCardholder]), + CardholderSurname: strings.TrimSpace(metadata[MetaCardholderSurname]), + Country: strings.ToUpper(strings.TrimSpace(metadata[MetaCardCountry])), + MaskedPan: strings.TrimSpace(metadata[MetaCardMaskedPan]), + } + if parsed, ok := parseUint32(metadata[MetaCardExpMonth]); ok { + card.ExpMonth = parsed + } + if parsed, ok := parseUint32(metadata[MetaCardExpYear]); ok { + card.ExpYear = parsed + } + if card.Pan == "" && card.Token == "" && card.Cardholder == "" && + card.CardholderSurname == "" && card.ExpMonth == 0 && card.ExpYear == 0 && + card.Country == "" && card.MaskedPan == "" { + return nil, false + } + return card, true +} + +func CustomerFromMetadata(metadata map[string]string) (*model.Customer, bool) { + if len(metadata) == 0 { + return nil, false + } + customer := &model.Customer{ + ID: strings.TrimSpace(metadata[MetaCustomerID]), + FirstName: strings.TrimSpace(metadata[MetaCustomerFirstName]), + MiddleName: strings.TrimSpace(metadata[MetaCustomerMiddleName]), + LastName: strings.TrimSpace(metadata[MetaCustomerLastName]), + IP: strings.TrimSpace(metadata[MetaCustomerIP]), + Zip: strings.TrimSpace(metadata[MetaCustomerZip]), + Country: strings.ToUpper(strings.TrimSpace(metadata[MetaCustomerCountry])), + State: strings.TrimSpace(metadata[MetaCustomerState]), + City: strings.TrimSpace(metadata[MetaCustomerCity]), + Address: strings.TrimSpace(metadata[MetaCustomerAddress]), + } + if customer.ID == "" && customer.FirstName == "" && customer.MiddleName == "" && + customer.LastName == "" && customer.IP == "" && customer.Zip == "" && + customer.Country == "" && customer.State == "" && customer.City == "" && + customer.Address == "" { + return nil, false + } + return customer, true +} + +func normalizeTargets(targets []PayoutTarget) []PayoutTarget { + if len(targets) == 0 { + return nil + } + out := make([]PayoutTarget, 0, len(targets)) + for i := range targets { + target := normalizeTarget(targets[i], i) + if target.TargetRef == "" && target.Amount == nil && target.Card == nil && target.Customer == nil { + continue + } + out = append(out, target) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeTarget(target PayoutTarget, index int) PayoutTarget { + target.TargetRef = strings.TrimSpace(target.TargetRef) + if target.TargetRef == "" { + target.TargetRef = "target-" + strconv.Itoa(index+1) + } + target.IntentRef = normalizeStringSlice(target.IntentRef) + if target.Amount != nil { + amount := strings.TrimSpace(target.Amount.Amount) + currency := strings.ToUpper(strings.TrimSpace(target.Amount.Currency)) + if amount == "" || currency == "" { + target.Amount = nil + } else { + target.Amount = &paymenttypes.Money{ + Amount: amount, + Currency: currency, + } + } + } + if target.Card != nil { + target.Card = &model.CardEndpoint{ + Pan: strings.TrimSpace(target.Card.Pan), + Token: strings.TrimSpace(target.Card.Token), + Cardholder: strings.TrimSpace(target.Card.Cardholder), + CardholderSurname: strings.TrimSpace(target.Card.CardholderSurname), + ExpMonth: target.Card.ExpMonth, + ExpYear: target.Card.ExpYear, + Country: strings.ToUpper(strings.TrimSpace(target.Card.Country)), + MaskedPan: strings.TrimSpace(target.Card.MaskedPan), + } + } + if target.Customer != nil { + target.Customer = &model.Customer{ + ID: strings.TrimSpace(target.Customer.ID), + FirstName: strings.TrimSpace(target.Customer.FirstName), + MiddleName: strings.TrimSpace(target.Customer.MiddleName), + LastName: strings.TrimSpace(target.Customer.LastName), + IP: strings.TrimSpace(target.Customer.IP), + Zip: strings.TrimSpace(target.Customer.Zip), + Country: strings.ToUpper(strings.TrimSpace(target.Customer.Country)), + State: strings.TrimSpace(target.Customer.State), + City: strings.TrimSpace(target.Customer.City), + Address: strings.TrimSpace(target.Customer.Address), + } + } + return target +} + +func normalizeStringSlice(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for i := range values { + token := strings.TrimSpace(values[i]) + if token == "" { + continue + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + out = append(out, token) + } + if len(out) == 0 { + return nil + } + return out +} + +func appendIfNotEmpty(dst map[string]string, key string, value string) { + if dst == nil { + return + } + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + dst[key] = trimmed +} + +func parseUint32(raw string) (uint32, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return 0, false + } + parsed, err := strconv.ParseUint(trimmed, 10, 32) + if err != nil { + return 0, false + } + return uint32(parsed), true +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go index 22cc2e9d..e480dbef 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go @@ -6,21 +6,30 @@ import ( "encoding/hex" "errors" "sort" + "strconv" "strings" "time" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/opagg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "go.uber.org/zap" ) +const ( + attrAggregatedByRecipient = "orchestrator.v2.aggregated_by_recipient" + attrAggregatedItems = "orchestrator.v2.aggregated_items" +) + func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (resp *orchestrationv2.ExecuteBatchPaymentResponse, err error) { logger := s.logger orgRef := "" @@ -72,21 +81,19 @@ func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.Exec if err != nil { return nil, err } - - payments := make([]*agg.Payment, 0, len(aggOutput.Groups)) - for _, group := range aggOutput.Groups { - payment, err := s.executeGroup(ctx, requestCtx, resolved.QuotationRef, group) - if err != nil { - return nil, err - } - payments = append(payments, payment) - } - - protoPayments, err := s.mapPayments(payments) + group, err := s.buildBatchOperationGroup(aggOutput.Groups) if err != nil { return nil, err } - return &orchestrationv2.ExecuteBatchPaymentResponse{Payments: protoPayments}, nil + payment, err := s.executeGroup(ctx, requestCtx, resolved.QuotationRef, group) + if err != nil { + return nil, err + } + protoPayment, err := s.mapPayment(payment) + if err != nil { + return nil, err + } + return &orchestrationv2.ExecuteBatchPaymentResponse{Payments: []*orchestrationv2.Payment{protoPayment}}, nil } func (s *svc) prepareBatchExecute(req *orchestrationv2.ExecuteBatchPaymentRequest) (*reqval.Ctx, error) { @@ -260,3 +267,120 @@ func normalizeIntentRefs(values []string) []string { } return out } + +func (s *svc) buildBatchOperationGroup(groups []opagg.Group) (opagg.Group, error) { + if len(groups) == 0 { + return opagg.Group{}, merrors.InvalidArgument("aggregation produced no groups") + } + + anchorDestination := groups[0].IntentSnapshot.Destination + synthetic := make([]opagg.Item, 0, len(groups)) + allIntentRefs := make([]string, 0, len(groups)) + for i := range groups { + group := groups[i] + intent := group.IntentSnapshot + intent.Destination = anchorDestination + synthetic = append(synthetic, opagg.Item{ + IntentRef: strings.Join(normalizeIntentRefs(group.IntentRefs), ","), + IntentSnapshot: intent, + QuoteSnapshot: group.QuoteSnapshot, + }) + allIntentRefs = append(allIntentRefs, group.IntentRefs...) + } + + collapsed, err := s.aggregator.Aggregate(opagg.Input{Items: synthetic}) + if err != nil { + return opagg.Group{}, err + } + if collapsed == nil || len(collapsed.Groups) != 1 { + return opagg.Group{}, merrors.InvalidArgument("batch quotation contains incompatible operation groups") + } + + out := collapsed.Groups[0] + out.IntentRefs = normalizeIntentRefs(allIntentRefs) + if len(out.IntentRefs) == 0 { + return opagg.Group{}, merrors.InvalidArgument("aggregated group has no intent refs") + } + if out.IntentSnapshot.Attributes == nil { + out.IntentSnapshot.Attributes = map[string]string{} + } + if len(groups) > 1 { + out.IntentSnapshot.Attributes[attrAggregatedByRecipient] = "true" + } + out.IntentSnapshot.Attributes[attrAggregatedItems] = strconv.Itoa(len(out.IntentRefs)) + + targets := buildBatchPayoutTargets(groups) + if routeContainsCardPayout(out.QuoteSnapshot) && len(targets) > 0 { + raw, err := batchmeta.EncodePayoutTargets(targets) + if err != nil { + return opagg.Group{}, err + } + if strings.TrimSpace(raw) != "" { + out.IntentSnapshot.Attributes[batchmeta.AttrPayoutTargets] = raw + } + } + return out, nil +} + +func buildBatchPayoutTargets(groups []opagg.Group) []batchmeta.PayoutTarget { + if len(groups) == 0 { + return nil + } + out := make([]batchmeta.PayoutTarget, 0, len(groups)) + for i := range groups { + group := groups[i] + target := batchmeta.PayoutTarget{ + TargetRef: firstNonEmpty(strings.TrimSpace(group.RecipientKey), "recipient-"+strconv.Itoa(i+1)), + IntentRef: normalizeIntentRefs(group.IntentRefs), + Amount: batchPayoutAmount(group), + } + if group.IntentSnapshot.Destination.Type == model.EndpointTypeCard && group.IntentSnapshot.Destination.Card != nil { + card := *group.IntentSnapshot.Destination.Card + target.Card = &card + } + if group.IntentSnapshot.Customer != nil { + customer := *group.IntentSnapshot.Customer + target.Customer = &customer + } + out = append(out, target) + } + if len(out) == 0 { + return nil + } + return out +} + +func batchPayoutAmount(group opagg.Group) *paymenttypes.Money { + if group.QuoteSnapshot != nil && group.QuoteSnapshot.ExpectedSettlementAmount != nil { + return &paymenttypes.Money{ + Amount: strings.TrimSpace(group.QuoteSnapshot.ExpectedSettlementAmount.Amount), + Currency: strings.TrimSpace(group.QuoteSnapshot.ExpectedSettlementAmount.Currency), + } + } + if group.IntentSnapshot.Amount == nil { + return nil + } + return &paymenttypes.Money{ + Amount: strings.TrimSpace(group.IntentSnapshot.Amount.Amount), + Currency: strings.TrimSpace(group.IntentSnapshot.Amount.Currency), + } +} + +func routeContainsCardPayout(snapshot *model.PaymentQuoteSnapshot) bool { + if snapshot == nil || snapshot.Route == nil { + return false + } + for i := range snapshot.Route.Hops { + hop := snapshot.Route.Hops[i] + if hop == nil { + continue + } + if model.ParseRail(hop.Rail) == model.RailCardPayout { + return true + } + } + if model.ParseRail(snapshot.Route.Rail) == model.RailCardPayout { + return true + } + return false +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go index 70abf825..4be95db9 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch_test.go @@ -2,10 +2,12 @@ package psvc import ( "context" + "sort" "testing" "time" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/storage/model" pm "github.com/tech/sendico/pkg/model" @@ -43,7 +45,7 @@ func TestExecuteBatchPayment_SameDestinationMerges(t *testing.T) { } } -func TestExecuteBatchPayment_DifferentDestinationsCreatesSeparate(t *testing.T) { +func TestExecuteBatchPayment_DifferentDestinationsCompactsIntoSinglePayment(t *testing.T) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { step := req.StepExecution step.State = agg.StepStateCompleted @@ -61,8 +63,8 @@ func TestExecuteBatchPayment_DifferentDestinationsCreatesSeparate(t *testing.T) if err != nil { t.Fatalf("ExecuteBatchPayment returned error: %v", err) } - if got, want := len(resp.GetPayments()), 2; got != want { - t.Fatalf("expected %d payments for different destinations, got=%d", want, got) + if got, want := len(resp.GetPayments()), 1; got != want { + t.Fatalf("expected %d payment for batched execution, got=%d", want, got) } for i, p := range resp.GetPayments() { if got, want := p.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { @@ -71,6 +73,55 @@ func TestExecuteBatchPayment_DifferentDestinationsCreatesSeparate(t *testing.T) } } +func TestExecuteBatchPayment_CardBatchCreatesPerTargetPayoutStepsInSinglePayment(t *testing.T) { + var ( + targetRefs []string + amounts []string + ) + env := newTestEnv(t, func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if kind == "card_payout" { + targetRefs = append(targetRefs, req.Step.Metadata[batchmeta.MetaPayoutTargetRef]) + amounts = append(amounts, req.Step.Metadata[batchmeta.MetaAmount]) + } + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + + quote := newExecutableBatchCardQuoteDiffDest(env.orgID, "quote-batch-card-diff") + env.quotes.Put(quote) + + resp, err := env.svc.ExecuteBatchPayment(context.Background(), &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: testMeta(env.orgID, "idem-batch-card-diff"), + QuotationRef: "quote-batch-card-diff", + ClientPaymentRef: "client-batch-card-diff", + }) + if err != nil { + t.Fatalf("ExecuteBatchPayment returned error: %v", err) + } + if got, want := len(resp.GetPayments()), 1; got != want { + t.Fatalf("expected %d payment for card batch, got=%d", want, got) + } + if got, want := resp.GetPayments()[0].GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got, want) + } + if got, want := len(targetRefs), 2; got != want { + t.Fatalf("expected %d card payout send calls, got=%d", want, got) + } + if got, want := len(amounts), 2; got != want { + t.Fatalf("expected %d card payout amounts, got=%d", want, got) + } + + sort.Strings(targetRefs) + sort.Strings(amounts) + if targetRefs[0] == targetRefs[1] { + t.Fatalf("expected distinct target refs, got=%v", targetRefs) + } + if got, want := amounts, []string{"100", "150"}; got[0] != want[0] || got[1] != want[1] { + t.Fatalf("amount overrides mismatch: got=%v want=%v", got, want) + } +} + func TestExecuteBatchPayment_IdempotentRetry(t *testing.T) { env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { step := req.StepExecution @@ -183,3 +234,76 @@ func newExecutableBatchQuoteDiffDest(orgRef bson.ObjectID, quoteRef string) *mod ExpiresAt: now.Add(1 * time.Hour), } } + +func newExecutableBatchCardQuoteDiffDest(orgRef bson.ObjectID, quoteRef string) *model.PaymentQuoteRecord { + now := time.Now().UTC() + return &model.PaymentQuoteRecord{ + Base: modelBase(now), + OrganizationBoundBase: pm.OrganizationBoundBase{ + OrganizationRef: orgRef, + }, + QuoteRef: quoteRef, + RequestShape: model.QuoteRequestShapeBatch, + Items: []*model.PaymentQuoteItemV2{ + { + Intent: &model.PaymentIntent{ + Ref: "intent-a", + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "2200700142860161", + ExpMonth: 3, + ExpYear: 2030, + }, + }, + Amount: &paymenttypes.Money{Amount: "100", Currency: "RUB"}, + SettlementCurrency: "RUB", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "1.2", Currency: "USDT"}, + ExpectedSettlementAmount: &paymenttypes.Money{Amount: "100", Currency: "RUB"}, + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + { + Intent: &model.PaymentIntent{ + Ref: "intent-b", + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "2200700142860162", + ExpMonth: 4, + ExpYear: 2030, + }, + }, + Amount: &paymenttypes.Money{Amount: "150", Currency: "RUB"}, + SettlementCurrency: "RUB", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "1.8", Currency: "USDT"}, + ExpectedSettlementAmount: &paymenttypes.Money{Amount: "150", Currency: "RUB"}, + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + }, + }, + ExpiresAt: now.Add(1 * time.Hour), + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_batch_targets_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_batch_targets_test.go new file mode 100644 index 00000000..f00a8daa --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_batch_targets_test.go @@ -0,0 +1,90 @@ +package xplan + +import ( + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func TestCompile_InternalToCard_WithBatchTargetsExpandsPerRecipientPayoutBranches(t *testing.T) { + compiler := New() + + targetsRaw, err := batchmeta.EncodePayoutTargets([]batchmeta.PayoutTarget{ + { + TargetRef: "recipient-1", + Amount: &paymenttypes.Money{Amount: "100", Currency: "RUB"}, + Card: &model.CardEndpoint{Pan: "2200700142860161", ExpMonth: 3, ExpYear: 2030}, + }, + { + TargetRef: "recipient-2", + Amount: &paymenttypes.Money{Amount: "150", Currency: "RUB"}, + Card: &model.CardEndpoint{Pan: "2200700142860162", ExpMonth: 4, ExpYear: 2030}, + }, + }) + if err != nil { + t.Fatalf("EncodePayoutTargets returned error: %v", err) + } + + intent := testIntent(model.PaymentKindPayout) + intent.Attributes = map[string]string{ + batchmeta.AttrPayoutTargets: targetsRaw, + } + + graph, err := compiler.Compile(Input{ + IntentSnapshot: intent, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Compile returned error: %v", err) + } + if graph == nil { + t.Fatal("expected graph") + } + if got, want := len(graph.Steps), 11; got != want { + t.Fatalf("steps count mismatch: got=%d want=%d", got, want) + } + if got, want := graph.Steps[0].StepCode, "edge.10_20.ledger.block"; got != want { + t.Fatalf("first step mismatch: got=%q want=%q", got, want) + } + + sendSteps := make([]Step, 0, 2) + for i := range graph.Steps { + step := graph.Steps[i] + if step.StepCode != "hop.20.card_payout.send" { + continue + } + sendSteps = append(sendSteps, step) + } + if got, want := len(sendSteps), 2; got != want { + t.Fatalf("send steps mismatch: got=%d want=%d", got, want) + } + + seenTargets := map[string]string{} + for i := range sendSteps { + step := sendSteps[i] + if got, want := step.DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) { + t.Fatalf("send step deps mismatch: got=%v want=%v", got, want) + } + targetRef := step.Metadata[batchmeta.MetaPayoutTargetRef] + amount := step.Metadata[batchmeta.MetaAmount] + if targetRef == "" || amount == "" { + t.Fatalf("expected target metadata on send step, got=%v", step.Metadata) + } + seenTargets[targetRef] = amount + } + if got, want := seenTargets["recipient-1"], "100"; got != want { + t.Fatalf("recipient-1 amount mismatch: got=%q want=%q", got, want) + } + if got, want := seenTargets["recipient-2"], "150"; got != want { + t.Fatalf("recipient-2 amount mismatch: got=%q want=%q", got, want) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go index bc2221b5..27543f36 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -3,6 +3,7 @@ package xplan import ( "strings" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" @@ -65,6 +66,13 @@ func (s *svc) applyDefaultBoundary( case isInternalRail(from.rail) && isExternalRail(to.rail): internalRail := internalRailForBoundary(from, to) + targets, err := payoutTargetsFromIntent(intent) + if err != nil { + return err + } + if to.rail == model.RailCardPayout && len(targets) > 0 { + return s.applyBatchCardPayoutBoundary(ex, from, to, internalRail, intent, targets) + } ex.appendMain(makeFundsBlockStep(from, to, internalRail)) observeRef, err := s.ensureExternalObserved(ex, to, intent) if err != nil { @@ -96,6 +104,40 @@ func (s *svc) applyDefaultBoundary( } } +func (s *svc) applyBatchCardPayoutBoundary( + ex *expansion, + from normalizedHop, + to normalizedHop, + internalRail model.Rail, + intent model.PaymentIntent, + targets []batchmeta.PayoutTarget, +) error { + blockRef := ex.appendMain(makeFundsBlockStep(from, to, internalRail)) + for i := range targets { + target := targets[i] + if target.Amount == nil { + return merrors.InvalidArgument("intent_snapshot.attributes[" + batchmeta.AttrPayoutTargets + "]: amount is required") + } + if len(targets) > 1 && target.Card == nil { + return merrors.InvalidArgument("intent_snapshot.attributes[" + batchmeta.AttrPayoutTargets + "]: card is required for each target") + } + targetMeta := batchmeta.StepMetadataForTarget(target, i) + + sendStep := makeRailSendStep(to, intent) + sendStep.DependsOn = []string{blockRef} + sendStep.Metadata = cloneMetadata(targetMeta) + sendRef := ex.appendBranch(sendStep) + + observeStep := makeRailObserveStep(to, intent) + observeStep.DependsOn = []string{sendRef} + observeStep.Metadata = cloneMetadata(targetMeta) + observeRef := ex.appendBranch(observeStep) + + appendSettlementBranchesWithMetadata(ex, from, to, internalRail, sendRef, observeRef, targetMeta) + } + return nil +} + func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) { key := observedKey(hop) if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" { @@ -252,7 +294,21 @@ func appendSettlementBranches( break } } + appendSettlementBranchesWithMetadata(ex, from, to, rail, anchorSendRef, anchorObserveRef, nil) +} +func appendSettlementBranchesWithMetadata( + ex *expansion, + from normalizedHop, + to normalizedHop, + rail model.Rail, + anchorSendRef string, + anchorObserveRef string, + metadata map[string]string, +) { + if strings.TrimSpace(anchorObserveRef) == "" { + return + } successStep := Step{ StepCode: edgeCode(from, to, rail, "debit"), Kind: StepKindFundsDebit, @@ -264,7 +320,7 @@ func appendSettlementBranches( Visibility: model.ReportVisibilityHidden, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{anchorObserveRef}, - Metadata: map[string]string{"mode": "finalize_debit"}, + Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "finalize_debit"}), } ex.appendBranch(successStep) @@ -280,7 +336,7 @@ func appendSettlementBranches( Visibility: model.ReportVisibilityHidden, CommitPolicy: model.CommitPolicyAfterFailure, CommitAfter: []string{anchorSendRef}, - Metadata: map[string]string{"mode": "unlock_hold"}, + Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "unlock_hold"}), } ex.appendBranch(sendFailureStep) } @@ -296,7 +352,47 @@ func appendSettlementBranches( Visibility: model.ReportVisibilityHidden, CommitPolicy: model.CommitPolicyAfterFailure, CommitAfter: []string{anchorObserveRef}, - Metadata: map[string]string{"mode": "unlock_hold"}, + Metadata: mergeStepMetadata(metadata, map[string]string{"mode": "unlock_hold"}), } ex.appendBranch(failureStep) } + +func mergeStepMetadata(left, right map[string]string) map[string]string { + if len(left) == 0 && len(right) == 0 { + return nil + } + out := cloneMetadata(left) + if out == nil { + out = map[string]string{} + } + for key, value := range right { + k := strings.TrimSpace(key) + if k == "" { + continue + } + v := strings.TrimSpace(value) + if v == "" { + continue + } + out[k] = v + } + if len(out) == 0 { + return nil + } + return out +} + +func payoutTargetsFromIntent(intent model.PaymentIntent) ([]batchmeta.PayoutTarget, error) { + if len(intent.Attributes) == 0 { + return nil, nil + } + raw := strings.TrimSpace(intent.Attributes[batchmeta.AttrPayoutTargets]) + if raw == "" { + return nil, nil + } + targets, err := batchmeta.DecodePayoutTargets(raw) + if err != nil { + return nil, merrors.InvalidArgument("intent_snapshot.attributes[" + batchmeta.AttrPayoutTargets + "] is invalid") + } + return targets, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go index 5315039c..67c2948f 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go @@ -8,6 +8,7 @@ import ( "github.com/shopspring/decimal" mntxclient "github.com/tech/sendico/gateway/mntx/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" @@ -45,11 +46,11 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s return nil, merrors.InvalidArgument("card payout send: unsupported action") } - card, err := payoutDestinationCard(req.Payment) + card, err := payoutDestinationCard(req.Payment, req.Step.Metadata) if err != nil { return nil, err } - amountMinor, currency, err := cardPayoutAmountMinor(req.Payment) + amountMinor, currency, err := cardPayoutAmountMinor(req.Payment, req.Step.Metadata) if err != nil { return nil, err } @@ -59,7 +60,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s payoutRef := cardPayoutRef(req.Payment, stepToken) idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken) projectID := cardPayoutProjectID(req.Payment) - customer := cardPayoutCustomerFromPayment(req.Payment, card) + customer := cardPayoutCustomerFromPayment(req.Payment, card, req.Step.Metadata) cardHolder := cardPayoutCardholder(card, customer) metadata := cardPayoutMetadata(req.Payment, req.Step) intentRef := strings.TrimSpace(req.Payment.IntentSnapshot.Ref) @@ -153,7 +154,10 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s return &sexec.ExecuteOutput{StepExecution: step}, nil } -func payoutDestinationCard(payment *agg.Payment) (*model.CardEndpoint, error) { +func payoutDestinationCard(payment *agg.Payment, metadata map[string]string) (*model.CardEndpoint, error) { + if card, ok := batchmeta.CardFromMetadata(metadata); ok && card != nil { + return card, nil + } if payment == nil { return nil, merrors.InvalidArgument("card payout send: payment is required") } @@ -164,7 +168,10 @@ func payoutDestinationCard(payment *agg.Payment) (*model.CardEndpoint, error) { return destination.Card, nil } -func cardPayoutMoney(payment *agg.Payment) *paymenttypes.Money { +func cardPayoutMoney(payment *agg.Payment, metadata map[string]string) *paymenttypes.Money { + if override, ok := batchmeta.AmountFromMetadata(metadata); ok && override != nil { + return override + } if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.ExpectedSettlementAmount != nil { return payment.QuoteSnapshot.ExpectedSettlementAmount } @@ -174,8 +181,8 @@ func cardPayoutMoney(payment *agg.Payment) *paymenttypes.Money { return payment.IntentSnapshot.Amount } -func cardPayoutAmountMinor(payment *agg.Payment) (int64, string, error) { - money := cardPayoutMoney(payment) +func cardPayoutAmountMinor(payment *agg.Payment, metadata map[string]string) (int64, string, error) { + money := cardPayoutMoney(payment, metadata) if money == nil { return 0, "", merrors.InvalidArgument("card payout send: payout amount is required") } @@ -261,7 +268,7 @@ func cardPayoutProjectID(payment *agg.Payment) int64 { return value } -func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoint) cardPayoutCustomer { +func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoint, stepMetadata map[string]string) cardPayoutCustomer { customer := cardPayoutCustomer{} if payment == nil { return customer @@ -275,7 +282,7 @@ func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoin cardholderSurname = strings.TrimSpace(card.CardholderSurname) cardCountry = strings.ToUpper(strings.TrimSpace(card.Country)) } - attrs := payment.IntentSnapshot.Attributes + attrs := mergeStringMaps(payment.IntentSnapshot.Attributes, stepMetadata) intentCustomer := payment.IntentSnapshot.Customer if intentCustomer != nil { customer.id = strings.TrimSpace(intentCustomer.ID) @@ -289,6 +296,38 @@ func cardPayoutCustomerFromPayment(payment *agg.Payment, card *model.CardEndpoin customer.city = strings.TrimSpace(intentCustomer.City) customer.address = strings.TrimSpace(intentCustomer.Address) } + if override, ok := batchmeta.CustomerFromMetadata(stepMetadata); ok && override != nil { + if customer.id == "" { + customer.id = strings.TrimSpace(override.ID) + } + if customer.firstName == "" { + customer.firstName = strings.TrimSpace(override.FirstName) + } + if customer.middleName == "" { + customer.middleName = strings.TrimSpace(override.MiddleName) + } + if customer.lastName == "" { + customer.lastName = strings.TrimSpace(override.LastName) + } + if customer.ip == "" { + customer.ip = strings.TrimSpace(override.IP) + } + if customer.zip == "" { + customer.zip = strings.TrimSpace(override.Zip) + } + if customer.country == "" { + customer.country = strings.ToUpper(strings.TrimSpace(override.Country)) + } + if customer.state == "" { + customer.state = strings.TrimSpace(override.State) + } + if customer.city == "" { + customer.city = strings.TrimSpace(override.City) + } + if customer.address == "" { + customer.address = strings.TrimSpace(override.Address) + } + } customer.id = firstNonEmpty(customer.id, cardPayoutAttribute(attrs, "customer_id", "customerId", "initiator_ref", "initiatorRef"), @@ -382,6 +421,37 @@ func cardPayoutMetadata(payment *agg.Payment, step xplan.Step) map[string]string if outgoingLeg := strings.TrimSpace(string(step.Rail)); outgoingLeg != "" { out[settlementMetadataOutgoingLeg] = outgoingLeg } + if targetRef := strings.TrimSpace(step.Metadata[batchmeta.MetaPayoutTargetRef]); targetRef != "" { + out[batchmeta.MetaPayoutTargetRef] = targetRef + } + if targetIndex := strings.TrimSpace(step.Metadata[batchmeta.MetaPayoutTargetIndex]); targetIndex != "" { + out[batchmeta.MetaPayoutTargetIndex] = targetIndex + } + if len(out) == 0 { + return nil + } + return out +} + +func mergeStringMaps(left, right map[string]string) map[string]string { + if len(left) == 0 && len(right) == 0 { + return nil + } + out := map[string]string{} + for key, value := range left { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } + for key, value := range right { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } if len(out) == 0 { return nil } diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go index 8bfce504..d7bc7899 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go @@ -7,6 +7,7 @@ import ( mntxclient "github.com/tech/sendico/gateway/mntx/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" @@ -140,6 +141,99 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin } } +func TestGatewayCardPayoutExecutor_ExecuteCardPayout_UsesStepMetadataOverrides(t *testing.T) { + orgID := bson.NewObjectID() + + var payoutReq *mntxv1.CardPayoutRequest + executor := &gatewayCardPayoutExecutor{ + mntxClient: &mntxclient.Fake{ + CreateCardPayoutFn: func(_ context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + payoutReq = req + return &mntxv1.CardPayoutResponse{ + Payout: &mntxv1.CardPayoutState{ + PayoutId: "payout-remote-2", + }, + }, nil + }, + }, + } + + req := sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-2", + IdempotencyKey: "idem-2", + QuotationRef: "quote-2", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-2", + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "2200700142860999", + ExpMonth: 1, + ExpYear: 2030, + }, + }, + Amount: &paymenttypes.Money{ + Amount: "1.000000", + Currency: "USDT", + }, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + ExpectedSettlementAmount: &paymenttypes.Money{ + Amount: "76.50", + Currency: "RUB", + }, + QuoteRef: "quote-2", + }, + }, + Step: xplan.Step{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Action: model.RailOperationSend, + Rail: model.RailCardPayout, + Gateway: paymenttypes.DefaultCardsGatewayID, + InstanceID: paymenttypes.DefaultCardsGatewayID, + Metadata: map[string]string{ + batchmeta.MetaPayoutTargetRef: "recipient-2", + batchmeta.MetaAmount: "150", + batchmeta.MetaCurrency: "RUB", + batchmeta.MetaCardPan: "2200700142860162", + batchmeta.MetaCardExpMonth: "4", + batchmeta.MetaCardExpYear: "2030", + }, + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_4_card_payout_send", + StepCode: "hop.4.card_payout.send", + Attempt: 1, + }, + } + + out, err := executor.ExecuteCardPayout(context.Background(), req) + if err != nil { + t.Fatalf("ExecuteCardPayout returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if payoutReq == nil { + t.Fatal("expected payout request to be submitted") + } + if got, want := payoutReq.GetAmountMinor(), int64(15000); got != want { + t.Fatalf("amount_minor mismatch: got=%d want=%d", got, want) + } + if got, want := payoutReq.GetCurrency(), "RUB"; got != want { + t.Fatalf("currency mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReq.GetCardPan(), "2200700142860162"; got != want { + t.Fatalf("card pan mismatch: got=%q want=%q", got, want) + } + if got, want := payoutReq.GetMetadata()[batchmeta.MetaPayoutTargetRef], "recipient-2"; got != want { + t.Fatalf("target_ref metadata mismatch: got=%q want=%q", got, want) + } +} + func TestGatewayCardPayoutExecutor_ExecuteCardPayout_RequiresMntxClient(t *testing.T) { orgID := bson.NewObjectID() diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go index f511c74e..1fd8ce43 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go @@ -7,6 +7,7 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" @@ -123,6 +124,9 @@ func ledgerAmountForStep( step xplan.Step, action model.RailOperation, ) (*moneyv1.Money, error) { + if override, ok := batchmeta.AmountFromMetadata(step.Metadata); ok && override != nil { + return protoMoneyRequired(override, "ledger step: override amount is invalid") + } sourceMoney := sourceMoneyForLedger(payment) settlementMoney := settlementMoneyForLedger(payment, sourceMoney) payoutMoney := payoutMoneyForLedger(payment, settlementMoney) diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go index 87d12a35..8df29768 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go @@ -7,6 +7,7 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" @@ -380,6 +381,52 @@ func TestGatewayLedgerExecutor_ExecuteLedger_ReleaseUsesHoldToOperatingAndPayout } } +func TestGatewayLedgerExecutor_ExecuteLedger_UsesStepAmountOverride(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var transferReq *ledgerv1.TransferRequest + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-override"}, nil + }, + }, + } + + _, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_3_4_ledger_block", + StepCode: "edge.3_4.ledger.block", + Action: model.RailOperationBlock, + Rail: model.RailLedger, + Metadata: map[string]string{ + batchmeta.MetaAmount: "40", + batchmeta.MetaCurrency: "RUB", + }, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_3_4_ledger_block", + StepCode: "edge.3_4.ledger.block", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if transferReq == nil { + t.Fatal("expected ledger transfer request") + } + if got, want := transferReq.GetMoney().GetAmount(), "40"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := transferReq.GetMoney().GetCurrency(), "RUB"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } +} + func TestGatewayLedgerExecutor_ExecuteLedger_UsesMetadataRoleOverrides(t *testing.T) { orgID := bson.NewObjectID() payment := testLedgerExecutorPayment(orgID) -- 2.49.1 From a794aff5f333acc8d8c50da12be7ae5b809cdeec Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 26 Feb 2026 23:37:09 +0100 Subject: [PATCH 23/23] release version bump --- api/billing/documents/go.mod | 2 +- api/billing/documents/go.sum | 4 ++-- api/billing/fees/go.mod | 2 +- api/billing/fees/go.sum | 4 ++-- api/discovery/go.mod | 2 +- api/discovery/go.sum | 4 ++-- api/fx/ingestor/go.mod | 2 +- api/fx/ingestor/go.sum | 4 ++-- api/fx/oracle/go.mod | 2 +- api/fx/oracle/go.sum | 4 ++-- api/gateway/chain/go.mod | 2 +- api/gateway/chain/go.sum | 4 ++-- api/gateway/mntx/go.mod | 2 +- api/gateway/mntx/go.sum | 4 ++-- api/gateway/tgsettle/go.mod | 2 +- api/gateway/tgsettle/go.sum | 4 ++-- api/gateway/tron/go.mod | 4 ++-- api/gateway/tron/go.sum | 8 ++++---- api/ledger/go.mod | 2 +- api/ledger/go.sum | 4 ++-- api/notification/go.mod | 2 +- api/notification/go.sum | 4 ++-- api/payments/methods/go.mod | 2 +- api/payments/methods/go.sum | 4 ++-- api/payments/orchestrator/go.mod | 2 +- api/payments/orchestrator/go.sum | 4 ++-- api/payments/quotation/go.mod | 2 +- api/payments/quotation/go.sum | 4 ++-- api/pkg/go.mod | 2 +- api/pkg/go.sum | 4 ++-- api/server/go.mod | 2 +- api/server/go.sum | 4 ++-- frontend/pweb/pubspec.yaml | 2 +- version | 2 +- 34 files changed, 53 insertions(+), 53 deletions(-) diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index ef5dee75..3d84c5ab 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -65,6 +65,6 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index cc640089..01ebca6d 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -258,8 +258,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index bd6ebaec..17de5f1f 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -50,6 +50,6 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 ) diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index 34b015ee..7d39e38b 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -208,8 +208,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/discovery/go.mod b/api/discovery/go.mod index a95dc140..d33fbe8a 100644 --- a/api/discovery/go.mod +++ b/api/discovery/go.mod @@ -43,7 +43,7 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/discovery/go.sum b/api/discovery/go.sum index 34b015ee..7d39e38b 100644 --- a/api/discovery/go.sum +++ b/api/discovery/go.sum @@ -208,8 +208,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index 336b940e..ab7af2cd 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -47,7 +47,7 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index 34b015ee..7d39e38b 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -208,8 +208,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/oracle/go.mod b/api/fx/oracle/go.mod index b02c4f5e..0f2bca44 100644 --- a/api/fx/oracle/go.mod +++ b/api/fx/oracle/go.mod @@ -48,5 +48,5 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/fx/oracle/go.sum b/api/fx/oracle/go.sum index 34b015ee..7d39e38b 100644 --- a/api/fx/oracle/go.sum +++ b/api/fx/oracle/go.sum @@ -208,8 +208,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 80fb8be7..a84aa694 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -91,5 +91,5 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index b79afa98..58fe594e 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -355,8 +355,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index 84d8b4da..0ca8b1f3 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -51,5 +51,5 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index 1279c139..3cf47150 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -210,8 +210,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod index 72b1618a..39280fb5 100644 --- a/api/gateway/tgsettle/go.mod +++ b/api/gateway/tgsettle/go.mod @@ -48,5 +48,5 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum index 34b015ee..7d39e38b 100644 --- a/api/gateway/tgsettle/go.sum +++ b/api/gateway/tgsettle/go.sum @@ -208,8 +208,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index 6a377626..8275bd54 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -99,6 +99,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index 91490bb2..ac4d8360 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -374,10 +374,10 @@ 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/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU= -google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/ledger/go.mod b/api/ledger/go.mod index 6e37b5c5..dbc7aa60 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -49,5 +49,5 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/ledger/go.sum b/api/ledger/go.sum index beac9979..c5c99dfc 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -210,8 +210,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/notification/go.mod b/api/notification/go.mod index c5046909..85268022 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -50,7 +50,7 @@ require ( golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/notification/go.sum b/api/notification/go.sum index b8f37da5..d2c6ea29 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -225,8 +225,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/methods/go.mod b/api/payments/methods/go.mod index ce43fea7..0d82a7de 100644 --- a/api/payments/methods/go.mod +++ b/api/payments/methods/go.mod @@ -49,5 +49,5 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/payments/methods/go.sum b/api/payments/methods/go.sum index beac9979..c5c99dfc 100644 --- a/api/payments/methods/go.sum +++ b/api/payments/methods/go.sum @@ -210,8 +210,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index b6fd70e2..5d0ad7ab 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -64,5 +64,5 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index 528fb75f..525536be 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -211,8 +211,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/quotation/go.mod b/api/payments/quotation/go.mod index 05c55ac6..627049e0 100644 --- a/api/payments/quotation/go.mod +++ b/api/payments/quotation/go.mod @@ -63,5 +63,5 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum index 3324a09c..7a3326e4 100644 --- a/api/payments/quotation/go.sum +++ b/api/payments/quotation/go.sum @@ -211,8 +211,8 @@ 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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/pkg/go.mod b/api/pkg/go.mod index 4239d956..50404c84 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -92,6 +92,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.5.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/pkg/go.sum b/api/pkg/go.sum index d4a9d30d..e12e7e78 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -271,8 +271,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/server/go.mod b/api/server/go.mod index 5fcf53d6..69a8c3dc 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -146,5 +146,5 @@ require ( 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-20260223185530-2f722ef697dc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/server/go.sum b/api/server/go.sum index 232641d1..7efa7df5 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -366,8 +366,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU= google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index a14f021a..e3495768 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.6.0+507 +version: 3.0.0+605 environment: sdk: ^3.8.1 diff --git a/version b/version index 914ec967..56fea8a0 100644 --- a/version +++ b/version @@ -1 +1 @@ -2.6.0 \ No newline at end of file +3.0.0 \ No newline at end of file -- 2.49.1