From 10bcdb4fe27e48cb031420b126c9534d331c949d Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 6 Mar 2026 15:42:32 +0100 Subject: [PATCH] Chimera Settle service --- api/gateway/chsettle/.air.toml | 46 + api/gateway/chsettle/.gitignore | 4 + api/gateway/chsettle/config.dev.yml | 52 + api/gateway/chsettle/config.yml | 52 + api/gateway/chsettle/go.mod | 52 + api/gateway/chsettle/go.sum | 221 ++++ .../chsettle/internal/appversion/version.go | 27 + .../internal/server/internal/serverimp.go | 230 ++++ .../chsettle/internal/server/server.go | 11 + .../service/gateway/confirmation_flow.go | 443 +++++++ .../internal/service/gateway/connector.go | 413 +++++++ .../service/gateway/connector_test.go | 119 ++ .../service/gateway/outbox_reliable.go | 47 + .../service/gateway/scenario_simulator.go | 302 +++++ .../gateway/scenario_simulator_test.go | 105 ++ .../internal/service/gateway/service.go | 1040 +++++++++++++++++ .../internal/service/gateway/service_test.go | 417 +++++++ .../service/gateway/transfer_notifications.go | 108 ++ .../internal/service/treasury/bot/commands.go | 94 ++ .../internal/service/treasury/bot/dialogs.go | 73 ++ .../internal/service/treasury/bot/markup.go | 18 + .../internal/service/treasury/bot/router.go | 527 +++++++++ .../service/treasury/bot/router_test.go | 362 ++++++ .../internal/service/treasury/config.go | 11 + .../service/treasury/ledger/client.go | 312 +++++ .../treasury/ledger/discovery_client.go | 235 ++++ .../internal/service/treasury/module.go | 205 ++++ .../internal/service/treasury/scheduler.go | 327 ++++++ .../internal/service/treasury/service.go | 457 ++++++++ .../internal/service/treasury/validator.go | 178 +++ api/gateway/chsettle/main.go | 17 + .../chsettle/storage/model/execution.go | 65 ++ .../chsettle/storage/model/storable.go | 29 + .../chsettle/storage/model/treasury.go | 59 + .../chsettle/storage/mongo/repository.go | 132 +++ .../chsettle/storage/mongo/store/payments.go | 159 +++ .../storage/mongo/store/payments_test.go | 245 ++++ .../mongo/store/pending_confirmations.go | 205 ++++ .../mongo/store/telegram_confirmations.go | 91 ++ .../storage/mongo/store/treasury_requests.go | 402 +++++++ .../mongo/store/treasury_telegram_users.go | 87 ++ .../chsettle/storage/mongo/transaction.go | 38 + api/gateway/chsettle/storage/storage.go | 53 + 43 files changed, 8070 insertions(+) create mode 100644 api/gateway/chsettle/.air.toml create mode 100644 api/gateway/chsettle/.gitignore create mode 100644 api/gateway/chsettle/config.dev.yml create mode 100644 api/gateway/chsettle/config.yml create mode 100644 api/gateway/chsettle/go.mod create mode 100644 api/gateway/chsettle/go.sum create mode 100644 api/gateway/chsettle/internal/appversion/version.go create mode 100644 api/gateway/chsettle/internal/server/internal/serverimp.go create mode 100644 api/gateway/chsettle/internal/server/server.go create mode 100644 api/gateway/chsettle/internal/service/gateway/confirmation_flow.go create mode 100644 api/gateway/chsettle/internal/service/gateway/connector.go create mode 100644 api/gateway/chsettle/internal/service/gateway/connector_test.go create mode 100644 api/gateway/chsettle/internal/service/gateway/outbox_reliable.go create mode 100644 api/gateway/chsettle/internal/service/gateway/scenario_simulator.go create mode 100644 api/gateway/chsettle/internal/service/gateway/scenario_simulator_test.go create mode 100644 api/gateway/chsettle/internal/service/gateway/service.go create mode 100644 api/gateway/chsettle/internal/service/gateway/service_test.go create mode 100644 api/gateway/chsettle/internal/service/gateway/transfer_notifications.go create mode 100644 api/gateway/chsettle/internal/service/treasury/bot/commands.go create mode 100644 api/gateway/chsettle/internal/service/treasury/bot/dialogs.go create mode 100644 api/gateway/chsettle/internal/service/treasury/bot/markup.go create mode 100644 api/gateway/chsettle/internal/service/treasury/bot/router.go create mode 100644 api/gateway/chsettle/internal/service/treasury/bot/router_test.go create mode 100644 api/gateway/chsettle/internal/service/treasury/config.go create mode 100644 api/gateway/chsettle/internal/service/treasury/ledger/client.go create mode 100644 api/gateway/chsettle/internal/service/treasury/ledger/discovery_client.go create mode 100644 api/gateway/chsettle/internal/service/treasury/module.go create mode 100644 api/gateway/chsettle/internal/service/treasury/scheduler.go create mode 100644 api/gateway/chsettle/internal/service/treasury/service.go create mode 100644 api/gateway/chsettle/internal/service/treasury/validator.go create mode 100644 api/gateway/chsettle/main.go create mode 100644 api/gateway/chsettle/storage/model/execution.go create mode 100644 api/gateway/chsettle/storage/model/storable.go create mode 100644 api/gateway/chsettle/storage/model/treasury.go create mode 100644 api/gateway/chsettle/storage/mongo/repository.go create mode 100644 api/gateway/chsettle/storage/mongo/store/payments.go create mode 100644 api/gateway/chsettle/storage/mongo/store/payments_test.go create mode 100644 api/gateway/chsettle/storage/mongo/store/pending_confirmations.go create mode 100644 api/gateway/chsettle/storage/mongo/store/telegram_confirmations.go create mode 100644 api/gateway/chsettle/storage/mongo/store/treasury_requests.go create mode 100644 api/gateway/chsettle/storage/mongo/store/treasury_telegram_users.go create mode 100644 api/gateway/chsettle/storage/mongo/transaction.go create mode 100644 api/gateway/chsettle/storage/storage.go diff --git a/api/gateway/chsettle/.air.toml b/api/gateway/chsettle/.air.toml new file mode 100644 index 00000000..16f8c34b --- /dev/null +++ b/api/gateway/chsettle/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + entrypoint = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go", "_templ.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/api/gateway/chsettle/.gitignore b/api/gateway/chsettle/.gitignore new file mode 100644 index 00000000..436d3e5e --- /dev/null +++ b/api/gateway/chsettle/.gitignore @@ -0,0 +1,4 @@ +internal/generated +.gocache +app +tmp diff --git a/api/gateway/chsettle/config.dev.yml b/api/gateway/chsettle/config.dev.yml new file mode 100644 index 00000000..b68a6f50 --- /dev/null +++ b/api/gateway/chsettle/config.dev.yml @@ -0,0 +1,52 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50080" + advertise_host: "dev-chsettle-gateway" + enable_reflection: true + enable_health: true + +metrics: + address: ":9406" + +database: + driver: mongodb + settings: + host_env: CHSETTLE_GATEWAY_MONGO_HOST + port_env: CHSETTLE_GATEWAY_MONGO_PORT + database_env: CHSETTLE_GATEWAY_MONGO_DATABASE + user_env: CHSETTLE_GATEWAY_MONGO_USER + password_env: CHSETTLE_GATEWAY_MONGO_PASSWORD + auth_source_env: CHSETTLE_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: CHSETTLE_GATEWAY_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: ChimeraSettle Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +gateway: + rail: "SETTLEMENT" + target_chat_id_env: CHSETTLE_GATEWAY_CHAT_ID + timeout_seconds: 345600 + accepted_user_ids: [] + success_reaction: "\U0001FAE1" + +treasury: + execution_delay: 60s + poll_interval: 60s + ledger: + timeout: 5s + limits: + max_amount_per_operation: "1000000" + max_daily_amount: "5000000" diff --git a/api/gateway/chsettle/config.yml b/api/gateway/chsettle/config.yml new file mode 100644 index 00000000..43202a0d --- /dev/null +++ b/api/gateway/chsettle/config.yml @@ -0,0 +1,52 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50080" + advertise_host: "sendico_chsettle_gateway" + enable_reflection: true + enable_health: true + +metrics: + address: ":9406" + +database: + driver: mongodb + settings: + host_env: CHSETTLE_GATEWAY_MONGO_HOST + port_env: CHSETTLE_GATEWAY_MONGO_PORT + database_env: CHSETTLE_GATEWAY_MONGO_DATABASE + user_env: CHSETTLE_GATEWAY_MONGO_USER + password_env: CHSETTLE_GATEWAY_MONGO_PASSWORD + auth_source_env: CHSETTLE_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: CHSETTLE_GATEWAY_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: ChimeraSettle Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +gateway: + rail: "SETTLEMENT" + target_chat_id_env: CHSETTLE_GATEWAY_CHAT_ID + timeout_seconds: 345600 + accepted_user_ids: [] + success_reaction: "\U0001FAE1" + +treasury: + execution_delay: 60s + poll_interval: 60s + ledger: + timeout: 5s + limits: + max_amount_per_operation: "" + max_daily_amount: "" diff --git a/api/gateway/chsettle/go.mod b/api/gateway/chsettle/go.mod new file mode 100644 index 00000000..dcadcd7d --- /dev/null +++ b/api/gateway/chsettle/go.mod @@ -0,0 +1,52 @@ +module github.com/tech/sendico/gateway/chsettle + +go 1.25.7 + +replace github.com/tech/sendico/pkg => ../../pkg + +replace github.com/tech/sendico/gateway/common => ../common + +require ( + github.com/tech/sendico/gateway/common v0.1.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 + google.golang.org/grpc v1.79.2 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + 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.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.20.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + 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.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 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect +) diff --git a/api/gateway/chsettle/go.sum b/api/gateway/chsettle/go.sum new file mode 100644 index 00000000..f8c3c7eb --- /dev/null +++ b/api/gateway/chsettle/go.sum @@ -0,0 +1,221 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ= +github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.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= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/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= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.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= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-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.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/gateway/chsettle/internal/appversion/version.go b/api/gateway/chsettle/internal/appversion/version.go new file mode 100644 index 00000000..3de8c146 --- /dev/null +++ b/api/gateway/chsettle/internal/appversion/version.go @@ -0,0 +1,27 @@ +package appversion + +import ( + "github.com/tech/sendico/pkg/version" + vf "github.com/tech/sendico/pkg/version/factory" +) + +// Build information. Populated at build-time. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +func Create() version.Printer { + info := version.Info{ + Program: "Sendico Payment Gateway Service", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + return vf.Create(&info) +} diff --git a/api/gateway/chsettle/internal/server/internal/serverimp.go b/api/gateway/chsettle/internal/server/internal/serverimp.go new file mode 100644 index 00000000..98877b58 --- /dev/null +++ b/api/gateway/chsettle/internal/server/internal/serverimp.go @@ -0,0 +1,230 @@ +package serverimp + +import ( + "context" + "os" + "strings" + "time" + + "github.com/tech/sendico/gateway/chsettle/internal/service/gateway" + "github.com/tech/sendico/gateway/chsettle/storage" + gatewaymongo "github.com/tech/sendico/gateway/chsettle/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" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config + app *grpcapp.App[storage.Repository] + service *gateway.Service + + discoveryWatcher *discovery.RegistryWatcher + discoveryReg *discovery.Registry +} + +type config struct { + *grpcapp.Config `yaml:",inline"` + Gateway gatewayConfig `yaml:"gateway"` + Treasury treasuryConfig `yaml:"treasury"` +} + +type gatewayConfig struct { + Rail string `yaml:"rail"` + TargetChatIDEnv string `yaml:"target_chat_id_env"` + TimeoutSeconds int32 `yaml:"timeout_seconds"` + AcceptedUserIDs []string `yaml:"accepted_user_ids"` + SuccessReaction string `yaml:"success_reaction"` +} + +type treasuryConfig struct { + ExecutionDelay time.Duration `yaml:"execution_delay"` + PollInterval time.Duration `yaml:"poll_interval"` + Ledger ledgerConfig `yaml:"ledger"` + Limits treasuryLimitsConfig `yaml:"limits"` +} + +type treasuryLimitsConfig struct { + MaxAmountPerOperation string `yaml:"max_amount_per_operation"` + MaxDailyAmount string `yaml:"max_daily_amount"` +} + +type ledgerConfig struct { + Timeout time.Duration `yaml:"timeout"` +} + +func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { + return &Imp{ + logger: logger.Named("server"), + file: file, + debug: debug, + }, nil +} + +func (i *Imp) Shutdown() { + if i.app == nil { + return + } + timeout := 15 * time.Second + if i.config != nil && i.config.Runtime != nil { + timeout = i.config.Runtime.ShutdownTimeout() + } + if i.service != nil { + i.service.Shutdown() + } + if i.discoveryWatcher != nil { + i.discoveryWatcher.Stop() + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + i.app.Shutdown(ctx) +} + +func (i *Imp) Start() error { + cfg, err := i.loadConfig() + if err != nil { + i.logger.Error("Service startup aborted: invalid configuration", zap.Error(err)) + return err + } + i.config = cfg + + var broker mb.Broker + if cfg.Messaging != nil && cfg.Messaging.Driver != "" { + broker, err = msg.CreateMessagingBroker(i.logger, cfg.Messaging) + if err != nil { + i.logger.Warn("Failed to create messaging broker", zap.Error(err)) + } + } + if broker != nil { + registry := discovery.NewRegistry() + watcher, watcherErr := discovery.NewRegistryWatcher(i.logger, broker, registry) + if watcherErr != nil { + i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(watcherErr)) + } else if startErr := watcher.Start(); startErr != nil { + i.logger.Warn("Failed to start discovery registry watcher", zap.Error(startErr)) + } else { + i.discoveryWatcher = watcher + i.discoveryReg = registry + i.logger.Info("Discovery registry watcher started") + } + } + + repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { + return gatewaymongo.New(logger, conn) + } + + serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { + invokeURI := "" + if cfg.GRPC != nil { + invokeURI = cfg.GRPC.DiscoveryInvokeURI() + } + msgSettings := map[string]any(nil) + if cfg.Messaging != nil { + msgSettings = cfg.Messaging.Settings + } + gwCfg := gateway.Config{ + Rail: cfg.Gateway.Rail, + TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv, + TimeoutSeconds: cfg.Gateway.TimeoutSeconds, + AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs, + SuccessReaction: cfg.Gateway.SuccessReaction, + InvokeURI: invokeURI, + MessagingSettings: msgSettings, + DiscoveryRegistry: i.discoveryReg, + Treasury: gateway.TreasuryConfig{ + ExecutionDelay: cfg.Treasury.ExecutionDelay, + PollInterval: cfg.Treasury.PollInterval, + Ledger: gateway.LedgerConfig{ + Timeout: cfg.Treasury.Ledger.Timeout, + }, + Limits: gateway.TreasuryLimitsConfig{ + MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation, + MaxDailyAmount: cfg.Treasury.Limits.MaxDailyAmount, + }, + }, + } + svc := gateway.NewService(logger, repo, producer, broker, gwCfg) + i.service = svc + return svc, nil + } + + app, err := grpcapp.NewApp(i.logger, "chsettle_gateway", cfg.Config, i.debug, repoFactory, serviceFactory) + if err != nil { + i.logger.Error("Service startup aborted: failed to construct app", zap.Error(err)) + return err + } + i.app = app + if err := i.app.Start(); err != nil { + i.logger.Error("Service startup aborted: app start failed", zap.Error(err)) + return err + } + grpcAddress := "" + metricsAddress := "" + if cfg.GRPC != nil { + grpcAddress = strings.TrimSpace(cfg.GRPC.Address) + } + if cfg.Metrics != nil { + metricsAddress = strings.TrimSpace(cfg.Metrics.Address) + } + i.logger.Info("ChimeraSettle gateway started", + zap.String("grpc_address", grpcAddress), + zap.String("metrics_address", metricsAddress)) + return nil +} + +func (i *Imp) loadConfig() (*config, error) { + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err + } + cfg := &config{Config: &grpcapp.Config{}} + if err := yaml.Unmarshal(data, cfg); err != nil { + i.logger.Error("Failed to parse configuration", zap.Error(err)) + return nil, err + } + if cfg.Runtime == nil { + cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} + } + if cfg.GRPC == nil { + cfg.GRPC = &routers.GRPCConfig{ + Network: "tcp", + Address: ":50080", + EnableReflection: true, + EnableHealth: true, + } + } + if cfg.Metrics == nil { + cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"} + } + if cfg.Treasury.ExecutionDelay <= 0 { + cfg.Treasury.ExecutionDelay = 30 * time.Second + } + if cfg.Treasury.PollInterval <= 0 { + cfg.Treasury.PollInterval = 30 * time.Second + } + if cfg.Treasury.Ledger.Timeout <= 0 { + cfg.Treasury.Ledger.Timeout = 5 * time.Second + } + cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail) + if cfg.Gateway.Rail == "" { + i.logger.Error("Invalid configuration: gateway rail is required") + return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail") + } + if !discovery.IsKnownRail(cfg.Gateway.Rail) { + i.logger.Error("Invalid configuration: gateway rail is unknown", zap.String("gateway_rail", cfg.Gateway.Rail)) + return nil, merrors.InvalidArgument("gateway rail must be a known token", "gateway.rail") + } + return cfg, nil +} diff --git a/api/gateway/chsettle/internal/server/server.go b/api/gateway/chsettle/internal/server/server.go new file mode 100644 index 00000000..1272e39f --- /dev/null +++ b/api/gateway/chsettle/internal/server/server.go @@ -0,0 +1,11 @@ +package server + +import ( + serverimp "github.com/tech/sendico/gateway/chsettle/internal/server/internal" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" +) + +func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return serverimp.Create(logger, file, debug) +} diff --git a/api/gateway/chsettle/internal/service/gateway/confirmation_flow.go b/api/gateway/chsettle/internal/service/gateway/confirmation_flow.go new file mode 100644 index 00000000..2d09c0e8 --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/confirmation_flow.go @@ -0,0 +1,443 @@ +package gateway + +import ( + "context" + "errors" + "regexp" + "strings" + "time" + + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" + tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.uber.org/zap" +) + +var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`) +var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`) + +func (s *Service) startConfirmationTimeoutWatcher() { + if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil { + return + } + if s.timeoutCancel != nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + s.timeoutCtx = ctx + s.timeoutCancel = cancel + s.timeoutWG.Add(1) + go func() { + defer s.timeoutWG.Done() + ticker := time.NewTicker(defaultConfirmationSweepInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.sweepExpiredConfirmations(ctx) + } + } + }() +} + +func (s *Service) sweepExpiredConfirmations(ctx context.Context) { + if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil { + return + } + expired, err := s.repo.PendingConfirmations().ListExpired(ctx, time.Now(), 100) + if err != nil { + s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err)) + return + } + for i := range expired { + pending := &expired[i] + if strings.TrimSpace(pending.RequestID) == "" { + continue + } + result := &model.ConfirmationResult{ + RequestID: pending.RequestID, + Status: model.ConfirmationStatusTimeout, + } + if err := s.publishPendingConfirmationResult(pending, result); err != nil { + s.logger.Warn("Failed to publish timeout confirmation result", zap.Error(err), zap.String("request_id", pending.RequestID)) + continue + } + if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil { + s.logger.Warn("Failed to remove expired pending confirmation", zap.Error(err), zap.String("request_id", pending.RequestID)) + } + } +} + +func (s *Service) persistPendingConfirmation(ctx context.Context, request *model.ConfirmationRequest) error { + if request == nil { + return merrors.InvalidArgument("confirmation request is nil", "request") + } + if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil { + return merrors.Internal("pending confirmations store unavailable") + } + timeout := request.TimeoutSeconds + if timeout <= 0 { + timeout = int32(defaultConfirmationTimeoutSeconds) + } + pending := &storagemodel.PendingConfirmation{ + RequestID: strings.TrimSpace(request.RequestID), + TargetChatID: strings.TrimSpace(request.TargetChatID), + AcceptedUserIDs: normalizeStringList(request.AcceptedUserIDs), + RequestedMoney: request.RequestedMoney, + SourceService: strings.TrimSpace(request.SourceService), + Rail: strings.TrimSpace(request.Rail), + ExpiresAt: time.Now().Add(time.Duration(timeout) * time.Second), + } + return s.repo.PendingConfirmations().Upsert(ctx, pending) +} + +func (s *Service) clearPendingConfirmation(ctx context.Context, requestID string) error { + if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil { + return nil + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return nil + } + return s.repo.PendingConfirmations().DeleteByRequestID(ctx, requestID) +} + +func (s *Service) onConfirmationDispatch(ctx context.Context, dispatch *model.ConfirmationRequestDispatch) error { + if dispatch == nil { + return merrors.InvalidArgument("confirmation dispatch is nil", "dispatch") + } + if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil { + return merrors.Internal("pending confirmations store unavailable") + } + requestID := strings.TrimSpace(dispatch.RequestID) + messageID := strings.TrimSpace(dispatch.MessageID) + if requestID == "" { + return merrors.InvalidArgument("confirmation request_id is required", "request_id") + } + if messageID == "" { + return merrors.InvalidArgument("confirmation message_id is required", "message_id") + } + if err := s.repo.PendingConfirmations().AttachMessage(ctx, requestID, messageID); err != nil { + if errors.Is(err, merrors.ErrNoData) { + s.logger.Info("Confirmation dispatch ignored: pending request not found", + zap.String("request_id", requestID), + zap.String("message_id", messageID)) + return nil + } + s.logger.Warn("Failed to attach confirmation message id", zap.Error(err), zap.String("request_id", requestID), zap.String("message_id", messageID)) + return err + } + s.logger.Info("Pending confirmation message attached", zap.String("request_id", requestID), zap.String("message_id", messageID)) + return nil +} + +func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) error { + if update == nil || update.Message == nil { + return nil + } + if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil { + return merrors.Internal("pending confirmations store unavailable") + } + message := update.Message + replyToID := strings.TrimSpace(message.ReplyToMessageID) + if replyToID == "" { + s.handleTreasuryTelegramUpdate(ctx, update) + return nil + } + replyFields := telegramReplyLogFields(update) + pending, err := s.repo.PendingConfirmations().FindByMessageID(ctx, replyToID) + if err != nil { + return err + } + if pending == nil { + if s.handleTreasuryTelegramUpdate(ctx, update) { + return nil + } + s.logger.Warn("Telegram confirmation reply dropped", + append(replyFields, + zap.String("outcome", "dropped"), + zap.String("reason", "no_pending_confirmation"), + )...) + return nil + } + replyFields = append(replyFields, + zap.String("request_id", strings.TrimSpace(pending.RequestID)), + zap.String("target_chat_id", strings.TrimSpace(pending.TargetChatID)), + ) + + if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) { + result := &model.ConfirmationResult{ + RequestID: pending.RequestID, + Status: model.ConfirmationStatusTimeout, + } + if err := s.publishPendingConfirmationResult(pending, result); err != nil { + return err + } + if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil { + return err + } + s.logger.Info("Telegram confirmation reply processed", + append(replyFields, + zap.String("outcome", "processed"), + zap.String("result_status", string(result.Status)), + zap.String("reason", "expired_confirmation"), + )...) + return nil + } + + if strings.TrimSpace(message.ChatID) != strings.TrimSpace(pending.TargetChatID) { + s.logger.Warn("Telegram confirmation reply dropped", + append(replyFields, + zap.String("outcome", "dropped"), + zap.String("reason", "chat_mismatch"), + zap.String("expected_chat_id", strings.TrimSpace(pending.TargetChatID)), + )...) + return nil + } + + if !isUserAllowed(message.FromUserID, pending.AcceptedUserIDs) { + result := &model.ConfirmationResult{ + RequestID: pending.RequestID, + Status: model.ConfirmationStatusRejected, + ParseError: "unauthorized_user", + RawReply: message, + } + if err := s.publishPendingConfirmationResult(pending, result); err != nil { + return err + } + if e := s.sendTelegramText(ctx, &model.TelegramTextRequest{ + RequestID: pending.RequestID, + ChatID: pending.TargetChatID, + ReplyToMessageID: message.MessageID, + Text: "Only approved users can confirm this payment.", + }); e != nil { + s.logger.Warn("Failed to create telegram text", append(replyFields, zap.Error(err))...) + } + if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil { + return err + } + s.logger.Info("Telegram confirmation reply processed", + append(replyFields, + zap.String("outcome", "processed"), + zap.String("result_status", string(result.Status)), + zap.String("reason", "unauthorized_user"), + )...) + return nil + } + + money, reason, err := parseConfirmationReply(message.Text) + if err != nil { + if markErr := s.repo.PendingConfirmations().MarkClarified(ctx, pending.RequestID); markErr != nil { + s.logger.Warn("Failed to mark confirmation as clarified", zap.Error(markErr), zap.String("request_id", pending.RequestID)) + } + if e := s.sendTelegramText(ctx, &model.TelegramTextRequest{ + RequestID: pending.RequestID, + ChatID: pending.TargetChatID, + ReplyToMessageID: message.MessageID, + Text: clarificationMessage(reason), + }); e != nil { + s.logger.Warn("Failed to create telegram text", append(replyFields, zap.Error(err))...) + } + s.logger.Warn("Telegram confirmation reply dropped", + append(replyFields, + zap.String("outcome", "dropped"), + zap.String("reason", "invalid_reply_format"), + zap.String("parse_reason", reason), + )...) + return nil + } + + status := model.ConfirmationStatusConfirmed + if pending.Clarified { + status = model.ConfirmationStatusClarified + } + result := &model.ConfirmationResult{ + RequestID: pending.RequestID, + Money: money, + RawReply: message, + Status: status, + } + if err := s.publishPendingConfirmationResult(pending, result); err != nil { + return err + } + if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil { + return err + } + s.logger.Info("Telegram confirmation reply processed", + append(replyFields, + zap.String("outcome", "processed"), + zap.String("result_status", string(result.Status)), + )...) + return nil +} + +func (s *Service) handleTreasuryTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool { + if s == nil || s.treasury == nil || update == nil || update.Message == nil { + return false + } + return s.treasury.HandleUpdate(ctx, update) +} + +func telegramReplyLogFields(update *model.TelegramWebhookUpdate) []zap.Field { + if update == nil || update.Message == nil { + return nil + } + message := update.Message + return []zap.Field{ + zap.Int64("update_id", update.UpdateID), + zap.String("message_id", strings.TrimSpace(message.MessageID)), + zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)), + zap.String("chat_id", strings.TrimSpace(message.ChatID)), + zap.String("from_user_id", strings.TrimSpace(message.FromUserID)), + } +} + +func (s *Service) publishPendingConfirmationResult(pending *storagemodel.PendingConfirmation, result *model.ConfirmationResult) error { + if pending == nil || result == nil { + return merrors.InvalidArgument("pending confirmation context is required") + } + if s == nil || s.producer == nil { + return merrors.Internal("messaging producer is not configured") + } + sourceService := strings.TrimSpace(pending.SourceService) + if sourceService == "" { + sourceService = string(mservice.PaymentGateway) + } + rail := strings.TrimSpace(pending.Rail) + if rail == "" { + rail = s.rail + } + env := confirmations.ConfirmationResult(string(mservice.PaymentGateway), result, sourceService, rail) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish confirmation result", zap.Error(err), + zap.String("request_id", strings.TrimSpace(result.RequestID)), + zap.String("status", string(result.Status)), + zap.String("source_service", sourceService), + zap.String("rail", rail)) + return err + } + return nil +} + +func (s *Service) sendTelegramText(_ context.Context, request *model.TelegramTextRequest) error { + if request == nil { + return merrors.InvalidArgument("telegram text request is nil", "request") + } + if s == nil || s.producer == nil { + return merrors.Internal("messaging producer is not configured") + } + request.ChatID = strings.TrimSpace(request.ChatID) + request.Text = strings.TrimSpace(request.Text) + request.ReplyToMessageID = strings.TrimSpace(request.ReplyToMessageID) + if request.ChatID == "" || request.Text == "" { + return merrors.InvalidArgument("telegram chat_id and text are required", "chat_id", "text") + } + env := tnotifications.TelegramText(string(mservice.PaymentGateway), request) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish telegram text request", zap.Error(err), + zap.String("request_id", request.RequestID), + zap.String("chat_id", request.ChatID), + zap.String("reply_to_message_id", request.ReplyToMessageID)) + return err + } + return nil +} + +func isFinalConfirmationStatus(status model.ConfirmationStatus) bool { + switch status { + case model.ConfirmationStatusConfirmed, model.ConfirmationStatusRejected, model.ConfirmationStatusTimeout, model.ConfirmationStatusClarified: + return true + default: + return false + } +} + +func isUserAllowed(userID string, allowed []string) bool { + allowed = normalizeStringList(allowed) + if len(allowed) == 0 { + return true + } + userID = strings.TrimSpace(userID) + if userID == "" { + return false + } + for _, id := range allowed { + if id == userID { + return true + } + } + return false +} + +func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, "empty", merrors.InvalidArgument("empty reply") + } + parts := strings.Fields(text) + if len(parts) < 2 { + if len(parts) == 1 && amountPattern.MatchString(parts[0]) { + return nil, "missing_currency", merrors.InvalidArgument("currency is required") + } + return nil, "missing_amount", merrors.InvalidArgument("amount is required") + } + if len(parts) > 2 { + return nil, "format", merrors.InvalidArgument("reply format is invalid") + } + amount := parts[0] + currency := parts[1] + if !amountPattern.MatchString(amount) { + return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid") + } + if !currencyPattern.MatchString(currency) { + return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid") + } + return &paymenttypes.Money{ + Amount: amount, + Currency: strings.ToUpper(currency), + }, "", nil +} + +func clarificationMessage(reason string) string { + switch reason { + case "missing_currency": + return "Currency code is required. Reply with \" \" (e.g., 12.34 USD)." + case "missing_amount": + return "Amount is required. Reply with \" \" (e.g., 12.34 USD)." + case "invalid_amount": + return "Amount must be a decimal number. Reply with \" \" (e.g., 12.34 USD)." + case "invalid_currency": + return "Currency must be a code like USD or EUR. Reply with \" \"." + default: + return "Reply with \" \" (e.g., 12.34 USD)." + } +} + +func normalizeStringList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/api/gateway/chsettle/internal/service/gateway/connector.go b/api/gateway/chsettle/internal/service/gateway/connector.go new file mode 100644 index 00000000..bbb02149 --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/connector.go @@ -0,0 +1,413 @@ +package gateway + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +const ( + chsettleConnectorID = "chsettle" + connectorScenarioParam = "scenario" + connectorScenarioMetaKey = "chsettle_scenario" +) + +func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { + return &connectorv1.GetCapabilitiesResponse{ + Capabilities: &connectorv1.ConnectorCapabilities{ + ConnectorType: chsettleConnectorID, + Version: "", + SupportedAccountKinds: nil, + SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER}, + OperationParams: chsettleOperationParams(), + }, + }, nil +} + +func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil +} + +func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) { + return nil, merrors.NotImplemented("get_account: unsupported") +} + +func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { + return nil, merrors.NotImplemented("list_accounts: unsupported") +} + +func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) { + return nil, merrors.NotImplemented("get_balance: unsupported") +} + +func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + if req == nil || req.GetOperation() == nil { + s.logger.Warn("Submit operation rejected", zap.String("reason", "operation is required")) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil + } + op := req.GetOperation() + s.logger.Debug("Submit operation request received", + zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())), + zap.String("intent_ref", strings.TrimSpace(op.GetIntentRef())), + zap.String("operation_ref", strings.TrimSpace(op.GetOperationRef()))) + if strings.TrimSpace(op.GetIdempotencyKey()) == "" { + s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "idempotency_key is required"))...) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil + } + if op.GetType() != connectorv1.OperationType_TRANSFER { + s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "unsupported operation type"))...) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil + } + reader := params.New(op.GetParams()) + metadata := reader.StringMap("metadata") + if metadata == nil { + metadata = map[string]string{} + } + paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id")) + if paymentIntentID == "" { + paymentIntentID = strings.TrimSpace(reader.String("payment_ref")) + } + if paymentIntentID == "" { + paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID]) + } + if paymentIntentID == "" { + s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "payment_intent_id is required"))...) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: payment_intent_id is required", op, "")}}, nil + } + source := operationAccountID(op.GetFrom()) + if source == "" { + s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "from.account is required"))...) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account is required", op, "")}}, nil + } + dest, err := transferDestinationFromOperation(op) + if err != nil { + s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.Error(err))...) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + amount := op.GetMoney() + if amount == nil { + s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "money is required"))...) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil + } + + metadata[metadataPaymentIntentID] = paymentIntentID + quoteRef := strings.TrimSpace(reader.String("quote_ref")) + if quoteRef != "" { + metadata[metadataQuoteRef] = quoteRef + } + targetChatID := strings.TrimSpace(reader.String("target_chat_id")) + if targetChatID != "" { + metadata[metadataTargetChatID] = targetChatID + } + outgoingLeg := normalizeRail(reader.String("outgoing_leg")) + if outgoingLeg != "" { + metadata[metadataOutgoingLeg] = outgoingLeg + } + if scenario := strings.TrimSpace(reader.String(connectorScenarioParam)); scenario != "" { + metadata[connectorScenarioMetaKey] = scenario + } + s.logger.Debug("Submit operation parsed transfer metadata", + zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())), + zap.String("payment_intent_id", paymentIntentID), + zap.String("quote_ref", quoteRef), + zap.String("target_chat_id", targetChatID), + zap.String("outgoing_leg", outgoingLeg), + zap.String("scenario_override", strings.TrimSpace(metadata[connectorScenarioMetaKey]))) + + normalizedAmount := normalizeMoneyForTransfer(amount) + logFields := append(operationLogFields(op), + zap.String("payment_intent_id", paymentIntentID), + zap.String("organization_ref", strings.TrimSpace(reader.String("organization_ref"))), + zap.String("source_wallet_ref", source), + zap.String("amount", strings.TrimSpace(normalizedAmount.GetAmount())), + zap.String("currency", strings.TrimSpace(normalizedAmount.GetCurrency())), + zap.String("quote_ref", quoteRef), + zap.String("operation_ref", req.Operation.GetOperationRef()), + zap.String("intent_ref", op.GetIntentRef()), + zap.String("outgoing_leg", outgoingLeg), + ) + logFields = append(logFields, transferDestinationLogFields(dest)...) + s.logger.Debug("Submit operation forwarding to transfer handler", logFields...) + + resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + OrganizationRef: strings.TrimSpace(reader.String("organization_ref")), + SourceWalletRef: source, + Destination: dest, + Amount: normalizedAmount, + Metadata: metadata, + PaymentRef: paymentIntentID, + IntentRef: strings.TrimSpace(op.GetIntentRef()), + OperationRef: strings.TrimSpace(op.GetOperationRef()), + }) + if err != nil { + s.logger.Warn("Submit operation transfer failed", append(logFields, zap.Error(err))...) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + transfer := resp.GetTransfer() + operationID := strings.TrimSpace(transfer.GetOperationRef()) + if operationID == "" { + s.logger.Warn("Submit operation transfer response missing operation_ref", append(logFields, + zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())), + )...) + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{ + Error: connectorError(connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE, "submit_operation: operation_ref is missing in transfer response", op, ""), + }}, nil + } + s.logger.Info("Submit operation transfer submitted", append(logFields, + zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())), + zap.String("status", transfer.GetStatus().String()), + )...) + return &connectorv1.SubmitOperationResponse{ + Receipt: &connectorv1.OperationReceipt{ + OperationId: operationID, + Status: transferStatusToOperation(transfer.GetStatus()), + ProviderRef: strings.TrimSpace(transfer.GetTransferRef()), + }, + }, nil +} + +func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) { + if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { + s.logger.Warn("Get operation rejected", zap.String("reason", "operation_id is required")) + return nil, merrors.InvalidArgument("get_operation: operation_id is required") + } + operationID := strings.TrimSpace(req.GetOperationId()) + s.logger.Debug("Get operation request received", zap.String("operation_id", operationID)) + + if s.repo == nil || s.repo.Payments() == nil { + s.logger.Warn("Get operation storage unavailable", zap.String("operation_id", operationID)) + return nil, merrors.Internal("get_operation: storage is not configured") + } + + record, err := s.repo.Payments().FindByOperationRef(ctx, operationID) + if err != nil { + s.logger.Warn("Get operation lookup by operation_ref failed", zap.String("operation_id", operationID), zap.Error(err)) + return nil, err + } + if record == nil { + s.logger.Info("Get operation not found", zap.String("operation_id", operationID)) + return nil, status.Error(codes.NotFound, "operation not found") + } + + return &connectorv1.GetOperationResponse{Operation: transferToOperation(transferFromPayment(record, nil))}, nil +} + +func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { + return nil, merrors.NotImplemented("list_operations: unsupported") +} + +func chsettleOperationParams() []*connectorv1.OperationParamSpec { + return []*connectorv1.OperationParamSpec{ + {OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{ + {Key: "payment_intent_id", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "quote_ref", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "target_chat_id", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "outgoing_leg", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: connectorScenarioParam, Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false}, + }}, + } +} + +func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) { + if op == nil { + return nil, merrors.InvalidArgument("transfer: operation is required") + } + if to := op.GetTo(); to != nil { + if account := to.GetAccount(); account != nil { + return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil + } + if ext := to.GetExternal(); ext != nil { + return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil + } + } + return nil, merrors.InvalidArgument("transfer: to.account or to.external is required") +} + +func normalizeMoneyForTransfer(m *moneyv1.Money) *moneyv1.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.GetCurrency()) + if idx := strings.Index(currency, "-"); idx > 0 { + currency = currency[:idx] + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(m.GetAmount()), + Currency: currency, + } +} + +func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation { + if transfer == nil { + return nil + } + op := &connectorv1.Operation{ + OperationId: strings.TrimSpace(transfer.GetOperationRef()), + Type: connectorv1.OperationType_TRANSFER, + Status: transferStatusToOperation(transfer.GetStatus()), + Money: transfer.GetRequestedAmount(), + ProviderRef: strings.TrimSpace(transfer.GetTransferRef()), + IntentRef: strings.TrimSpace(transfer.GetIntentRef()), + OperationRef: strings.TrimSpace(transfer.GetOperationRef()), + CreatedAt: transfer.GetCreatedAt(), + UpdatedAt: transfer.GetUpdatedAt(), + } + params := map[string]interface{}{} + if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" { + params["payment_ref"] = paymentRef + } + if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" { + params["organization_ref"] = organizationRef + } + if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" { + params["failure_reason"] = failureReason + } + if len(params) > 0 { + op.Params = structFromMap(params) + } + if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" { + op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ + ConnectorId: chsettleConnectorID, + AccountId: source, + }}} + } + if dest := transfer.GetDestination(); dest != nil { + switch d := dest.GetDestination().(type) { + case *chainv1.TransferDestination_ManagedWalletRef: + op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ + ConnectorId: chsettleConnectorID, + AccountId: strings.TrimSpace(d.ManagedWalletRef), + }}} + case *chainv1.TransferDestination_ExternalAddress: + op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{ + ExternalRef: strings.TrimSpace(d.ExternalAddress), + }}} + } + } + return op +} + +func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus { + switch status { + + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return connectorv1.OperationStatus_OPERATION_PROCESSING + + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + + case chainv1.TransferStatus_TRANSFER_FAILED: + return connectorv1.OperationStatus_OPERATION_FAILED + + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return connectorv1.OperationStatus_OPERATION_CANCELLED + + case chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED: + fallthrough + default: + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED + } +} + +func operationAccountID(party *connectorv1.OperationParty) string { + if party == nil { + return "" + } + if account := party.GetAccount(); account != nil { + return strings.TrimSpace(account.GetAccountId()) + } + return "" +} + +func structFromMap(values map[string]interface{}) *structpb.Struct { + if len(values) == 0 { + return nil + } + result, err := structpb.NewStruct(values) + if err != nil { + return nil + } + return result +} + +func operationLogFields(op *connectorv1.Operation) []zap.Field { + if op == nil { + return nil + } + return []zap.Field{ + zap.String("operation_id", strings.TrimSpace(op.GetOperationId())), + zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())), + zap.String("correlation_id", strings.TrimSpace(op.GetCorrelationId())), + zap.String("parent_intent_id", strings.TrimSpace(op.GetParentIntentId())), + zap.String("operation_type", op.GetType().String()), + zap.String("intent_ref", strings.TrimSpace(op.GetIntentRef())), + } +} + +func transferDestinationLogFields(dest *chainv1.TransferDestination) []zap.Field { + if dest == nil { + return nil + } + switch d := dest.GetDestination().(type) { + case *chainv1.TransferDestination_ManagedWalletRef: + return []zap.Field{ + zap.String("destination_type", "managed_wallet"), + zap.String("destination_ref", strings.TrimSpace(d.ManagedWalletRef)), + } + case *chainv1.TransferDestination_ExternalAddress: + return []zap.Field{ + zap.String("destination_type", "external_address"), + zap.String("destination_ref", strings.TrimSpace(d.ExternalAddress)), + } + default: + return []zap.Field{zap.String("destination_type", "unknown")} + } +} + +func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { + err := &connectorv1.ConnectorError{ + Code: code, + Message: strings.TrimSpace(message), + AccountId: strings.TrimSpace(accountID), + } + if op != nil { + err.CorrelationId = strings.TrimSpace(op.GetCorrelationId()) + err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId()) + err.OperationId = strings.TrimSpace(op.GetOperationId()) + } + return err +} + +func mapErrorCode(err error) connectorv1.ErrorCode { + switch { + case errors.Is(err, merrors.ErrInvalidArg): + return connectorv1.ErrorCode_INVALID_PARAMS + case errors.Is(err, merrors.ErrNoData): + return connectorv1.ErrorCode_NOT_FOUND + case errors.Is(err, merrors.ErrNotImplemented): + return connectorv1.ErrorCode_UNSUPPORTED_OPERATION + case errors.Is(err, merrors.ErrInternal): + return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE + default: + return connectorv1.ErrorCode_PROVIDER_ERROR + } +} diff --git a/api/gateway/chsettle/internal/service/gateway/connector_test.go b/api/gateway/chsettle/internal/service/gateway/connector_test.go new file mode 100644 index 00000000..3eaa88f7 --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/connector_test.go @@ -0,0 +1,119 @@ +package gateway + +import ( + "context" + "testing" + + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestSubmitOperation_UsesOperationRefAsOperationID(t *testing.T) { + svc, _, _ := newTestService(t) + svc.chatID = "1" + + req := &connectorv1.SubmitOperationRequest{ + Operation: &connectorv1.Operation{ + Type: connectorv1.OperationType_TRANSFER, + IdempotencyKey: "idem-settlement-1", + OperationRef: "payment-1:hop_2_settlement_fx_convert", + IntentRef: "intent-1", + Money: &moneyv1.Money{Amount: "1.00", Currency: "USDT"}, + From: &connectorv1.OperationParty{ + Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ + ConnectorId: chsettleConnectorID, + AccountId: "wallet-src", + }}, + }, + To: &connectorv1.OperationParty{ + Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ + ConnectorId: chsettleConnectorID, + AccountId: "wallet-dst", + }}, + }, + Params: structFromMap(map[string]interface{}{ + "payment_ref": "payment-1", + "organization_ref": "org-1", + }), + }, + } + + resp, err := svc.SubmitOperation(context.Background(), req) + if err != nil { + t.Fatalf("SubmitOperation returned error: %v", err) + } + if resp.GetReceipt() == nil { + t.Fatal("expected receipt") + } + if got := resp.GetReceipt().GetError(); got != nil { + t.Fatalf("expected no connector error, got: %v", got) + } + if got, want := resp.GetReceipt().GetOperationId(), "payment-1:hop_2_settlement_fx_convert"; got != want { + t.Fatalf("operation_id mismatch: got=%q want=%q", got, want) + } + if got, want := resp.GetReceipt().GetProviderRef(), "idem-settlement-1"; got != want { + t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestGetOperation_UsesOperationRefIdentity(t *testing.T) { + svc, repo, _ := newTestService(t) + + record := &storagemodel.PaymentRecord{ + IdempotencyKey: "idem-settlement-2", + OperationRef: "payment-2:hop_2_settlement_fx_convert", + PaymentIntentID: "pi-2", + PaymentRef: "payment-2", + RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"}, + Status: storagemodel.PaymentStatusSuccess, + } + if err := repo.payments.Upsert(context.Background(), record); err != nil { + t.Fatalf("failed to seed payment record: %v", err) + } + + resp, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{ + OperationId: "payment-2:hop_2_settlement_fx_convert", + }) + if err != nil { + t.Fatalf("GetOperation returned error: %v", err) + } + if resp.GetOperation() == nil { + t.Fatal("expected operation") + } + if got, want := resp.GetOperation().GetOperationId(), "payment-2:hop_2_settlement_fx_convert"; got != want { + t.Fatalf("operation_id mismatch: got=%q want=%q", got, want) + } + if got, want := resp.GetOperation().GetProviderRef(), "idem-settlement-2"; got != want { + t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestGetOperation_DoesNotResolveByIdempotencyKey(t *testing.T) { + svc, repo, _ := newTestService(t) + + record := &storagemodel.PaymentRecord{ + IdempotencyKey: "idem-settlement-3", + OperationRef: "payment-3:hop_2_settlement_fx_convert", + PaymentIntentID: "pi-3", + PaymentRef: "payment-3", + RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"}, + Status: storagemodel.PaymentStatusSuccess, + } + if err := repo.payments.Upsert(context.Background(), record); err != nil { + t.Fatalf("failed to seed payment record: %v", err) + } + + _, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{ + OperationId: "idem-settlement-3", + }) + if err == nil { + t.Fatal("expected not found error") + } + if status.Code(err) != codes.NotFound { + t.Fatalf("unexpected error code: got=%s want=%s", status.Code(err), codes.NotFound) + } +} diff --git a/api/gateway/chsettle/internal/service/gateway/outbox_reliable.go b/api/gateway/chsettle/internal/service/gateway/outbox_reliable.go new file mode 100644 index 00000000..e9e31c6d --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/outbox_reliable.go @@ -0,0 +1,47 @@ +package gateway + +import ( + "context" + + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/db/transaction" + me "github.com/tech/sendico/pkg/messaging/envelope" +) + +type tgOutboxProvider interface { + Outbox() gatewayoutbox.Store +} + +type tgTransactionProvider interface { + TransactionFactory() transaction.Factory +} + +func (s *Service) outboxStore() gatewayoutbox.Store { + provider, ok := s.repo.(tgOutboxProvider) + if !ok || provider == nil { + return nil + } + return provider.Outbox() +} + +func (s *Service) startOutboxReliableProducer() error { + if s == nil || s.repo == nil { + return nil + } + return s.outbox.Start(s.logger, s.producer, s.outboxStore(), s.msgCfg) +} + +func (s *Service) sendWithOutbox(ctx context.Context, env me.Envelope) error { + if err := s.startOutboxReliableProducer(); err != nil { + return err + } + return s.outbox.Send(ctx, env) +} + +func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + provider, ok := s.repo.(tgTransactionProvider) + if !ok || provider == nil || provider.TransactionFactory() == nil { + return cb(ctx) + } + return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb) +} diff --git a/api/gateway/chsettle/internal/service/gateway/scenario_simulator.go b/api/gateway/chsettle/internal/service/gateway/scenario_simulator.go new file mode 100644 index 00000000..e5c411b6 --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/scenario_simulator.go @@ -0,0 +1,302 @@ +package gateway + +import ( + "hash/fnv" + "strconv" + "strings" + "time" + + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +const ( + scenarioMetadataKey = "chsettle_scenario" + scenarioMetadataAliasKey = "scenario" +) + +type settlementScenario struct { + Name string + InitialStatus storagemodel.PaymentStatus + FinalStatus storagemodel.PaymentStatus + FinalDelay time.Duration + FailureReason string +} + +type settlementScenarioTrace struct { + Source string + OverrideRaw string + OverrideNormalized string + AmountRaw string + AmountCurrency string + BucketSlot int +} + +var scenarioFastSuccess = settlementScenario{ + Name: "fast_success", + InitialStatus: storagemodel.PaymentStatusSuccess, +} + +var scenarioSlowSuccess = settlementScenario{ + Name: "slow_success", + InitialStatus: storagemodel.PaymentStatusWaiting, + FinalStatus: storagemodel.PaymentStatusSuccess, + FinalDelay: 30 * time.Second, +} + +var scenarioFailImmediate = settlementScenario{ + Name: "fail_immediate", + InitialStatus: storagemodel.PaymentStatusFailed, + FailureReason: "simulated_fail_immediate", +} + +var scenarioFailTimeout = settlementScenario{ + Name: "fail_timeout", + InitialStatus: storagemodel.PaymentStatusWaiting, + FinalStatus: storagemodel.PaymentStatusFailed, + FinalDelay: 45 * time.Second, + FailureReason: "simulated_fail_timeout", +} + +var scenarioStuckPending = settlementScenario{ + Name: "stuck_pending", + InitialStatus: storagemodel.PaymentStatusWaiting, +} + +var scenarioRetryThenSuccess = settlementScenario{ + Name: "retry_then_success", + InitialStatus: storagemodel.PaymentStatusProcessing, + FinalStatus: storagemodel.PaymentStatusSuccess, + FinalDelay: 25 * time.Second, +} + +var scenarioWebhookDelayedSuccess = settlementScenario{ + Name: "webhook_delayed_success", + InitialStatus: storagemodel.PaymentStatusWaiting, + FinalStatus: storagemodel.PaymentStatusSuccess, + FinalDelay: 60 * time.Second, +} + +var scenarioSlowThenFail = settlementScenario{ + Name: "slow_then_fail", + InitialStatus: storagemodel.PaymentStatusProcessing, + FinalStatus: storagemodel.PaymentStatusFailed, + FinalDelay: 75 * time.Second, + FailureReason: "simulated_slow_then_fail", +} + +var scenarioPartialProgressStuck = settlementScenario{ + Name: "partial_progress_stuck", + InitialStatus: storagemodel.PaymentStatusProcessing, +} + +func resolveSettlementScenario(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) settlementScenario { + scenario, _ := resolveSettlementScenarioWithTrace(idempotencyKey, amount, metadata) + return scenario +} + +func resolveSettlementScenarioWithTrace(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) (settlementScenario, settlementScenarioTrace) { + trace := settlementScenarioTrace{ + BucketSlot: -1, + } + if amount != nil { + trace.AmountRaw = strings.TrimSpace(amount.Amount) + trace.AmountCurrency = strings.TrimSpace(amount.Currency) + } + overrideScenario, overrideRaw, overrideNormalized, overrideApplied := parseScenarioOverride(metadata) + if overrideRaw != "" { + trace.OverrideRaw = overrideRaw + trace.OverrideNormalized = overrideNormalized + } + if overrideApplied { + trace.Source = "explicit_override" + return overrideScenario, trace + } + slot, ok := amountModuloSlot(amount) + if ok { + if trace.OverrideRaw != "" { + trace.Source = "invalid_override_amount_bucket" + } else { + trace.Source = "amount_bucket" + } + trace.BucketSlot = slot + return scenarioBySlot(slot, idempotencyKey), trace + } + slot = hashModulo(idempotencyKey, 1000) + if trace.OverrideRaw != "" { + trace.Source = "invalid_override_idempotency_hash_bucket" + } else { + trace.Source = "idempotency_hash_bucket" + } + trace.BucketSlot = slot + return scenarioBySlot(slot, idempotencyKey), trace +} + +func parseScenarioOverride(metadata map[string]string) (settlementScenario, string, string, bool) { + if len(metadata) == 0 { + return settlementScenario{}, "", "", false + } + overrideRaw := strings.TrimSpace(metadata[scenarioMetadataKey]) + if overrideRaw == "" { + overrideRaw = strings.TrimSpace(metadata[scenarioMetadataAliasKey]) + } + if overrideRaw == "" { + return settlementScenario{}, "", "", false + } + scenario, normalized, ok := scenarioByName(overrideRaw) + return scenario, overrideRaw, normalized, ok +} + +func scenarioByName(value string) (settlementScenario, string, bool) { + key := normalizeScenarioName(value) + switch key { + case "fast_success", "success_fast", "instant_success": + return scenarioFastSuccess, key, true + case "slow_success", "success_slow": + return scenarioSlowSuccess, key, true + case "fail_immediate", "immediate_fail", "failed": + return scenarioFailImmediate, key, true + case "fail_timeout", "timeout_fail": + return scenarioFailTimeout, key, true + case "stuck", "stuck_pending", "pending_stuck": + return scenarioStuckPending, key, true + case "retry_then_success": + return scenarioRetryThenSuccess, key, true + case "webhook_delayed_success": + return scenarioWebhookDelayedSuccess, key, true + case "slow_then_fail": + return scenarioSlowThenFail, key, true + case "partial_progress_stuck": + return scenarioPartialProgressStuck, key, true + case "chaos", "chaos_random_seeded": + return scenarioBySlot(950, ""), key, true + default: + return settlementScenario{}, key, false + } +} + +func normalizeScenarioName(value string) string { + key := strings.ToLower(strings.TrimSpace(value)) + key = strings.ReplaceAll(key, "-", "_") + return key +} + +func scenarioBySlot(slot int, seed string) settlementScenario { + switch { + case slot < 100: + return scenarioFastSuccess + case slot < 200: + return scenarioSlowSuccess + case slot < 300: + return scenarioFailImmediate + case slot < 400: + return scenarioFailTimeout + case slot < 500: + return scenarioStuckPending + case slot < 600: + return scenarioRetryThenSuccess + case slot < 700: + return scenarioWebhookDelayedSuccess + case slot < 800: + return scenarioSlowThenFail + case slot < 900: + return scenarioPartialProgressStuck + default: + return chaosScenario(seed) + } +} + +func chaosScenario(seed string) settlementScenario { + choices := []settlementScenario{ + scenarioFastSuccess, + scenarioSlowSuccess, + scenarioFailImmediate, + scenarioFailTimeout, + scenarioStuckPending, + scenarioSlowThenFail, + } + idx := hashModulo(seed, len(choices)) + return choices[idx] +} + +func amountModuloSlot(amount *paymenttypes.Money) (int, bool) { + if amount == nil { + return 0, false + } + raw := strings.TrimSpace(amount.Amount) + if raw == "" { + return 0, false + } + sign := 1 + if strings.HasPrefix(raw, "+") { + raw = strings.TrimPrefix(raw, "+") + } + if strings.HasPrefix(raw, "-") { + sign = -1 + raw = strings.TrimPrefix(raw, "-") + } + parts := strings.SplitN(raw, ".", 3) + if len(parts) == 0 || len(parts) > 2 { + return 0, false + } + whole := parts[0] + if whole == "" || !digitsOnly(whole) { + return 0, false + } + frac := "00" + if len(parts) == 2 { + f := parts[1] + if f == "" || !digitsOnly(f) { + return 0, false + } + if len(f) >= 2 { + frac = f[:2] + } else { + frac = f + "0" + } + } + wholeMod := digitsMod(whole, 10) + fracVal, _ := strconv.Atoi(frac) + slot := (wholeMod*100 + fracVal) % 1000 + if sign < 0 { + slot = (-slot + 1000) % 1000 + } + return slot, true +} + +func digitsOnly(value string) bool { + if value == "" { + return false + } + for i := 0; i < len(value); i++ { + if value[i] < '0' || value[i] > '9' { + return false + } + } + return true +} + +func digitsMod(value string, mod int) int { + if mod <= 0 { + return 0 + } + result := 0 + for i := 0; i < len(value); i++ { + digit := int(value[i] - '0') + result = (result*10 + digit) % mod + } + return result +} + +func hashModulo(input string, mod int) int { + if mod <= 0 { + return 0 + } + h := fnv.New32a() + _, _ = h.Write([]byte(strings.TrimSpace(input))) + return int(h.Sum32() % uint32(mod)) +} + +func (s settlementScenario) delayedTransitionEnabled() bool { + return s.FinalStatus != "" && s.FinalDelay > 0 +} diff --git a/api/gateway/chsettle/internal/service/gateway/scenario_simulator_test.go b/api/gateway/chsettle/internal/service/gateway/scenario_simulator_test.go new file mode 100644 index 00000000..536116ee --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/scenario_simulator_test.go @@ -0,0 +1,105 @@ +package gateway + +import ( + "context" + "testing" + + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/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" +) + +func TestResolveSettlementScenario_AmountBuckets(t *testing.T) { + tests := []struct { + name string + amount string + want string + }{ + {name: "bucket_000_fast_success", amount: "10.00", want: scenarioFastSuccess.Name}, + {name: "bucket_100_slow_success", amount: "11.00", want: scenarioSlowSuccess.Name}, + {name: "bucket_200_fail_immediate", amount: "12.00", want: scenarioFailImmediate.Name}, + {name: "bucket_300_fail_timeout", amount: "13.00", want: scenarioFailTimeout.Name}, + {name: "bucket_400_stuck_pending", amount: "14.00", want: scenarioStuckPending.Name}, + {name: "bucket_500_retry_then_success", amount: "15.00", want: scenarioRetryThenSuccess.Name}, + {name: "bucket_600_webhook_delayed_success", amount: "16.00", want: scenarioWebhookDelayedSuccess.Name}, + {name: "bucket_700_slow_then_fail", amount: "17.00", want: scenarioSlowThenFail.Name}, + {name: "bucket_800_partial_progress_stuck", amount: "18.00", want: scenarioPartialProgressStuck.Name}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveSettlementScenario("idem-"+tc.name, &paymenttypes.Money{Amount: tc.amount, Currency: "USD"}, nil) + if got.Name != tc.want { + t.Fatalf("scenario mismatch: got=%q want=%q", got.Name, tc.want) + } + }) + } +} + +func TestResolveSettlementScenario_ExplicitOverride(t *testing.T) { + got := resolveSettlementScenario("idem-override", &paymenttypes.Money{Amount: "10.00", Currency: "USD"}, map[string]string{ + scenarioMetadataKey: "stuck", + }) + if got.Name != scenarioStuckPending.Name { + t.Fatalf("scenario mismatch: got=%q want=%q", got.Name, scenarioStuckPending.Name) + } +} + +func TestSubmitTransfer_UsesImmediateFailureScenario(t *testing.T) { + svc, repo, _ := newTestService(t) + + resp, err := svc.SubmitTransfer(context.Background(), &chainv1.SubmitTransferRequest{ + IdempotencyKey: "idem-immediate-fail", + IntentRef: "intent-immediate-fail", + OperationRef: "op-immediate-fail", + PaymentRef: "payment-immediate-fail", + Amount: &moneyv1.Money{Amount: "9.99", Currency: "USD"}, + Metadata: map[string]string{ + scenarioMetadataAliasKey: "fail_immediate", + }, + }) + if err != nil { + t.Fatalf("submit transfer failed: %v", err) + } + if got, want := resp.GetTransfer().GetStatus(), chainv1.TransferStatus_TRANSFER_FAILED; got != want { + t.Fatalf("transfer status mismatch: got=%v want=%v", got, want) + } + + record := repo.payments.records["idem-immediate-fail"] + if record == nil { + t.Fatalf("expected payment record") + } + if got, want := record.Status, storagemodel.PaymentStatusFailed; got != want { + t.Fatalf("record status mismatch: got=%s want=%s", got, want) + } + if got, want := record.Scenario, scenarioFailImmediate.Name; got != want { + t.Fatalf("record scenario mismatch: got=%q want=%q", got, want) + } +} + +func TestApplyScenarioTransition_SetsFinalSuccess(t *testing.T) { + svc, repo, _ := newTestService(t) + _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ + IdempotencyKey: "idem-transition-success", + IntentRef: "intent-transition-success", + OperationRef: "op-transition-success", + PaymentRef: "payment-transition-success", + RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USD"}, + Status: storagemodel.PaymentStatusWaiting, + Scenario: scenarioSlowSuccess.Name, + }) + + svc.applyScenarioTransition("idem-transition-success", scenarioSlowSuccess) + + record := repo.payments.records["idem-transition-success"] + if record == nil { + t.Fatalf("expected payment record") + } + if got, want := record.Status, storagemodel.PaymentStatusSuccess; got != want { + t.Fatalf("record status mismatch: got=%s want=%s", got, want) + } + if record.ExecutedMoney == nil { + t.Fatalf("expected executed money") + } +} diff --git a/api/gateway/chsettle/internal/service/gateway/service.go b/api/gateway/chsettle/internal/service/gateway/service.go new file mode 100644 index 00000000..9f1fd8df --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/service.go @@ -0,0 +1,1040 @@ +package gateway + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "time" + + treasurysvc "github.com/tech/sendico/gateway/chsettle/internal/service/treasury" + treasuryledger "github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger" + "github.com/tech/sendico/gateway/chsettle/storage" + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/api/routers" + "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" + cons "github.com/tech/sendico/pkg/messaging/consumer" + confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + defaultConfirmationTimeoutSeconds = 345600 + defaultTelegramSuccessReaction = "\U0001FAE1" + defaultConfirmationSweepInterval = 5 * time.Second + defaultTreasuryExecutionDelay = 30 * time.Second + defaultTreasuryPollInterval = 30 * time.Second + defaultTreasuryLedgerTimeout = 5 * time.Second +) + +const ( + metadataPaymentIntentID = "payment_intent_id" + metadataQuoteRef = "quote_ref" + metadataTargetChatID = "target_chat_id" + metadataOutgoingLeg = "outgoing_leg" + metadataSourceAmount = "source_amount" + metadataSourceCurrency = "source_currency" +) + +type Config struct { + Rail string + TargetChatIDEnv string + TimeoutSeconds int32 + AcceptedUserIDs []string + SuccessReaction string + InvokeURI string + MessagingSettings pmodel.SettingsT + DiscoveryRegistry *discovery.Registry + Treasury TreasuryConfig +} + +type TreasuryConfig struct { + ExecutionDelay time.Duration + PollInterval time.Duration + Ledger LedgerConfig + Limits TreasuryLimitsConfig +} + +type TreasuryLimitsConfig struct { + MaxAmountPerOperation string + MaxDailyAmount string +} + +type LedgerConfig struct { + Timeout time.Duration +} + +type Service struct { + logger mlogger.Logger + repo storage.Repository + producer msg.Producer + broker mb.Broker + cfg Config + msgCfg pmodel.SettingsT + rail string + chatID string + announcer *discovery.Announcer + invokeURI string + successReaction string + outbox gatewayoutbox.ReliableRuntime + + consumers []msg.Consumer + timeoutCtx context.Context + timeoutCancel context.CancelFunc + timeoutWG sync.WaitGroup + + treasury *treasurysvc.Module + + connectorv1.UnimplementedConnectorServiceServer +} + +func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service { + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("service") + svc := &Service{ + logger: logger, + repo: repo, + producer: producer, + broker: broker, + cfg: cfg, + msgCfg: cfg.MessagingSettings, + 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 == "" { + svc.successReaction = defaultTelegramSuccessReaction + } + if err := svc.startOutboxReliableProducer(); err != nil { + svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err)) + } + svc.startRuntimeContext() + svc.startAnnouncer() + svc.logger.Info("ChimeraSettle service initialized", + zap.String("rail", svc.rail), + zap.String("invoke_uri", svc.invokeURI)) + return svc +} + +func (s *Service) startRuntimeContext() { + if s == nil || s.timeoutCancel != nil { + return + } + s.timeoutCtx, s.timeoutCancel = context.WithCancel(context.Background()) + s.logger.Debug("Runtime context started") +} + +func (s *Service) Register(router routers.GRPC) error { + return router.Register(func(reg grpc.ServiceRegistrar) { + connectorv1.RegisterConnectorServiceServer(reg, s) + }) +} + +func (s *Service) Shutdown() { + if s == nil { + return + } + s.outbox.Stop() + if s.announcer != nil { + s.announcer.Stop() + } + for _, consumer := range s.consumers { + if consumer != nil { + consumer.Close() + } + } + if s.treasury != nil { + s.treasury.Shutdown() + } + if s.timeoutCancel != nil { + s.timeoutCancel() + } + s.timeoutWG.Wait() +} + +func (s *Service) startTreasuryModule() { + if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil || s.repo.TreasuryTelegramUsers() == nil { + return + } + if s.cfg.DiscoveryRegistry == nil { + s.logger.Warn("Treasury module disabled: discovery registry is unavailable") + return + } + + ledgerTimeout := s.cfg.Treasury.Ledger.Timeout + if ledgerTimeout <= 0 { + ledgerTimeout = defaultTreasuryLedgerTimeout + } + ledgerClient, err := treasuryledger.NewDiscoveryClient(treasuryledger.DiscoveryConfig{ + Logger: s.logger, + Registry: s.cfg.DiscoveryRegistry, + Timeout: ledgerTimeout, + }) + if err != nil { + s.logger.Warn("Failed to initialise treasury ledger client", zap.Error(err)) + return + } + + executionDelay := s.cfg.Treasury.ExecutionDelay + if executionDelay <= 0 { + executionDelay = defaultTreasuryExecutionDelay + } + pollInterval := s.cfg.Treasury.PollInterval + if pollInterval <= 0 { + pollInterval = defaultTreasuryPollInterval + } + + module, err := treasurysvc.NewModule( + s.logger, + s.repo.TreasuryRequests(), + s.repo.TreasuryTelegramUsers(), + ledgerClient, + treasurysvc.Config{ + ExecutionDelay: executionDelay, + PollInterval: pollInterval, + MaxAmountPerOperation: s.cfg.Treasury.Limits.MaxAmountPerOperation, + MaxDailyAmount: s.cfg.Treasury.Limits.MaxDailyAmount, + }, + func(ctx context.Context, chatID string, text string) error { + return s.sendTelegramText(ctx, &model.TelegramTextRequest{ + ChatID: chatID, + Text: text, + }) + }, + ) + if err != nil { + s.logger.Warn("Failed to initialise treasury module", zap.Error(err)) + _ = ledgerClient.Close() + return + } + if !module.Enabled() { + _ = ledgerClient.Close() + return + } + module.Start() + s.treasury = module + s.logger.Info("Treasury module started", zap.Duration("execution_delay", executionDelay), zap.Duration("poll_interval", pollInterval)) +} + +func (s *Service) startConsumers() { + if s == nil || s.broker == nil { + if s != nil && s.logger != nil { + s.logger.Warn("Messaging broker not configured; confirmation flow disabled") + } + return + } + resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult) + s.consumeProcessor(resultProcessor) + dispatchProcessor := confirmations.NewConfirmationDispatchProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationDispatch) + s.consumeProcessor(dispatchProcessor) + updateProcessor := tnotifications.NewTelegramUpdateProcessor(s.logger, s.onTelegramUpdate) + s.consumeProcessor(updateProcessor) +} + +func (s *Service) consumeProcessor(processor np.EnvelopeProcessor) { + consumer, err := cons.NewConsumer(s.logger, s.broker, processor.GetSubject()) + if err != nil { + s.logger.Warn("Failed to create messaging consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + return + } + s.consumers = append(s.consumers, consumer) + go func() { + if err := consumer.ConsumeMessages(processor.Process); err != nil && !errors.Is(err, context.Canceled) { + s.logger.Warn("Messaging consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + } + }() +} + +func (s *Service) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + if req == nil { + s.logger.Warn("Submit transfer rejected", zap.String("reason", "request is required")) + return nil, merrors.InvalidArgument("submit_transfer: request is required") + } + s.logger.Debug("Submit transfer request received", + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + zap.String("intent_ref", strings.TrimSpace(req.GetIntentRef())), + zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("payment_ref", strings.TrimSpace(req.GetPaymentRef())), + zap.Strings("metadata_keys", metadataKeys(req.GetMetadata()))) + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + if idempotencyKey == "" { + s.logger.Warn("Submit transfer rejected", zap.String("reason", "idempotency_key is required")) + return nil, merrors.InvalidArgument("submit_transfer: idempotency_key is required") + } + amount := req.GetAmount() + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + s.logger.Warn("Submit transfer rejected", zap.String("reason", "amount is required"), zap.String("idempotency_key", idempotencyKey)) + return nil, merrors.InvalidArgument("submit_transfer: amount is required") + } + intent, err := intentFromSubmitTransfer(req, s.rail, s.chatID) + if err != nil { + s.logger.Warn("Submit transfer rejected", zap.Error(err), zap.String("idempotency_key", idempotencyKey)) + return nil, err + } + logFields := []zap.Field{ + zap.String("idempotency_key", intent.IdempotencyKey), + zap.String("payment_intent_id", intent.PaymentIntentID), + zap.String("quote_ref", intent.QuoteRef), + zap.String("rail", intent.OutgoingLeg), + zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())), + zap.String("source_wallet_ref", strings.TrimSpace(req.GetSourceWalletRef())), + zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("intent_ref", strings.TrimSpace(req.GetIntentRef())), + } + if intent.RequestedMoney != nil { + logFields = append(logFields, + zap.String("amount", strings.TrimSpace(intent.RequestedMoney.Amount)), + zap.String("currency", strings.TrimSpace(intent.RequestedMoney.Currency)), + ) + } + logFields = append(logFields, transferDestinationLogFields(req.GetDestination())...) + if s.repo == nil || s.repo.Payments() == nil { + s.logger.Warn("Payment gateway storage unavailable", logFields...) + return nil, merrors.Internal("payment gateway storage unavailable") + } + s.logger.Debug("Submit transfer checking idempotency key", zap.String("idempotency_key", idempotencyKey)) + existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, idempotencyKey) + if err != nil { + s.logger.Warn("Submit transfer lookup failed", append(logFields, zap.Error(err))...) + return nil, err + } + if existing != nil { + s.logger.Info("Submit transfer idempotent hit", append(logFields, zap.String("status", string(existing.Status)))...) + return &chainv1.SubmitTransferResponse{Transfer: transferFromPayment(existing, req)}, nil + } + record, err := s.onIntent(ctx, intent, req.GetMetadata()) + if err != nil { + s.logger.Warn("Submit transfer intent handling failed", append(logFields, zap.Error(err))...) + return nil, err + } + s.logger.Info("Submit transfer accepted", append(logFields, + zap.String("scenario", strings.TrimSpace(record.Scenario)), + zap.String("status", string(record.Status)), + )...) + return &chainv1.SubmitTransferResponse{Transfer: transferFromPayment(record, req)}, nil +} + +func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) { + if req == nil { + s.logger.Warn("Get transfer rejected", zap.String("reason", "request is required")) + return nil, merrors.InvalidArgument("get_transfer: request is required") + } + transferRef := strings.TrimSpace(req.GetTransferRef()) + if transferRef == "" { + s.logger.Warn("Get transfer rejected", zap.String("reason", "transfer_ref is required")) + return nil, merrors.InvalidArgument("get_transfer: transfer_ref is required") + } + logFields := []zap.Field{zap.String("transfer_ref", transferRef)} + s.logger.Debug("Get transfer request received", logFields...) + if s.repo == nil || s.repo.Payments() == nil { + s.logger.Warn("Payment gateway storage unavailable", logFields...) + return nil, merrors.Internal("payment gateway storage unavailable") + } + existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, transferRef) + if err != nil { + s.logger.Warn("Get transfer lookup failed", append(logFields, zap.Error(err))...) + return nil, err + } + if existing != nil { + s.logger.Info("Get transfer resolved from execution", append(logFields, + zap.String("payment_intent_id", strings.TrimSpace(existing.PaymentIntentID)), + zap.String("status", string(existing.Status)), + )...) + return &chainv1.GetTransferResponse{Transfer: transferFromPayment(existing, nil)}, nil + } + s.logger.Info("Get transfer not found", logFields...) + return nil, status.Error(codes.NotFound, "transfer not found") +} + +func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayIntent, metadata map[string]string) (*storagemodel.PaymentRecord, error) { + if intent == nil { + s.logger.Warn("Payment gateway intent rejected", zap.String("reason", "intent is nil")) + return nil, merrors.InvalidArgument("payment gateway intent is nil", "intent") + } + intent = normalizeIntent(intent) + s.logger.Debug("Payment gateway intent normalized", + zap.String("idempotency_key", intent.IdempotencyKey), + zap.String("payment_intent_id", intent.PaymentIntentID), + zap.String("intent_ref", intent.IntentRef), + zap.String("operation_ref", intent.OperationRef), + zap.String("payment_ref", intent.PaymentRef), + zap.String("amount", safeMoneyAmount(intent.RequestedMoney)), + zap.String("currency", safeMoneyCurrency(intent.RequestedMoney)), + zap.Strings("metadata_keys", metadataKeys(metadata))) + if intent.IdempotencyKey == "" { + s.logger.Warn("Payment gateway intent rejected", zap.String("reason", "idempotency_key is required")) + return nil, merrors.InvalidArgument("idempotency_key is required", "idempotency_key") + } + if intent.PaymentIntentID == "" { + s.logger.Warn("Payment gateway intent rejected", zap.String("reason", "payment_intent_id is required"), zap.String("idempotency_key", intent.IdempotencyKey)) + return nil, merrors.InvalidArgument("payment_intent_id is required", "payment_intent_id") + } + if intent.IntentRef == "" { + s.logger.Warn("Payment gateway intent rejected", zap.String("reason", "payment_intent_ref is required"), zap.String("idempotency_key", intent.IdempotencyKey)) + return nil, merrors.InvalidArgument("payment_intent_ref is required", "payment_intent_ref") + } + if intent.RequestedMoney == nil || strings.TrimSpace(intent.RequestedMoney.Amount) == "" || strings.TrimSpace(intent.RequestedMoney.Currency) == "" { + s.logger.Warn("Payment gateway intent rejected", zap.String("reason", "requested_money is required"), zap.String("idempotency_key", intent.IdempotencyKey)) + return nil, merrors.InvalidArgument("requested_money is required", "requested_money") + } + if intent.OperationRef == "" { + s.logger.Warn("Payment gateway intent rejected", zap.String("reason", "operation_ref is required")) + return nil, merrors.InvalidArgument("operation_ref is required", "operation_ref") + } + if s.repo == nil || s.repo.Payments() == nil { + s.logger.Warn("Payment gateway storage unavailable", zap.String("idempotency_key", intent.IdempotencyKey)) + return nil, merrors.Internal("payment gateway storage unavailable") + } + + existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, intent.IdempotencyKey) + if err != nil { + return nil, err + } + if existing != nil { + s.logger.Info("Payment gateway intent deduplicated", + zap.String("idempotency_key", intent.IdempotencyKey), + zap.String("existing_status", string(existing.Status)), + zap.String("existing_scenario", strings.TrimSpace(existing.Scenario))) + return existing, nil + } + + scenario, trace := resolveSettlementScenarioWithTrace(intent.IdempotencyKey, intent.RequestedMoney, metadata) + s.logger.Info("Settlement scenario resolved", + zap.String("idempotency_key", intent.IdempotencyKey), + zap.String("payment_intent_id", intent.PaymentIntentID), + zap.String("scenario", scenario.Name), + zap.String("decision_source", trace.Source), + zap.Int("bucket_slot", trace.BucketSlot), + zap.String("override_raw", trace.OverrideRaw), + zap.String("override_normalized", trace.OverrideNormalized), + zap.String("amount", trace.AmountRaw), + zap.String("currency", trace.AmountCurrency)) + record := paymentRecordFromIntent(intent, nil) + record.Scenario = scenario.Name + record.Status = scenario.InitialStatus + if record.Status == "" { + record.Status = storagemodel.PaymentStatusWaiting + } + if record.Status == storagemodel.PaymentStatusSuccess { + record.ExecutedMoney = clonePaymentMoney(intent.RequestedMoney) + record.ExecutedAt = time.Now() + } + if record.Status == storagemodel.PaymentStatusFailed && strings.TrimSpace(record.FailureReason) == "" { + record.FailureReason = strings.TrimSpace(scenario.FailureReason) + if record.FailureReason == "" { + record.FailureReason = "simulated_settlement_failure" + } + } + record.UpdatedAt = time.Now() + + if err := s.updateTransferStatus(ctx, record); err != nil { + s.logger.Warn("Failed to persist payment record", zap.Error(err), + zap.String("idempotency_key", record.IdempotencyKey), zap.String("intent_ref", record.IntentRef)) + return nil, err + } + s.logger.Debug("Payment record persisted", + zap.String("idempotency_key", record.IdempotencyKey), + zap.String("status", string(record.Status)), + zap.String("scenario", strings.TrimSpace(record.Scenario))) + s.scheduleScenarioTransition(record.IdempotencyKey, scenario) + return record, nil +} + +func (s *Service) onConfirmationResult(ctx context.Context, result *model.ConfirmationResult) error { + if result == nil { + return merrors.InvalidArgument("confirmation result is nil", "result") + } + + requestID := strings.TrimSpace(result.RequestID) + if requestID == "" { + return merrors.InvalidArgument("confirmation request_id is required", "request_id") + } + + record, err := s.loadPayment(ctx, requestID) + if err != nil { + return err + } + if record == nil { + return nil + } + + // Store raw reply for audit/debug purposes. This does NOT affect payment state. + if result.RawReply != nil && s.repo != nil && s.repo.TelegramConfirmations() != nil { + if e := s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{ + RequestID: requestID, + PaymentIntentID: record.PaymentIntentID, + QuoteRef: record.QuoteRef, + RawReply: result.RawReply, + }); e != nil { + s.logger.Warn("Failed to store confirmation error", zap.Error(e), + zap.String("request_id", requestID), + zap.String("status", string(result.Status))) + } + } + + // If the payment is already finalized — ignore the result. + switch record.Status { + case storagemodel.PaymentStatusSuccess, + storagemodel.PaymentStatusFailed, + storagemodel.PaymentStatusCancelled: + return nil + } + + now := time.Now() + + switch result.Status { + + // FINAL: confirmation succeeded + case model.ConfirmationStatusConfirmed: + record.Status = storagemodel.PaymentStatusSuccess + record.ExecutedMoney = result.Money + if record.ExecutedAt.IsZero() { + record.ExecutedAt = now + } + record.UpdatedAt = now + + // FINAL: confirmation rejected or timed out + case model.ConfirmationStatusRejected, + model.ConfirmationStatusTimeout: + record.Status = storagemodel.PaymentStatusFailed + record.UpdatedAt = now + + // NOT FINAL: do absolutely nothing + case model.ConfirmationStatusClarified: + s.logger.Debug("Confirmation clarified — no state change", + zap.String("request_id", requestID)) + return nil + + default: + s.logger.Debug("Non-final confirmation status — ignored", + zap.String("request_id", requestID), + zap.String("status", string(result.Status))) + return nil + } + + // The ONLY place where state is persisted and rail event may be emitted + if err := s.updateTransferStatus(ctx, record); err != nil { + return err + } + + if isFinalConfirmationStatus(result.Status) { + _ = s.clearPendingConfirmation(ctx, requestID) + } + + s.publishTelegramReaction(result) + + return nil +} + +func (s *Service) buildConfirmationRequest(intent *model.PaymentGatewayIntent) (*model.ConfirmationRequest, error) { + targetChatID := s.chatID + if targetChatID == "" { + return nil, merrors.InvalidArgument("target_chat_id is required", "target_chat_id") + } + rail := normalizeRail(intent.OutgoingLeg) + if rail == "" { + rail = normalizeRail(s.rail) + } + timeout := s.cfg.TimeoutSeconds + if timeout <= 0 { + timeout = int32(defaultConfirmationTimeoutSeconds) + } + return &model.ConfirmationRequest{ + RequestID: intent.IdempotencyKey, + TargetChatID: targetChatID, + RequestedMoney: intent.RequestedMoney, + PaymentIntentID: intent.PaymentIntentID, + QuoteRef: intent.QuoteRef, + AcceptedUserIDs: s.cfg.AcceptedUserIDs, + TimeoutSeconds: timeout, + SourceService: string(mservice.PaymentGateway), + Rail: rail, + OperationRef: intent.OperationRef, + IntentRef: intent.IntentRef, + PaymentRef: intent.PaymentRef, + }, nil +} + +func (s *Service) sendConfirmationRequest(request *model.ConfirmationRequest) error { + if request == nil { + s.logger.Warn("Confirmation request rejected", zap.String("reason", "request is nil")) + return merrors.InvalidArgument("confirmation request is nil", "request") + } + if s.producer == nil { + s.logger.Warn("Messaging producer not configured") + return merrors.Internal("messaging producer is not configured") + } + env := confirmations.ConfirmationRequest(string(mservice.PaymentGateway), request) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish confirmation request", + zap.Error(err), + zap.String("request_id", request.RequestID), + zap.String("payment_intent_id", request.PaymentIntentID), + zap.String("quote_ref", request.QuoteRef), + zap.String("rail", request.Rail), + zap.Int32("timeout_seconds", request.TimeoutSeconds)) + return err + } + s.logger.Info("Published confirmation request", + zap.String("request_id", request.RequestID), + zap.String("payment_intent_id", request.PaymentIntentID), + zap.String("quote_ref", request.QuoteRef), + zap.String("rail", request.Rail), + zap.Int32("timeout_seconds", request.TimeoutSeconds)) + return nil +} + +func (s *Service) publishTelegramReaction(result *model.ConfirmationResult) { + if s == nil || s.producer == nil || result == nil || result.RawReply == nil { + return + } + if result.Status != model.ConfirmationStatusConfirmed && result.Status != model.ConfirmationStatusClarified { + return + } + request := &model.TelegramReactionRequest{ + RequestID: strings.TrimSpace(result.RequestID), + ChatID: strings.TrimSpace(result.RawReply.ChatID), + MessageID: strings.TrimSpace(result.RawReply.MessageID), + Emoji: s.successReaction, + } + if request.ChatID == "" || request.MessageID == "" || request.Emoji == "" { + return + } + env := tnotifications.TelegramReaction(string(mservice.PaymentGateway), request) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish telegram reaction", + zap.Error(err), + zap.String("request_id", request.RequestID), + zap.String("chat_id", request.ChatID), + zap.String("message_id", request.MessageID), + zap.String("emoji", request.Emoji)) + return + } + s.logger.Info("Published telegram reaction", + zap.String("request_id", request.RequestID), + zap.String("chat_id", request.ChatID), + zap.String("message_id", request.MessageID), + zap.String("emoji", request.Emoji)) +} + +func (s *Service) loadPayment(ctx context.Context, requestID string) (*storagemodel.PaymentRecord, error) { + if s == nil || s.repo == nil || s.repo.Payments() == nil { + return nil, merrors.Internal("payment gateway storage unavailable") + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return nil, merrors.InvalidArgument("request_id is required", "request_id") + } + return s.repo.Payments().FindByIdempotencyKey(ctx, requestID) +} + +func (s *Service) scheduleScenarioTransition(requestID string, scenario settlementScenario) { + if s == nil || strings.TrimSpace(requestID) == "" || !scenario.delayedTransitionEnabled() { + return + } + s.logger.Info("Scenario transition scheduled", + zap.String("request_id", strings.TrimSpace(requestID)), + zap.String("scenario", scenario.Name), + zap.String("initial_status", string(scenario.InitialStatus)), + zap.String("final_status", string(scenario.FinalStatus)), + zap.Duration("final_delay", scenario.FinalDelay)) + s.timeoutWG.Add(1) + go func() { + defer s.timeoutWG.Done() + timer := time.NewTimer(scenario.FinalDelay) + defer timer.Stop() + done := (<-chan struct{})(nil) + if s.timeoutCtx != nil { + done = s.timeoutCtx.Done() + } + select { + case <-timer.C: + s.logger.Debug("Scenario transition timer fired", + zap.String("request_id", strings.TrimSpace(requestID)), + zap.String("scenario", scenario.Name)) + s.applyScenarioTransition(requestID, scenario) + case <-done: + s.logger.Debug("Scenario transition cancelled by shutdown", + zap.String("request_id", strings.TrimSpace(requestID)), + zap.String("scenario", scenario.Name)) + return + } + }() +} + +func (s *Service) applyScenarioTransition(requestID string, scenario settlementScenario) { + if s == nil || strings.TrimSpace(requestID) == "" || scenario.FinalStatus == "" { + return + } + record, err := s.loadPayment(context.Background(), requestID) + if err != nil { + s.logger.Warn("Failed to load payment for scenario transition", zap.Error(err), zap.String("request_id", requestID)) + return + } + if record == nil || isFinalStatus(record) { + s.logger.Debug("Skipping scenario transition: no-op state", + zap.String("request_id", requestID), + zap.Bool("record_missing", record == nil), + zap.Bool("already_final", record != nil && isFinalStatus(record))) + return + } + if current := strings.TrimSpace(record.Scenario); current != "" && !strings.EqualFold(current, scenario.Name) { + s.logger.Debug("Skipping scenario transition due to scenario mismatch", + zap.String("request_id", requestID), + zap.String("expected", scenario.Name), + zap.String("actual", current)) + return + } + + fromStatus := record.Status + record.Status = scenario.FinalStatus + record.UpdatedAt = time.Now() + s.logger.Debug("Applying scenario transition", + zap.String("request_id", requestID), + zap.String("scenario", scenario.Name), + zap.String("from_status", string(fromStatus)), + zap.String("to_status", string(scenario.FinalStatus))) + switch scenario.FinalStatus { + case storagemodel.PaymentStatusSuccess: + record.FailureReason = "" + record.ExecutedMoney = clonePaymentMoney(record.RequestedMoney) + if record.ExecutedAt.IsZero() { + record.ExecutedAt = time.Now() + } + case storagemodel.PaymentStatusFailed: + if strings.TrimSpace(scenario.FailureReason) != "" { + record.FailureReason = strings.TrimSpace(scenario.FailureReason) + } else if strings.TrimSpace(record.FailureReason) == "" { + record.FailureReason = "simulated_settlement_failure" + } + } + + if err := s.updateTransferStatus(context.Background(), record); err != nil { + s.logger.Warn("Failed to apply scenario transition", zap.Error(err), + zap.String("request_id", requestID), + zap.String("scenario", scenario.Name), + zap.String("status", string(record.Status))) + return + } + s.logger.Info("Applied scenario transition", + zap.String("request_id", requestID), + zap.String("scenario", scenario.Name), + zap.String("status", string(record.Status))) +} + +func clonePaymentMoney(src *paymenttypes.Money) *paymenttypes.Money { + if src == nil { + return nil + } + return &paymenttypes.Money{ + Amount: strings.TrimSpace(src.Amount), + Currency: strings.TrimSpace(src.Currency), + } +} + +func metadataKeys(values map[string]string) []string { + if len(values) == 0 { + return nil + } + keys := make([]string, 0, len(values)) + for key := range values { + key = strings.TrimSpace(key) + if key == "" { + continue + } + keys = append(keys, key) + } + return keys +} + +func safeMoneyAmount(m *paymenttypes.Money) string { + if m == nil { + return "" + } + return strings.TrimSpace(m.Amount) +} + +func safeMoneyCurrency(m *paymenttypes.Money) string { + if m == nil { + return "" + } + return strings.TrimSpace(m.Currency) +} + +func (s *Service) startAnnouncer() { + if s == nil { + return + } + if s.producer == nil { + s.logger.Warn("Discovery announcer disabled: messaging producer is not configured") + return + } + rail := discovery.RailProviderSettlement + caps := discovery.ProviderSettlementRailGatewayOperations() + announce := discovery.Announcement{ + ID: discovery.StablePaymentGatewayID(rail), + InstanceID: discovery.InstanceID(), + Service: mservice.ChSettle, + Rail: rail, + Operations: caps, + InvokeURI: s.invokeURI, + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.PaymentGateway), announce) + s.announcer.Start() + s.logger.Info("Discovery announcer started", + zap.String("service", mservice.ChSettle), + zap.String("rail", rail), + zap.String("invoke_uri", s.invokeURI)) +} + +func normalizeIntent(intent *model.PaymentGatewayIntent) *model.PaymentGatewayIntent { + if intent == nil { + return nil + } + cp := *intent + cp.PaymentIntentID = strings.TrimSpace(cp.PaymentIntentID) + cp.IdempotencyKey = strings.TrimSpace(cp.IdempotencyKey) + cp.OutgoingLeg = normalizeRail(cp.OutgoingLeg) + cp.QuoteRef = strings.TrimSpace(cp.QuoteRef) + if cp.RequestedMoney != nil { + cp.RequestedMoney.Amount = strings.TrimSpace(cp.RequestedMoney.Amount) + cp.RequestedMoney.Currency = strings.TrimSpace(cp.RequestedMoney.Currency) + } + cp.IntentRef = strings.TrimSpace(cp.IntentRef) + cp.OperationRef = strings.TrimSpace(cp.OperationRef) + return &cp +} + +func paymentRecordFromIntent(intent *model.PaymentGatewayIntent, confirmReq *model.ConfirmationRequest) *storagemodel.PaymentRecord { + record := &storagemodel.PaymentRecord{ + Status: storagemodel.PaymentStatusWaiting, + } + if intent != nil { + record.IdempotencyKey = strings.TrimSpace(intent.IdempotencyKey) + record.PaymentIntentID = strings.TrimSpace(intent.PaymentIntentID) + record.QuoteRef = strings.TrimSpace(intent.QuoteRef) + record.OutgoingLeg = normalizeRail(intent.OutgoingLeg) + record.RequestedMoney = intent.RequestedMoney + record.IntentRef = intent.IntentRef + record.OperationRef = intent.OperationRef + record.PaymentRef = intent.PaymentRef + } + if confirmReq != nil { + record.IdempotencyKey = strings.TrimSpace(confirmReq.RequestID) + record.PaymentIntentID = strings.TrimSpace(confirmReq.PaymentIntentID) + record.QuoteRef = strings.TrimSpace(confirmReq.QuoteRef) + record.OutgoingLeg = normalizeRail(confirmReq.Rail) + record.RequestedMoney = confirmReq.RequestedMoney + record.IntentRef = strings.TrimSpace(confirmReq.IntentRef) + record.OperationRef = strings.TrimSpace(confirmReq.OperationRef) + record.PaymentRef = confirmReq.PaymentRef + // ExpiresAt is not used to derive an "expired" status — it can be kept for informational purposes only. + if confirmReq.TimeoutSeconds > 0 { + record.ExpiresAt = time.Now().Add(time.Duration(confirmReq.TimeoutSeconds) * time.Second) + } + } + return record +} + +func intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, defaultChatID string) (*model.PaymentGatewayIntent, error) { + if req == nil { + return nil, merrors.InvalidArgument("submit_transfer: request is required") + } + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + if idempotencyKey == "" { + return nil, merrors.InvalidArgument("submit_transfer: idempotency_key is required") + } + intentRef := strings.TrimSpace(req.GetIntentRef()) + if intentRef == "" { + return nil, merrors.InvalidArgument("submit_transfer: intent_ref is required") + } + amount := req.GetAmount() + if amount == nil { + return nil, merrors.InvalidArgument("submit_transfer: amount is required") + } + metadata := req.GetMetadata() + requestedMoney := &paymenttypes.Money{ + Amount: strings.TrimSpace(amount.GetAmount()), + Currency: strings.TrimSpace(amount.GetCurrency()), + } + if requestedMoney.Amount == "" || requestedMoney.Currency == "" { + return nil, merrors.InvalidArgument("submit_transfer: amount is required") + } + sourceAmount := strings.TrimSpace(metadata[metadataSourceAmount]) + sourceCurrency := strings.TrimSpace(metadata[metadataSourceCurrency]) + if sourceAmount != "" && sourceCurrency != "" { + requestedMoney = &paymenttypes.Money{ + Amount: sourceAmount, + Currency: sourceCurrency, + } + } + paymentIntentID := strings.TrimSpace(req.GetIntentRef()) + if paymentIntentID == "" { + paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID]) + } + if paymentIntentID == "" { + return nil, merrors.InvalidArgument("submit_transfer: payment_intent_id is required") + } + paymentRef := strings.TrimSpace(req.PaymentRef) + if paymentRef == "" { + return nil, merrors.InvalidArgument("submit_transfer: payment_ref is required") + } + operationRef := strings.TrimSpace(req.OperationRef) + if operationRef == "" { + return nil, merrors.InvalidArgument("submit_transfer: operation_ref is required") + } + quoteRef := strings.TrimSpace(metadata[metadataQuoteRef]) + targetChatID := strings.TrimSpace(metadata[metadataTargetChatID]) + outgoingLeg := normalizeRail(metadata[metadataOutgoingLeg]) + if outgoingLeg == "" { + outgoingLeg = normalizeRail(defaultRail) + } + if targetChatID == "" { + targetChatID = strings.TrimSpace(defaultChatID) + } + return &model.PaymentGatewayIntent{ + PaymentRef: paymentRef, + PaymentIntentID: paymentIntentID, + IdempotencyKey: idempotencyKey, + OutgoingLeg: outgoingLeg, + QuoteRef: quoteRef, + RequestedMoney: requestedMoney, + IntentRef: intentRef, + OperationRef: operationRef, + }, nil +} + +func normalizeRail(value string) string { + return discovery.NormalizeRail(value) +} + +func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer { + if req == nil { + return nil + } + return &chainv1.Transfer{ + TransferRef: strings.TrimSpace(req.GetIdempotencyKey()), + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()), + SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()), + Destination: req.GetDestination(), + RequestedAmount: req.GetAmount(), + IntentRef: strings.TrimSpace(req.GetIntentRef()), + OperationRef: strings.TrimSpace(req.GetOperationRef()), + PaymentRef: strings.TrimSpace(req.GetPaymentRef()), + Status: chainv1.TransferStatus_TRANSFER_CREATED, + } +} + +func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.SubmitTransferRequest) *chainv1.Transfer { + if record == nil { + return nil + } + + var requested *moneyv1.Money + if req != nil && req.GetAmount() != nil { + requested = req.GetAmount() + } else { + requested = moneyFromPayment(record.RequestedMoney) + } + net := moneyFromPayment(record.RequestedMoney) + + var status chainv1.TransferStatus + + switch record.Status { + case storagemodel.PaymentStatusSuccess: + status = chainv1.TransferStatus_TRANSFER_SUCCESS + case storagemodel.PaymentStatusCancelled: + status = chainv1.TransferStatus_TRANSFER_CANCELLED + case storagemodel.PaymentStatusFailed: + status = chainv1.TransferStatus_TRANSFER_FAILED + case storagemodel.PaymentStatusProcessing: + status = chainv1.TransferStatus_TRANSFER_PROCESSING + case storagemodel.PaymentStatusWaiting: + status = chainv1.TransferStatus_TRANSFER_WAITING + default: + status = chainv1.TransferStatus_TRANSFER_CREATED + } + + transfer := &chainv1.Transfer{ + TransferRef: strings.TrimSpace(record.IdempotencyKey), + IdempotencyKey: strings.TrimSpace(record.IdempotencyKey), + RequestedAmount: requested, + NetAmount: net, + IntentRef: strings.TrimSpace(record.IntentRef), + OperationRef: strings.TrimSpace(record.OperationRef), + PaymentRef: strings.TrimSpace(record.PaymentRef), + FailureReason: strings.TrimSpace(record.FailureReason), + Status: status, + } + + if req != nil { + transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef()) + transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef()) + transfer.Destination = req.GetDestination() + } + + if !record.ExecutedAt.IsZero() { + ts := timestamppb.New(record.ExecutedAt) + transfer.CreatedAt = ts + transfer.UpdatedAt = ts + } else if !record.UpdatedAt.IsZero() { + ts := timestamppb.New(record.UpdatedAt) + transfer.UpdatedAt = ts + if !record.CreatedAt.IsZero() { + transfer.CreatedAt = timestamppb.New(record.CreatedAt) + } + } + + return transfer +} + +func moneyFromPayment(m *paymenttypes.Money) *moneyv1.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.Currency) + amount := strings.TrimSpace(m.Amount) + if currency == "" || amount == "" { + return nil + } + return &moneyv1.Money{ + Currency: currency, + Amount: amount, + } +} + +func readEnv(env string) string { + if strings.TrimSpace(env) == "" { + return "" + } + return strings.TrimSpace(os.Getenv(env)) +} + +var _ grpcapp.Service = (*Service)(nil) diff --git a/api/gateway/chsettle/internal/service/gateway/service_test.go b/api/gateway/chsettle/internal/service/gateway/service_test.go new file mode 100644 index 00000000..a28e57e4 --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/service_test.go @@ -0,0 +1,417 @@ +package gateway + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/tech/sendico/gateway/chsettle/storage" + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/discovery" + envelope "github.com/tech/sendico/pkg/messaging/envelope" + tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram" + mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + 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" +) + +// +// FAKE STORES +// + +type fakePaymentsStore struct { + mu sync.Mutex + records map[string]*storagemodel.PaymentRecord +} + +func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentRecord, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.records == nil { + return nil, nil + } + return f.records[key], nil +} + +func (f *fakePaymentsStore) FindByOperationRef(_ context.Context, key string) (*storagemodel.PaymentRecord, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.records == nil { + return nil, nil + } + for _, record := range f.records { + if record != nil && record.OperationRef == key { + return record, nil + } + } + return nil, nil +} + +func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.records == nil { + f.records = map[string]*storagemodel.PaymentRecord{} + } + f.records[record.IdempotencyKey] = record + return nil +} + +type fakeTelegramStore struct { + mu sync.Mutex + records map[string]*storagemodel.TelegramConfirmation +} + +func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.TelegramConfirmation) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.records == nil { + f.records = map[string]*storagemodel.TelegramConfirmation{} + } + f.records[record.RequestID] = record + return nil +} + +type fakeRepo struct { + payments *fakePaymentsStore + tg *fakeTelegramStore + pending *fakePendingStore + treasury storage.TreasuryRequestsStore + users storage.TreasuryTelegramUsersStore +} + +func (f *fakeRepo) Payments() storage.PaymentsStore { + return f.payments +} + +func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore { + return f.tg +} + +func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore { + return f.pending +} + +func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore { + return f.treasury +} + +func (f *fakeRepo) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore { + return f.users +} + +type fakePendingStore struct { + mu sync.Mutex + records map[string]*storagemodel.PendingConfirmation +} + +func (f *fakePendingStore) Upsert(_ context.Context, record *storagemodel.PendingConfirmation) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.records == nil { + f.records = map[string]*storagemodel.PendingConfirmation{} + } + cp := *record + f.records[record.RequestID] = &cp + return nil +} + +func (f *fakePendingStore) FindByRequestID(_ context.Context, requestID string) (*storagemodel.PendingConfirmation, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.records == nil { + return nil, nil + } + return f.records[requestID], nil +} + +func (f *fakePendingStore) FindByMessageID(_ context.Context, messageID string) (*storagemodel.PendingConfirmation, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, record := range f.records { + if record != nil && record.MessageID == messageID { + return record, nil + } + } + return nil, nil +} + +func (f *fakePendingStore) MarkClarified(_ context.Context, requestID string) error { + f.mu.Lock() + defer f.mu.Unlock() + if record := f.records[requestID]; record != nil { + record.Clarified = true + } + return nil +} + +func (f *fakePendingStore) AttachMessage(_ context.Context, requestID string, messageID string) error { + f.mu.Lock() + defer f.mu.Unlock() + if record := f.records[requestID]; record != nil { + if record.MessageID == "" { + record.MessageID = messageID + } + } + return nil +} + +func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.records, requestID) + return nil +} + +func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]storagemodel.PendingConfirmation, error) { + f.mu.Lock() + defer f.mu.Unlock() + if limit <= 0 { + limit = 100 + } + result := make([]storagemodel.PendingConfirmation, 0) + for _, record := range f.records { + if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) { + continue + } + result = append(result, *record) + if int64(len(result)) >= limit { + break + } + } + return result, nil +} + +// +// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА) +// + +type fakeBroker struct{} + +func (f *fakeBroker) Publish(_ envelope.Envelope) error { + return nil +} + +func (f *fakeBroker) Subscribe(event model.NotificationEvent) (<-chan envelope.Envelope, error) { + return nil, nil +} + +func (f *fakeBroker) Unsubscribe(event model.NotificationEvent, subChan <-chan envelope.Envelope) error { + return nil +} + +// +// CAPTURE ONLY TELEGRAM REACTIONS +// + +type captureProducer struct { + mu sync.Mutex + reactions []envelope.Envelope + sig string +} + +func (c *captureProducer) SendMessage(env envelope.Envelope) error { + if env.GetSignature().ToString() != c.sig { + return nil + } + c.mu.Lock() + c.reactions = append(c.reactions, env) + c.mu.Unlock() + return nil +} + +// +// TESTS +// + +func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) { + logger := mloggerfactory.NewLogger(false) + + repo := &fakeRepo{ + payments: &fakePaymentsStore{}, + tg: &fakeTelegramStore{}, + pending: &fakePendingStore{}, + } + + sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{ + RequestID: "x", + ChatID: "1", + MessageID: "2", + Emoji: "ok", + }) + + prod := &captureProducer{ + sig: sigEnv.GetSignature().ToString(), + } + + svc := NewService(logger, repo, prod, &fakeBroker{}, Config{ + Rail: "card", + SuccessReaction: "👍", + }) + + return svc, repo, prod +} + +func TestConfirmed(t *testing.T) { + svc, repo, prod := newTestService(t) + + _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ + IdempotencyKey: "idem-1", + PaymentIntentID: "pi-1", + QuoteRef: "quote-1", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + Status: storagemodel.PaymentStatusWaiting, + }) + + result := &model.ConfirmationResult{ + RequestID: "idem-1", + Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + Status: model.ConfirmationStatusConfirmed, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, + } + + _ = svc.onConfirmationResult(context.Background(), result) + + rec := repo.payments.records["idem-1"] + + if rec.Status != storagemodel.PaymentStatusSuccess { + t.Fatalf("expected success, got %s", rec.Status) + } + if rec.RequestedMoney == nil { + t.Fatalf("requested money not set") + } + if rec.ExecutedAt.IsZero() { + t.Fatalf("executedAt not set") + } + if repo.tg.records["idem-1"] == nil { + t.Fatalf("telegram confirmation not stored") + } + if len(prod.reactions) != 1 { + t.Fatalf("reaction must be published") + } +} + +func TestClarified(t *testing.T) { + svc, repo, prod := newTestService(t) + + _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ + IdempotencyKey: "idem-2", + Status: storagemodel.PaymentStatusWaiting, + }) + + result := &model.ConfirmationResult{ + RequestID: "idem-2", + Status: model.ConfirmationStatusClarified, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, + } + + _ = svc.onConfirmationResult(context.Background(), result) + + rec := repo.payments.records["idem-2"] + + if rec.Status != storagemodel.PaymentStatusWaiting { + t.Fatalf("clarified must not change status") + } + if repo.tg.records["idem-2"] == nil { + t.Fatalf("telegram confirmation must be stored") + } + if len(prod.reactions) != 0 { + t.Fatalf("clarified must not publish reaction") + } +} + +func TestRejected(t *testing.T) { + svc, repo, prod := newTestService(t) + + // ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil, + // даем минимально ожидаемые поля + non-nil ExecutedMoney. + _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ + IdempotencyKey: "idem-3", + PaymentIntentID: "pi-3", + QuoteRef: "quote-3", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"}, + Status: storagemodel.PaymentStatusWaiting, + }) + + result := &model.ConfirmationResult{ + RequestID: "idem-3", + Status: model.ConfirmationStatusRejected, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, + } + + _ = svc.onConfirmationResult(context.Background(), result) + + rec := repo.payments.records["idem-3"] + + if rec.Status != storagemodel.PaymentStatusFailed { + t.Fatalf("expected failed") + } + if repo.tg.records["idem-3"] == nil { + t.Fatalf("telegram confirmation must be stored") + } + if len(prod.reactions) != 0 { + t.Fatalf("rejected must not publish reaction") + } +} + +func TestTimeout(t *testing.T) { + svc, repo, prod := newTestService(t) + + // ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil, + // даем минимально ожидаемые поля + non-nil ExecutedMoney. + _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ + IdempotencyKey: "idem-4", + PaymentIntentID: "pi-4", + QuoteRef: "quote-4", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"}, + Status: storagemodel.PaymentStatusWaiting, + }) + + result := &model.ConfirmationResult{ + RequestID: "idem-4", + Status: model.ConfirmationStatusTimeout, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, + } + + _ = svc.onConfirmationResult(context.Background(), result) + + rec := repo.payments.records["idem-4"] + + if rec.Status != storagemodel.PaymentStatusFailed { + t.Fatalf("timeout must be failed") + } + if repo.tg.records["idem-4"] == nil { + t.Fatalf("telegram confirmation must be stored") + } + if len(prod.reactions) != 0 { + t.Fatalf("timeout must not publish reaction") + } +} + +func TestIntentFromSubmitTransfer_NormalizesOutgoingLeg(t *testing.T) { + intent, err := intentFromSubmitTransfer(&chainv1.SubmitTransferRequest{ + IdempotencyKey: "idem-5", + IntentRef: "pi-5", + OperationRef: "op-5", + PaymentRef: "pay-5", + Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + Metadata: map[string]string{ + metadataOutgoingLeg: "card", + }, + }, "provider_settlement", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := intent.OutgoingLeg, discovery.RailCardPayout; got != want { + t.Fatalf("unexpected outgoing leg: got=%q want=%q", got, want) + } +} diff --git a/api/gateway/chsettle/internal/service/gateway/transfer_notifications.go b/api/gateway/chsettle/internal/service/gateway/transfer_notifications.go new file mode 100644 index 00000000..39fe76eb --- /dev/null +++ b/api/gateway/chsettle/internal/service/gateway/transfer_notifications.go @@ -0,0 +1,108 @@ +package gateway + +import ( + "context" + + "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" + pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/mutil/mzap" + "github.com/tech/sendico/pkg/payments/rail" + "go.uber.org/zap" +) + +func isFinalStatus(t *model.PaymentRecord) bool { + switch t.Status { + case model.PaymentStatusFailed, model.PaymentStatusSuccess, model.PaymentStatusCancelled: + return true + default: + return false + } +} + +func toOpStatus(t *model.PaymentRecord) (rail.OperationResult, error) { + switch t.Status { + case model.PaymentStatusFailed: + return rail.OperationResultFailed, nil + case model.PaymentStatusSuccess: + return rail.OperationResultSuccess, nil + case model.PaymentStatusCancelled: + return rail.OperationResultCancelled, nil + default: + return rail.OperationResultFailed, merrors.InvalidArgument("unexpected transfer status", "payment.status") + } +} + +func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error { + if record == nil { + return merrors.InvalidArgument("payment record is required", "record") + } + s.logger.Debug("Persisting transfer status", + zap.String("idempotency_key", record.IdempotencyKey), + zap.String("payment_ref", record.PaymentIntentID), + zap.String("status", string(record.Status)), + zap.Bool("is_final", isFinalStatus(record))) + if !isFinalStatus(record) { + if err := s.repo.Payments().Upsert(ctx, record); err != nil { + s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err)) + return err + } + s.logger.Debug("Transfer status persisted (non-final)", + zap.String("idempotency_key", record.IdempotencyKey), + zap.String("status", string(record.Status))) + return nil + } + + _, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) { + if upsertErr := s.repo.Payments().Upsert(txCtx, record); upsertErr != nil { + return nil, upsertErr + } + if isFinalStatus(record) { + if emitErr := s.emitTransferStatusEvent(txCtx, record); emitErr != nil { + return nil, emitErr + } + } + return nil, nil + }) + if err != nil { + s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err)) + return err + } + s.logger.Info("Transfer status persisted (final)", + zap.String("idempotency_key", record.IdempotencyKey), + zap.String("status", string(record.Status))) + return nil +} + +func (s *Service) emitTransferStatusEvent(ctx context.Context, record *model.PaymentRecord) error { + if s == nil || record == nil { + return nil + } + if s.producer == nil || s.outboxStore() == nil { + return nil + } + status, err := toOpStatus(record) + if err != nil { + s.logger.Warn("Failed to map transfer status for transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID)) + return err + } + + exec := pmodel.PaymentGatewayExecution{ + PaymentIntentID: record.PaymentIntentID, + IdempotencyKey: record.IdempotencyKey, + ExecutedMoney: record.ExecutedMoney, + PaymentRef: record.PaymentRef, + Status: status, + OperationRef: record.OperationRef, + Error: record.FailureReason, + TransferRef: record.ID.Hex(), + } + env := paymentgateway.PaymentGatewayExecution(mservice.ChSettle, &exec) + if sendErr := s.sendWithOutbox(ctx, env); sendErr != nil { + s.logger.Warn("Failed to publish transfer status event", zap.Error(sendErr), mzap.ObjRef("transfer_ref", record.ID)) + return sendErr + } + return nil +} diff --git a/api/gateway/chsettle/internal/service/treasury/bot/commands.go b/api/gateway/chsettle/internal/service/treasury/bot/commands.go new file mode 100644 index 00000000..d506616c --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/bot/commands.go @@ -0,0 +1,94 @@ +package bot + +import "strings" + +type Command string + +const ( + CommandStart Command = "start" + CommandHelp Command = "help" + CommandFund Command = "fund" + CommandWithdraw Command = "withdraw" + CommandConfirm Command = "confirm" + CommandCancel Command = "cancel" +) + +var supportedCommands = []Command{ + CommandStart, + CommandHelp, + CommandFund, + CommandWithdraw, + CommandConfirm, + CommandCancel, +} + +func (c Command) Slash() string { + name := strings.TrimSpace(string(c)) + if name == "" { + return "" + } + return "/" + name +} + +func parseCommand(text string) Command { + text = strings.TrimSpace(text) + if !strings.HasPrefix(text, "/") { + return "" + } + token := text + if idx := strings.IndexAny(token, " \t\n\r"); idx >= 0 { + token = token[:idx] + } + token = strings.TrimPrefix(token, "/") + if idx := strings.Index(token, "@"); idx >= 0 { + token = token[:idx] + } + return Command(strings.ToLower(strings.TrimSpace(token))) +} + +func supportedCommandsMessage() string { + lines := make([]string, 0, len(supportedCommands)+2) + lines = append(lines, "*Supported Commands*") + lines = append(lines, "") + for _, cmd := range supportedCommands { + lines = append(lines, markdownCommand(cmd)) + } + return strings.Join(lines, "\n") +} + +func confirmationCommandsMessage() string { + return strings.Join([]string{ + "*Confirm Operation*", + "", + "Use " + markdownCommand(CommandConfirm) + " to execute.", + "Use " + markdownCommand(CommandCancel) + " to abort.", + }, "\n") +} + +func helpMessage(accountCode string, currency string) string { + accountCode = strings.TrimSpace(accountCode) + currency = strings.ToUpper(strings.TrimSpace(currency)) + if accountCode == "" { + accountCode = "N/A" + } + if currency == "" { + currency = "N/A" + } + + lines := []string{ + "*Treasury Bot Help*", + "", + "*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")", + "", + "*How to use*", + "1. Start funding with " + markdownCommand(CommandFund) + " or withdrawal with " + markdownCommand(CommandWithdraw) + ".", + "2. Enter amount as decimal with dot separator and no currency.", + " Example: " + markdownCode("1250.75"), + "3. Confirm with " + markdownCommand(CommandConfirm) + " or abort with " + markdownCommand(CommandCancel) + ".", + "", + "*Cooldown*", + "After confirmation there is a cooldown window. You can cancel during it with " + markdownCommand(CommandCancel) + ".", + "You will receive a follow-up message with execution success or failure.", + } + return strings.Join(lines, "\n") +} diff --git a/api/gateway/chsettle/internal/service/treasury/bot/dialogs.go b/api/gateway/chsettle/internal/service/treasury/bot/dialogs.go new file mode 100644 index 00000000..08f36fa7 --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/bot/dialogs.go @@ -0,0 +1,73 @@ +package bot + +import ( + "strings" + "sync" + + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" +) + +type DialogState string + +const ( + DialogStateWaitingAmount DialogState = "waiting_amount" + DialogStateWaitingConfirmation DialogState = "waiting_confirmation" +) + +type DialogSession struct { + State DialogState + OperationType storagemodel.TreasuryOperationType + LedgerAccountID string + RequestID string +} + +type Dialogs struct { + mu sync.Mutex + sessions map[string]DialogSession +} + +func NewDialogs() *Dialogs { + return &Dialogs{ + sessions: map[string]DialogSession{}, + } +} + +func (d *Dialogs) Get(telegramUserID string) (DialogSession, bool) { + if d == nil { + return DialogSession{}, false + } + telegramUserID = strings.TrimSpace(telegramUserID) + if telegramUserID == "" { + return DialogSession{}, false + } + d.mu.Lock() + defer d.mu.Unlock() + session, ok := d.sessions[telegramUserID] + return session, ok +} + +func (d *Dialogs) Set(telegramUserID string, session DialogSession) { + if d == nil { + return + } + telegramUserID = strings.TrimSpace(telegramUserID) + if telegramUserID == "" { + return + } + d.mu.Lock() + defer d.mu.Unlock() + d.sessions[telegramUserID] = session +} + +func (d *Dialogs) Clear(telegramUserID string) { + if d == nil { + return + } + telegramUserID = strings.TrimSpace(telegramUserID) + if telegramUserID == "" { + return + } + d.mu.Lock() + defer d.mu.Unlock() + delete(d.sessions, telegramUserID) +} diff --git a/api/gateway/chsettle/internal/service/treasury/bot/markup.go b/api/gateway/chsettle/internal/service/treasury/bot/markup.go new file mode 100644 index 00000000..e62b202f --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/bot/markup.go @@ -0,0 +1,18 @@ +package bot + +import ( + "strings" +) + +func markdownCode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + value = "N/A" + } + value = strings.ReplaceAll(value, "`", "'") + return "`" + value + "`" +} + +func markdownCommand(command Command) string { + return command.Slash() +} diff --git a/api/gateway/chsettle/internal/service/treasury/bot/router.go b/api/gateway/chsettle/internal/service/treasury/bot/router.go new file mode 100644 index 00000000..af338a72 --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/bot/router.go @@ -0,0 +1,527 @@ +package bot + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "go.uber.org/zap" +) + +const unauthorizedMessage = "*Unauthorized*\nYour Telegram account is not allowed to perform treasury operations." +const unauthorizedChatMessage = "*Unauthorized Chat*\nThis Telegram chat is not allowed to perform treasury operations." + +const amountInputHint = "*Amount format*\nEnter amount as a decimal number using a dot separator and without currency.\nExample: `1250.75`" + +type SendTextFunc func(ctx context.Context, chatID string, text string) error + +type ScheduleTracker interface { + TrackScheduled(record *storagemodel.TreasuryRequest) + Untrack(requestID string) +} + +type AccountProfile struct { + AccountID string + AccountCode string + Currency string +} + +type CreateRequestInput struct { + OperationType storagemodel.TreasuryOperationType + TelegramUserID string + LedgerAccountID string + ChatID string + Amount string +} + +type TreasuryService interface { + ExecutionDelay() time.Duration + MaxPerOperationLimit() string + + GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) + GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error) + CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) + ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) + CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) +} + +type UserBinding struct { + TelegramUserID string + LedgerAccountID string + AllowedChatIDs []string +} + +type UserBindingResolver interface { + ResolveUserBinding(ctx context.Context, telegramUserID string) (*UserBinding, error) +} + +type limitError interface { + error + LimitKind() string + LimitMax() string +} + +type Router struct { + logger mlogger.Logger + + service TreasuryService + dialogs *Dialogs + send SendTextFunc + tracker ScheduleTracker + + users UserBindingResolver +} + +func NewRouter( + logger mlogger.Logger, + service TreasuryService, + send SendTextFunc, + tracker ScheduleTracker, + users UserBindingResolver, +) *Router { + if logger != nil { + logger = logger.Named("treasury_router") + } + return &Router{ + logger: logger, + service: service, + dialogs: NewDialogs(), + send: send, + tracker: tracker, + users: users, + } +} + +func (r *Router) Enabled() bool { + return r != nil && r.service != nil && r.users != nil +} + +func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool { + if !r.Enabled() || update == nil || update.Message == nil { + return false + } + message := update.Message + chatID := strings.TrimSpace(message.ChatID) + userID := strings.TrimSpace(message.FromUserID) + text := strings.TrimSpace(message.Text) + + if chatID == "" || userID == "" { + return false + } + command := parseCommand(text) + if r.logger != nil { + r.logger.Debug("Telegram treasury update received", + zap.Int64("update_id", update.UpdateID), + zap.String("chat_id", chatID), + zap.String("telegram_user_id", userID), + zap.String("command", strings.TrimSpace(string(command))), + zap.String("message_text", text), + zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)), + ) + } + + binding, err := r.users.ResolveUserBinding(ctx, userID) + if err != nil { + if r.logger != nil { + r.logger.Warn("Failed to resolve treasury user binding", + zap.Error(err), + zap.String("telegram_user_id", userID), + zap.String("chat_id", chatID)) + } + _ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check treasury authorization right now. Please try again.") + return true + } + if binding == nil || strings.TrimSpace(binding.LedgerAccountID) == "" { + r.logUnauthorized(update) + _ = r.sendText(ctx, chatID, unauthorizedMessage) + return true + } + if !isChatAllowed(chatID, binding.AllowedChatIDs) { + r.logUnauthorized(update) + _ = r.sendText(ctx, chatID, unauthorizedChatMessage) + return true + } + accountID := strings.TrimSpace(binding.LedgerAccountID) + + switch command { + case CommandStart: + profile := r.resolveAccountProfile(ctx, accountID) + _ = r.sendText(ctx, chatID, welcomeMessage(profile)) + return true + case CommandHelp: + profile := r.resolveAccountProfile(ctx, accountID) + _ = r.sendText(ctx, chatID, helpMessage(displayAccountCode(profile, accountID), profile.Currency)) + return true + case CommandFund: + if r.logger != nil { + r.logger.Info("Treasury funding dialog requested", + zap.String("chat_id", chatID), + zap.String("telegram_user_id", userID), + zap.String("ledger_account_id", accountID)) + } + r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund) + return true + case CommandWithdraw: + if r.logger != nil { + r.logger.Info("Treasury withdrawal dialog requested", + zap.String("chat_id", chatID), + zap.String("telegram_user_id", userID), + zap.String("ledger_account_id", accountID)) + } + r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw) + return true + case CommandConfirm: + if r.logger != nil { + r.logger.Info("Treasury confirmation requested", + zap.String("chat_id", chatID), + zap.String("telegram_user_id", userID), + zap.String("ledger_account_id", accountID)) + } + r.confirm(ctx, userID, accountID, chatID) + return true + case CommandCancel: + if r.logger != nil { + r.logger.Info("Treasury cancellation requested", + zap.String("chat_id", chatID), + zap.String("telegram_user_id", userID), + zap.String("ledger_account_id", accountID)) + } + r.cancel(ctx, userID, accountID, chatID) + return true + } + + session, hasSession := r.dialogs.Get(userID) + if hasSession { + switch session.State { + case DialogStateWaitingAmount: + r.captureAmount(ctx, userID, accountID, chatID, session.OperationType, text) + return true + case DialogStateWaitingConfirmation: + _ = r.sendText(ctx, chatID, confirmationCommandsMessage()) + return true + } + } + + if strings.HasPrefix(text, "/") { + _ = r.sendText(ctx, chatID, supportedCommandsMessage()) + return true + } + if strings.TrimSpace(message.ReplyToMessageID) != "" { + return false + } + if text != "" { + _ = r.sendText(ctx, chatID, supportedCommandsMessage()) + return true + } + return false +} + +func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) { + active, err := r.service.GetActiveRequestForAccount(ctx, accountID) + if err != nil { + if r.logger != nil { + r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID)) + } + _ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check pending treasury operations right now. Please try again.") + return + } + if active != nil { + _ = r.sendText(ctx, chatID, pendingRequestMessage(active)) + r.dialogs.Set(userID, DialogSession{ + State: DialogStateWaitingConfirmation, + LedgerAccountID: accountID, + RequestID: active.RequestID, + }) + return + } + r.dialogs.Set(userID, DialogSession{ + State: DialogStateWaitingAmount, + OperationType: operation, + LedgerAccountID: accountID, + }) + profile := r.resolveAccountProfile(ctx, accountID) + _ = r.sendText(ctx, chatID, amountPromptMessage(operation, profile, accountID)) +} + +func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType, amount string) { + record, err := r.service.CreateRequest(ctx, CreateRequestInput{ + OperationType: operation, + TelegramUserID: userID, + LedgerAccountID: accountID, + ChatID: chatID, + Amount: amount, + }) + if err != nil { + if record != nil { + _ = r.sendText(ctx, chatID, pendingRequestMessage(record)) + r.dialogs.Set(userID, DialogSession{ + State: DialogStateWaitingConfirmation, + LedgerAccountID: accountID, + RequestID: record.RequestID, + }) + return + } + if typed, ok := err.(limitError); ok { + switch typed.LimitKind() { + case "per_operation": + _ = r.sendText(ctx, chatID, "*Amount exceeds allowed limit*\n\n*Max per operation:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") + return + case "daily": + _ = r.sendText(ctx, chatID, "*Daily amount limit exceeded*\n\n*Max per day:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") + return + } + } + if errors.Is(err, merrors.ErrInvalidArg) { + _ = r.sendText(ctx, chatID, "*Invalid amount*\n\n"+amountInputHint+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") + return + } + _ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") + return + } + if record == nil { + _ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") + return + } + r.dialogs.Set(userID, DialogSession{ + State: DialogStateWaitingConfirmation, + LedgerAccountID: accountID, + RequestID: record.RequestID, + }) + _ = r.sendText(ctx, chatID, confirmationPrompt(record)) +} + +func (r *Router) confirm(ctx context.Context, userID string, accountID string, chatID string) { + requestID := "" + if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" { + requestID = strings.TrimSpace(session.RequestID) + } else { + active, err := r.service.GetActiveRequestForAccount(ctx, accountID) + if err == nil && active != nil { + requestID = strings.TrimSpace(active.RequestID) + } + } + if requestID == "" { + _ = r.sendText(ctx, chatID, "*No pending treasury operation.*") + return + } + record, err := r.service.ConfirmRequest(ctx, requestID, userID) + if err != nil { + _ = r.sendText(ctx, chatID, "*Unable to confirm treasury request.*\n\nUse "+markdownCommand(CommandCancel)+" or create a new request with "+markdownCommand(CommandFund)+" or "+markdownCommand(CommandWithdraw)+".") + return + } + if r.tracker != nil { + r.tracker.TrackScheduled(record) + } + r.dialogs.Clear(userID) + delay := int64(r.service.ExecutionDelay().Seconds()) + if delay < 0 { + delay = 0 + } + _ = r.sendText(ctx, chatID, + "*Operation confirmed*\n\n"+ + "*Execution:* scheduled in "+markdownCode(formatSeconds(delay))+".\n"+ + "You can cancel during this cooldown with "+markdownCommand(CommandCancel)+".\n\n"+ + "You will receive a follow-up message with execution success or failure.\n\n"+ + "*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID))) +} + +func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) { + requestID := "" + if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" { + requestID = strings.TrimSpace(session.RequestID) + } else { + active, err := r.service.GetActiveRequestForAccount(ctx, accountID) + if err == nil && active != nil { + requestID = strings.TrimSpace(active.RequestID) + } + } + if requestID == "" { + r.dialogs.Clear(userID) + _ = r.sendText(ctx, chatID, "*No pending treasury operation.*") + return + } + record, err := r.service.CancelRequest(ctx, requestID, userID) + if err != nil { + _ = r.sendText(ctx, chatID, "*Unable to cancel treasury request.*") + return + } + if r.tracker != nil { + r.tracker.Untrack(record.RequestID) + } + r.dialogs.Clear(userID) + _ = r.sendText(ctx, chatID, "*Operation cancelled*\n\n*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID))) +} + +func (r *Router) sendText(ctx context.Context, chatID string, text string) error { + if r == nil || r.send == nil { + return nil + } + chatID = strings.TrimSpace(chatID) + text = strings.TrimSpace(text) + if chatID == "" || text == "" { + return nil + } + if err := r.send(ctx, chatID, text); err != nil { + if r.logger != nil { + r.logger.Warn("Failed to send treasury bot response", + zap.Error(err), + zap.String("chat_id", chatID), + zap.String("message_text", text)) + } + return err + } + return nil +} + +func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) { + if r == nil || r.logger == nil || update == nil || update.Message == nil { + return + } + message := update.Message + r.logger.Warn("unauthorized_access", + zap.String("event", "unauthorized_access"), + zap.String("telegram_user_id", strings.TrimSpace(message.FromUserID)), + zap.String("chat_id", strings.TrimSpace(message.ChatID)), + zap.String("message_text", strings.TrimSpace(message.Text)), + zap.Time("timestamp", time.Now()), + ) +} + +func pendingRequestMessage(record *storagemodel.TreasuryRequest) string { + if record == nil { + return "*Pending treasury operation already exists.*\n\nUse " + markdownCommand(CommandCancel) + "." + } + return "*Pending Treasury Operation*\n\n" + + "*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" + + "*Request ID:* " + markdownCode(strings.TrimSpace(record.RequestID)) + "\n" + + "*Status:* " + markdownCode(strings.TrimSpace(string(record.Status))) + "\n" + + "*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" + + "Wait for execution or cancel with " + markdownCommand(CommandCancel) + "." +} + +func confirmationPrompt(record *storagemodel.TreasuryRequest) string { + if record == nil { + return "*Request created.*\n\nUse " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + "." + } + title := "*Funding request created.*" + if record.OperationType == storagemodel.TreasuryOperationWithdraw { + title = "*Withdrawal request created.*" + } + return title + "\n\n" + + "*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" + + "*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" + + confirmationCommandsMessage() +} + +func welcomeMessage(profile *AccountProfile) string { + accountCode := displayAccountCode(profile, "") + currency := "" + if profile != nil { + currency = strings.ToUpper(strings.TrimSpace(profile.Currency)) + } + if accountCode == "" { + accountCode = "N/A" + } + if currency == "" { + currency = "N/A" + } + return "*Sendico Treasury Bot*\n\n" + + "*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n" + + "Use " + markdownCommand(CommandFund) + " to credit your account and " + markdownCommand(CommandWithdraw) + " to debit it.\n" + + "After entering an amount, use " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + ".\n" + + "Use " + markdownCommand(CommandHelp) + " for detailed usage." +} + +func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string { + title := "*Funding request*" + if operation == storagemodel.TreasuryOperationWithdraw { + title = "*Withdrawal request*" + } + accountCode := displayAccountCode(profile, fallbackAccountID) + currency := "" + if profile != nil { + currency = strings.ToUpper(strings.TrimSpace(profile.Currency)) + } + if accountCode == "" { + accountCode = "N/A" + } + if currency == "" { + currency = "N/A" + } + return title + "\n\n" + + "*Account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n\n" + + amountInputHint +} + +func requestAccountDisplay(record *storagemodel.TreasuryRequest) string { + if record == nil { + return "" + } + if code := strings.TrimSpace(record.LedgerAccountCode); code != "" { + return code + } + return strings.TrimSpace(record.LedgerAccountID) +} + +func displayAccountCode(profile *AccountProfile, fallbackAccountID string) string { + if profile != nil { + if code := strings.TrimSpace(profile.AccountCode); code != "" { + return code + } + if id := strings.TrimSpace(profile.AccountID); id != "" { + return id + } + } + return strings.TrimSpace(fallbackAccountID) +} + +func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID string) *AccountProfile { + if r == nil || r.service == nil { + return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)} + } + profile, err := r.service.GetAccountProfile(ctx, ledgerAccountID) + if err != nil { + if r.logger != nil { + r.logger.Warn("Failed to resolve treasury account profile", + zap.Error(err), + zap.String("ledger_account_id", strings.TrimSpace(ledgerAccountID))) + } + return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)} + } + if profile == nil { + return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)} + } + if strings.TrimSpace(profile.AccountID) == "" { + profile.AccountID = strings.TrimSpace(ledgerAccountID) + } + return profile +} + +func isChatAllowed(chatID string, allowedChatIDs []string) bool { + chatID = strings.TrimSpace(chatID) + if chatID == "" { + return false + } + if len(allowedChatIDs) == 0 { + return true + } + for _, allowed := range allowedChatIDs { + if strings.TrimSpace(allowed) == chatID { + return true + } + } + return false +} + +func formatSeconds(value int64) string { + if value == 1 { + return "1 second" + } + return strconv.FormatInt(value, 10) + " seconds" +} diff --git a/api/gateway/chsettle/internal/service/treasury/bot/router_test.go b/api/gateway/chsettle/internal/service/treasury/bot/router_test.go new file mode 100644 index 00000000..a956fc67 --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/bot/router_test.go @@ -0,0 +1,362 @@ +package bot + +import ( + "context" + "testing" + "time" + + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" + "github.com/tech/sendico/pkg/model" +) + +type fakeService struct{} + +type fakeUserBindingResolver struct { + bindings map[string]*UserBinding + err error +} + +func (f fakeUserBindingResolver) ResolveUserBinding(_ context.Context, telegramUserID string) (*UserBinding, error) { + if f.err != nil { + return nil, f.err + } + if f.bindings == nil { + return nil, nil + } + return f.bindings[telegramUserID], nil +} + +func (fakeService) ExecutionDelay() time.Duration { + return 30 * time.Second +} + +func (fakeService) MaxPerOperationLimit() string { + return "1000000" +} + +func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storagemodel.TreasuryRequest, error) { + return nil, nil +} + +func (fakeService) GetAccountProfile(_ context.Context, ledgerAccountID string) (*AccountProfile, error) { + return &AccountProfile{ + AccountID: ledgerAccountID, + AccountCode: ledgerAccountID, + Currency: "USD", + }, nil +} + +func (fakeService) CreateRequest(context.Context, CreateRequestInput) (*storagemodel.TreasuryRequest, error) { + return nil, nil +} + +func (fakeService) ConfirmRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) { + return nil, nil +} + +func (fakeService) CancelRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) { + return nil, nil +} + +func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) { + var sent []string + router := NewRouter( + mloggerfactory.NewLogger(false), + fakeService{}, + func(_ context.Context, _ string, text string) error { + sent = append(sent, text) + return nil + }, + nil, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + AllowedChatIDs: []string{"100"}, + }, + }, + }, + ) + handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ + Message: &model.TelegramMessage{ + ChatID: "100", + FromUserID: "999", + Text: "/fund", + }, + }) + if !handled { + t.Fatalf("expected update to be handled") + } + if len(sent) != 1 { + t.Fatalf("expected one message, got %d", len(sent)) + } + if sent[0] != unauthorizedMessage { + t.Fatalf("unexpected message: %q", sent[0]) + } +} + +func TestRouterUnknownChatGetsDenied(t *testing.T) { + var sent []string + router := NewRouter( + mloggerfactory.NewLogger(false), + fakeService{}, + func(_ context.Context, _ string, text string) error { + sent = append(sent, text) + return nil + }, + nil, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + AllowedChatIDs: []string{"100"}, + }, + }, + }, + ) + handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ + Message: &model.TelegramMessage{ + ChatID: "999", + FromUserID: "123", + Text: "/fund", + }, + }) + if !handled { + t.Fatalf("expected update to be handled") + } + if len(sent) != 1 { + t.Fatalf("expected one message, got %d", len(sent)) + } + if sent[0] != unauthorizedChatMessage { + t.Fatalf("unexpected message: %q", sent[0]) + } +} + +func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) { + var sent []string + router := NewRouter( + mloggerfactory.NewLogger(false), + fakeService{}, + func(_ context.Context, _ string, text string) error { + sent = append(sent, text) + return nil + }, + nil, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, + ) + handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ + Message: &model.TelegramMessage{ + ChatID: "999", + FromUserID: "123", + Text: "/fund", + }, + }) + if !handled { + t.Fatalf("expected update to be handled") + } + if len(sent) != 1 { + t.Fatalf("expected one message, got %d", len(sent)) + } + if sent[0] != amountPromptMessage( + storagemodel.TreasuryOperationFund, + &AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"}, + "acct-1", + ) { + t.Fatalf("unexpected message: %q", sent[0]) + } +} + +func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) { + var sent []string + router := NewRouter( + mloggerfactory.NewLogger(false), + fakeService{}, + func(_ context.Context, _ string, text string) error { + sent = append(sent, text) + return nil + }, + nil, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, + ) + handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ + Message: &model.TelegramMessage{ + ChatID: "777", + FromUserID: "999", + Text: "/fund", + }, + }) + if !handled { + t.Fatalf("expected update to be handled") + } + if len(sent) != 1 { + t.Fatalf("expected one message, got %d", len(sent)) + } + if sent[0] != unauthorizedMessage { + t.Fatalf("unexpected message: %q", sent[0]) + } +} + +func TestRouterStartAuthorizedShowsWelcome(t *testing.T) { + var sent []string + router := NewRouter( + mloggerfactory.NewLogger(false), + fakeService{}, + func(_ context.Context, _ string, text string) error { + sent = append(sent, text) + return nil + }, + nil, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, + ) + handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ + Message: &model.TelegramMessage{ + ChatID: "777", + FromUserID: "123", + Text: "/start", + }, + }) + if !handled { + t.Fatalf("expected update to be handled") + } + if len(sent) != 1 { + t.Fatalf("expected one message, got %d", len(sent)) + } + if sent[0] != welcomeMessage(&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"}) { + t.Fatalf("unexpected message: %q", sent[0]) + } +} + +func TestRouterHelpAuthorizedShowsHelp(t *testing.T) { + var sent []string + router := NewRouter( + mloggerfactory.NewLogger(false), + fakeService{}, + func(_ context.Context, _ string, text string) error { + sent = append(sent, text) + return nil + }, + nil, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, + ) + handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ + Message: &model.TelegramMessage{ + ChatID: "777", + FromUserID: "123", + Text: "/help", + }, + }) + if !handled { + t.Fatalf("expected update to be handled") + } + if len(sent) != 1 { + t.Fatalf("expected one message, got %d", len(sent)) + } + if sent[0] != helpMessage("acct-1", "USD") { + t.Fatalf("unexpected message: %q", sent[0]) + } +} + +func TestRouterStartUnauthorizedGetsDenied(t *testing.T) { + var sent []string + router := NewRouter( + mloggerfactory.NewLogger(false), + fakeService{}, + func(_ context.Context, _ string, text string) error { + sent = append(sent, text) + return nil + }, + nil, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, + ) + handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ + Message: &model.TelegramMessage{ + ChatID: "777", + FromUserID: "999", + Text: "/start", + }, + }) + if !handled { + t.Fatalf("expected update to be handled") + } + if len(sent) != 1 { + t.Fatalf("expected one message, got %d", len(sent)) + } + if sent[0] != unauthorizedMessage { + t.Fatalf("unexpected message: %q", sent[0]) + } +} + +func TestRouterPlainTextWithoutSession_ShowsSupportedCommands(t *testing.T) { + var sent []string + router := NewRouter( + mloggerfactory.NewLogger(false), + fakeService{}, + func(_ context.Context, _ string, text string) error { + sent = append(sent, text) + return nil + }, + nil, + fakeUserBindingResolver{ + bindings: map[string]*UserBinding{ + "123": { + TelegramUserID: "123", + LedgerAccountID: "acct-1", + }, + }, + }, + ) + handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{ + Message: &model.TelegramMessage{ + ChatID: "777", + FromUserID: "123", + Text: "hello", + }, + }) + if !handled { + t.Fatalf("expected update to be handled") + } + if len(sent) != 1 { + t.Fatalf("expected one message, got %d", len(sent)) + } + if sent[0] != supportedCommandsMessage() { + t.Fatalf("unexpected message: %q", sent[0]) + } +} diff --git a/api/gateway/chsettle/internal/service/treasury/config.go b/api/gateway/chsettle/internal/service/treasury/config.go new file mode 100644 index 00000000..6ea1cf15 --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/config.go @@ -0,0 +1,11 @@ +package treasury + +import "time" + +type Config struct { + ExecutionDelay time.Duration + PollInterval time.Duration + + MaxAmountPerOperation string + MaxDailyAmount string +} diff --git a/api/gateway/chsettle/internal/service/treasury/ledger/client.go b/api/gateway/chsettle/internal/service/treasury/ledger/client.go new file mode 100644 index 00000000..91bde6ac --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/ledger/client.go @@ -0,0 +1,312 @@ +package ledger + +import ( + "context" + "crypto/tls" + "fmt" + "net/url" + "strings" + "time" + + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" +) + +const ledgerConnectorID = "ledger" + +type Config struct { + Endpoint string + Timeout time.Duration + Insecure bool +} + +type Account struct { + AccountID string + AccountCode string + Currency string + OrganizationRef string +} + +type Balance struct { + AccountID string + Amount string + Currency string +} + +type PostRequest struct { + AccountID string + OrganizationRef string + Amount string + Currency string + Reference string + IdempotencyKey string +} + +type OperationResult struct { + Reference string +} + +type Client interface { + GetAccount(ctx context.Context, accountID string) (*Account, error) + GetBalance(ctx context.Context, accountID string) (*Balance, error) + ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) + ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) + Close() error +} + +type grpcConnectorClient interface { + GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error) + GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error) + SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) +} + +type connectorClient struct { + cfg Config + conn *grpc.ClientConn + client grpcConnectorClient +} + +func New(cfg Config) (Client, error) { + cfg.Endpoint = strings.TrimSpace(cfg.Endpoint) + if cfg.Endpoint == "" { + return nil, merrors.InvalidArgument("ledger endpoint is required", "ledger.endpoint") + } + if normalized, insecure := normalizeEndpoint(cfg.Endpoint); normalized != "" { + cfg.Endpoint = normalized + if insecure { + cfg.Insecure = true + } + } + if cfg.Timeout <= 0 { + cfg.Timeout = 5 * time.Second + } + dialOpts := []grpc.DialOption{} + if cfg.Insecure { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) + } + conn, err := grpc.NewClient(cfg.Endpoint, dialOpts...) + if err != nil { + return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Endpoint)) + } + return &connectorClient{ + cfg: cfg, + conn: conn, + client: connectorv1.NewConnectorServiceClient(conn), + }, nil +} + +func (c *connectorClient) Close() error { + if c == nil || c.conn == nil { + return nil + } + return c.conn.Close() +} + +func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Account, error) { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return nil, merrors.InvalidArgument("ledger account_id is required", "account_id") + } + ctx, cancel := c.callContext(ctx) + defer cancel() + + resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{ + AccountRef: &connectorv1.AccountRef{ + ConnectorId: ledgerConnectorID, + AccountId: accountID, + }, + }) + if err != nil { + return nil, err + } + account := resp.GetAccount() + if account == nil { + return nil, merrors.NoData("ledger account not found") + } + accountCode := strings.TrimSpace(account.GetLabel()) + organizationRef := strings.TrimSpace(account.GetOwnerRef()) + if organizationRef == "" && account.GetProviderDetails() != nil { + details := account.GetProviderDetails().AsMap() + if organizationRef == "" { + organizationRef = firstDetailValue(details, "organization_ref", "organizationRef", "org_ref") + } + if accountCode == "" { + accountCode = firstDetailValue(details, "account_code", "accountCode", "code", "ledger_account_code") + } + } + return &Account{ + AccountID: accountID, + AccountCode: accountCode, + Currency: strings.ToUpper(strings.TrimSpace(account.GetAsset())), + OrganizationRef: organizationRef, + }, nil +} + +func (c *connectorClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return nil, merrors.InvalidArgument("ledger account_id is required", "account_id") + } + ctx, cancel := c.callContext(ctx) + defer cancel() + + resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{ + AccountRef: &connectorv1.AccountRef{ + ConnectorId: ledgerConnectorID, + AccountId: accountID, + }, + }) + if err != nil { + return nil, err + } + balance := resp.GetBalance() + if balance == nil || balance.GetAvailable() == nil { + return nil, merrors.Internal("ledger balance is unavailable") + } + return &Balance{ + AccountID: accountID, + Amount: strings.TrimSpace(balance.GetAvailable().GetAmount()), + Currency: strings.ToUpper(strings.TrimSpace(balance.GetAvailable().GetCurrency())), + }, nil +} + +func (c *connectorClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) { + return c.submitExternalOperation(ctx, connectorv1.OperationType_CREDIT, discovery.OperationExternalCredit, req) +} + +func (c *connectorClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) { + return c.submitExternalOperation(ctx, connectorv1.OperationType_DEBIT, discovery.OperationExternalDebit, req) +} + +func (c *connectorClient) submitExternalOperation(ctx context.Context, opType connectorv1.OperationType, operation string, req PostRequest) (*OperationResult, error) { + req.AccountID = strings.TrimSpace(req.AccountID) + req.OrganizationRef = strings.TrimSpace(req.OrganizationRef) + req.Amount = strings.TrimSpace(req.Amount) + req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency)) + req.Reference = strings.TrimSpace(req.Reference) + req.IdempotencyKey = strings.TrimSpace(req.IdempotencyKey) + + if req.AccountID == "" { + return nil, merrors.InvalidArgument("ledger account_id is required", "account_id") + } + if req.OrganizationRef == "" { + return nil, merrors.InvalidArgument("ledger organization_ref is required", "organization_ref") + } + if req.Amount == "" || req.Currency == "" { + return nil, merrors.InvalidArgument("ledger amount is required", "amount") + } + if req.IdempotencyKey == "" { + return nil, merrors.InvalidArgument("ledger idempotency_key is required", "idempotency_key") + } + + params := map[string]any{ + "organization_ref": req.OrganizationRef, + "operation": operation, + "description": "chsettle treasury operation", + "metadata": map[string]any{ + "reference": req.Reference, + }, + } + operationReq := &connectorv1.Operation{ + Type: opType, + IdempotencyKey: req.IdempotencyKey, + Money: &moneyv1.Money{ + Amount: req.Amount, + Currency: req.Currency, + }, + Params: structFromMap(params), + } + account := &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: req.AccountID} + switch opType { + case connectorv1.OperationType_CREDIT: + operationReq.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}} + case connectorv1.OperationType_DEBIT: + operationReq.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}} + } + + ctx, cancel := c.callContext(ctx) + defer cancel() + + resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operationReq}) + if err != nil { + return nil, err + } + if resp.GetReceipt() == nil { + return nil, merrors.Internal("ledger receipt is unavailable") + } + if receiptErr := resp.GetReceipt().GetError(); receiptErr != nil { + message := strings.TrimSpace(receiptErr.GetMessage()) + if message == "" { + message = "ledger operation failed" + } + return nil, merrors.InvalidArgument(message) + } + reference := strings.TrimSpace(resp.GetReceipt().GetOperationId()) + if reference == "" { + reference = req.Reference + } + return &OperationResult{Reference: reference}, nil +} + +func (c *connectorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(ctx, c.cfg.Timeout) +} + +func structFromMap(values map[string]any) *structpb.Struct { + if len(values) == 0 { + return nil + } + result, err := structpb.NewStruct(values) + if err != nil { + return nil + } + return result +} + +func normalizeEndpoint(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return raw, false + } + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { + case "http", "grpc": + return parsed.Host, true + case "https", "grpcs": + return parsed.Host, false + default: + return raw, false + } +} + +func firstDetailValue(values map[string]any, keys ...string) string { + if len(values) == 0 || len(keys) == 0 { + return "" + } + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if value, ok := values[key]; ok { + if text := strings.TrimSpace(fmt.Sprint(value)); text != "" { + return text + } + } + } + return "" +} diff --git a/api/gateway/chsettle/internal/service/treasury/ledger/discovery_client.go b/api/gateway/chsettle/internal/service/treasury/ledger/discovery_client.go new file mode 100644 index 00000000..1bee6a1d --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/ledger/discovery_client.go @@ -0,0 +1,235 @@ +package ledger + +import ( + "context" + "fmt" + "net" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + "go.uber.org/zap" +) + +type DiscoveryConfig struct { + Logger mlogger.Logger + Registry *discovery.Registry + Timeout time.Duration +} + +type discoveryEndpoint struct { + address string + insecure bool + raw string +} + +func (e discoveryEndpoint) key() string { + return fmt.Sprintf("%s|%t", e.address, e.insecure) +} + +type discoveryClient struct { + logger mlogger.Logger + registry *discovery.Registry + timeout time.Duration + + mu sync.Mutex + client Client + endpointKey string +} + +func NewDiscoveryClient(cfg DiscoveryConfig) (Client, error) { + if cfg.Registry == nil { + return nil, merrors.InvalidArgument("treasury ledger discovery registry is required", "registry") + } + if cfg.Timeout <= 0 { + cfg.Timeout = 5 * time.Second + } + logger := cfg.Logger + if logger != nil { + logger = logger.Named("treasury_ledger_discovery") + } + return &discoveryClient{ + logger: logger, + registry: cfg.Registry, + timeout: cfg.Timeout, + }, nil +} + +func (c *discoveryClient) Close() error { + if c == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.client != nil { + err := c.client.Close() + c.client = nil + c.endpointKey = "" + return err + } + return nil +} + +func (c *discoveryClient) GetAccount(ctx context.Context, accountID string) (*Account, error) { + client, err := c.resolveClient(ctx) + if err != nil { + return nil, err + } + return client.GetAccount(ctx, accountID) +} + +func (c *discoveryClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) { + client, err := c.resolveClient(ctx) + if err != nil { + return nil, err + } + return client.GetBalance(ctx, accountID) +} + +func (c *discoveryClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) { + client, err := c.resolveClient(ctx) + if err != nil { + return nil, err + } + return client.ExternalCredit(ctx, req) +} + +func (c *discoveryClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) { + client, err := c.resolveClient(ctx) + if err != nil { + return nil, err + } + return client.ExternalDebit(ctx, req) +} + +func (c *discoveryClient) resolveClient(_ context.Context) (Client, error) { + if c == nil || c.registry == nil { + return nil, merrors.Internal("treasury ledger discovery is unavailable") + } + endpoint, err := c.resolveEndpoint() + if err != nil { + return nil, err + } + key := endpoint.key() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil && c.endpointKey == key { + return c.client, nil + } + if c.client != nil { + _ = c.client.Close() + c.client = nil + c.endpointKey = "" + } + next, err := New(Config{ + Endpoint: endpoint.address, + Timeout: c.timeout, + Insecure: endpoint.insecure, + }) + if err != nil { + return nil, err + } + c.client = next + c.endpointKey = key + if c.logger != nil { + c.logger.Info("Discovered ledger endpoint selected", + zap.String("service", string(mservice.Ledger)), + zap.String("invoke_uri", endpoint.raw), + zap.String("address", endpoint.address), + zap.Bool("insecure", endpoint.insecure)) + } + return c.client, nil +} + +func (c *discoveryClient) resolveEndpoint() (discoveryEndpoint, error) { + entries := c.registry.List(time.Now(), true) + type match struct { + entry discovery.RegistryEntry + opMatch bool + } + matches := make([]match, 0, len(entries)) + requiredOps := discovery.LedgerServiceOperations() + for _, entry := range entries { + if !matchesService(entry.Service, mservice.Ledger) { + continue + } + matches = append(matches, match{ + entry: entry, + opMatch: discovery.HasAnyOperation(entry.Operations, requiredOps), + }) + } + if len(matches) == 0 { + return discoveryEndpoint{}, merrors.NoData("discovery: ledger service unavailable") + } + sort.Slice(matches, func(i, j int) bool { + if matches[i].opMatch != matches[j].opMatch { + return matches[i].opMatch + } + if matches[i].entry.RoutingPriority != matches[j].entry.RoutingPriority { + return matches[i].entry.RoutingPriority > matches[j].entry.RoutingPriority + } + 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 + }) + return parseDiscoveryEndpoint(matches[0].entry.InvokeURI) +} + +func matchesService(service string, candidate mservice.Type) bool { + service = strings.TrimSpace(service) + if service == "" || strings.TrimSpace(string(candidate)) == "" { + return false + } + return strings.EqualFold(service, strings.TrimSpace(string(candidate))) +} + +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") + } + + scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) + switch 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/gateway/chsettle/internal/service/treasury/module.go b/api/gateway/chsettle/internal/service/treasury/module.go new file mode 100644 index 00000000..462e11c8 --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/module.go @@ -0,0 +1,205 @@ +package treasury + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/gateway/chsettle/internal/service/treasury/bot" + "github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger" + "github.com/tech/sendico/gateway/chsettle/storage" + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type Module struct { + logger mlogger.Logger + + service *Service + router *bot.Router + scheduler *Scheduler + ledger ledger.Client +} + +func NewModule( + logger mlogger.Logger, + repo storage.TreasuryRequestsStore, + users storage.TreasuryTelegramUsersStore, + ledgerClient ledger.Client, + cfg Config, + send bot.SendTextFunc, +) (*Module, error) { + if logger != nil { + logger = logger.Named("treasury") + } + if users == nil { + return nil, merrors.InvalidArgument("treasury telegram users store is required", "users") + } + service, err := NewService( + logger, + repo, + ledgerClient, + cfg.ExecutionDelay, + cfg.MaxAmountPerOperation, + cfg.MaxDailyAmount, + ) + if err != nil { + return nil, err + } + + module := &Module{ + logger: logger, + service: service, + ledger: ledgerClient, + } + module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval) + module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, &botUsersAdapter{store: users}) + return module, nil +} + +func (m *Module) Enabled() bool { + return m != nil && m.router != nil && m.router.Enabled() && m.scheduler != nil +} + +func (m *Module) Start() { + if m == nil || m.scheduler == nil { + return + } + m.scheduler.Start() +} + +func (m *Module) Shutdown() { + if m == nil { + return + } + if m.scheduler != nil { + m.scheduler.Shutdown() + } + if m.ledger != nil { + _ = m.ledger.Close() + } +} + +func (m *Module) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool { + if m == nil || m.router == nil { + return false + } + return m.router.HandleUpdate(ctx, update) +} + +type botServiceAdapter struct { + svc *Service +} + +type botUsersAdapter struct { + store storage.TreasuryTelegramUsersStore +} + +func (a *botUsersAdapter) ResolveUserBinding(ctx context.Context, telegramUserID string) (*bot.UserBinding, error) { + if a == nil || a.store == nil { + return nil, merrors.Internal("treasury users store unavailable") + } + record, err := a.store.FindByTelegramUserID(ctx, telegramUserID) + if err != nil { + return nil, err + } + if record == nil { + return nil, nil + } + return &bot.UserBinding{ + TelegramUserID: strings.TrimSpace(record.TelegramUserID), + LedgerAccountID: strings.TrimSpace(record.LedgerAccountID), + AllowedChatIDs: normalizeChatIDs(record.AllowedChatIDs), + }, nil +} + +func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) { + if a == nil || a.svc == nil { + return 0 + } + return a.svc.ExecutionDelay() +} + +func (a *botServiceAdapter) MaxPerOperationLimit() string { + if a == nil || a.svc == nil { + return "" + } + return a.svc.MaxPerOperationLimit() +} + +func (a *botServiceAdapter) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) { + if a == nil || a.svc == nil { + return nil, merrors.Internal("treasury service unavailable") + } + return a.svc.GetActiveRequestForAccount(ctx, ledgerAccountID) +} + +func (a *botServiceAdapter) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*bot.AccountProfile, error) { + if a == nil || a.svc == nil { + return nil, merrors.Internal("treasury service unavailable") + } + profile, err := a.svc.GetAccountProfile(ctx, ledgerAccountID) + if err != nil { + return nil, err + } + if profile == nil { + return nil, nil + } + return &bot.AccountProfile{ + AccountID: strings.TrimSpace(profile.AccountID), + AccountCode: strings.TrimSpace(profile.AccountCode), + Currency: strings.TrimSpace(profile.Currency), + }, nil +} + +func (a *botServiceAdapter) CreateRequest(ctx context.Context, input bot.CreateRequestInput) (*storagemodel.TreasuryRequest, error) { + if a == nil || a.svc == nil { + return nil, merrors.Internal("treasury service unavailable") + } + return a.svc.CreateRequest(ctx, CreateRequestInput{ + OperationType: input.OperationType, + TelegramUserID: input.TelegramUserID, + LedgerAccountID: input.LedgerAccountID, + ChatID: input.ChatID, + Amount: input.Amount, + }) +} + +func (a *botServiceAdapter) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) { + if a == nil || a.svc == nil { + return nil, merrors.Internal("treasury service unavailable") + } + return a.svc.ConfirmRequest(ctx, requestID, telegramUserID) +} + +func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) { + if a == nil || a.svc == nil { + return nil, merrors.Internal("treasury service unavailable") + } + return a.svc.CancelRequest(ctx, requestID, telegramUserID) +} + +func normalizeChatIDs(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, next := range values { + next = strings.TrimSpace(next) + if next == "" { + continue + } + if _, ok := seen[next]; ok { + continue + } + seen[next] = struct{}{} + out = append(out, next) + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/api/gateway/chsettle/internal/service/treasury/scheduler.go b/api/gateway/chsettle/internal/service/treasury/scheduler.go new file mode 100644 index 00000000..5f7053db --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/scheduler.go @@ -0,0 +1,327 @@ +package treasury + +import ( + "context" + "strings" + "sync" + "time" + + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type NotifyFunc func(ctx context.Context, chatID string, text string) error + +type Scheduler struct { + logger mlogger.Logger + service *Service + notify NotifyFunc + safetySweepInterval time.Duration + + cancel context.CancelFunc + wg sync.WaitGroup + + timersMu sync.Mutex + timers map[string]*time.Timer +} + +func NewScheduler(logger mlogger.Logger, service *Service, notify NotifyFunc, safetySweepInterval time.Duration) *Scheduler { + if logger != nil { + logger = logger.Named("treasury_scheduler") + } + if safetySweepInterval <= 0 { + safetySweepInterval = 30 * time.Second + } + return &Scheduler{ + logger: logger, + service: service, + notify: notify, + safetySweepInterval: safetySweepInterval, + timers: map[string]*time.Timer{}, + } +} + +func (s *Scheduler) Start() { + if s == nil || s.service == nil || s.cancel != nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + + // Rebuild in-memory timers from DB on startup. + s.hydrateTimers(ctx) + // Safety pass for overdue items at startup. + s.sweep(ctx) + + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.safetySweepInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.sweep(ctx) + } + } + }() +} + +func (s *Scheduler) Shutdown() { + if s == nil || s.cancel == nil { + return + } + s.cancel() + s.wg.Wait() + s.timersMu.Lock() + for requestID, timer := range s.timers { + if timer != nil { + timer.Stop() + } + delete(s.timers, requestID) + } + s.timersMu.Unlock() +} + +func (s *Scheduler) TrackScheduled(record *storagemodel.TreasuryRequest) { + if s == nil || s.service == nil || record == nil { + return + } + if strings.TrimSpace(record.RequestID) == "" { + return + } + if record.Status != storagemodel.TreasuryRequestStatusScheduled { + return + } + requestID := strings.TrimSpace(record.RequestID) + when := record.ScheduledAt + if when.IsZero() { + when = time.Now() + } + delay := time.Until(when) + if delay <= 0 { + s.Untrack(requestID) + go s.executeAndNotifyByID(context.Background(), requestID) + return + } + + s.timersMu.Lock() + if existing := s.timers[requestID]; existing != nil { + existing.Stop() + } + s.timers[requestID] = time.AfterFunc(delay, func() { + s.Untrack(requestID) + s.executeAndNotifyByID(context.Background(), requestID) + }) + s.timersMu.Unlock() +} + +func (s *Scheduler) Untrack(requestID string) { + if s == nil { + return + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return + } + s.timersMu.Lock() + if timer := s.timers[requestID]; timer != nil { + timer.Stop() + } + delete(s.timers, requestID) + s.timersMu.Unlock() +} + +func (s *Scheduler) hydrateTimers(ctx context.Context) { + if s == nil || s.service == nil { + return + } + scheduled, err := s.service.ScheduledRequests(ctx, 1000) + if err != nil { + s.logger.Warn("Failed to hydrate scheduled treasury requests", zap.Error(err)) + return + } + for _, record := range scheduled { + s.TrackScheduled(&record) + } +} + +func (s *Scheduler) sweep(ctx context.Context) { + if s == nil || s.service == nil { + return + } + now := time.Now() + + confirmed, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{ + storagemodel.TreasuryRequestStatusConfirmed, + }, now, 100) + if err != nil { + s.logger.Warn("Failed to list confirmed treasury requests", zap.Error(err)) + return + } + for _, request := range confirmed { + s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID)) + } + + scheduled, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{ + storagemodel.TreasuryRequestStatusScheduled, + }, now, 100) + if err != nil { + s.logger.Warn("Failed to list scheduled treasury requests", zap.Error(err)) + return + } + for _, request := range scheduled { + s.Untrack(strings.TrimSpace(request.RequestID)) + s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID)) + } +} + +func (s *Scheduler) executeAndNotifyByID(ctx context.Context, requestID string) { + if s == nil || s.service == nil { + return + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return + } + + runCtx := ctx + if runCtx == nil { + runCtx = context.Background() + } + withTimeout, cancel := context.WithTimeout(runCtx, 30*time.Second) + defer cancel() + + result, err := s.service.ExecuteRequest(withTimeout, requestID) + if err != nil { + s.logger.Warn("Failed to execute treasury request", zap.Error(err), zap.String("request_id", requestID)) + return + } + if result == nil || result.Request == nil { + s.logger.Debug("Treasury execution produced no result", zap.String("request_id", requestID)) + return + } + if s.notify == nil { + s.logger.Warn("Treasury execution notifier is unavailable", zap.String("request_id", requestID)) + return + } + + text := executionMessage(result) + if strings.TrimSpace(text) == "" { + s.logger.Debug("Treasury execution result has no notification text", + zap.String("request_id", strings.TrimSpace(result.Request.RequestID)), + zap.String("status", strings.TrimSpace(string(result.Request.Status)))) + return + } + chatID := strings.TrimSpace(result.Request.ChatID) + if chatID == "" { + s.logger.Warn("Treasury execution notification skipped: empty chat_id", + zap.String("request_id", strings.TrimSpace(result.Request.RequestID))) + return + } + + s.logger.Info("Sending treasury execution notification", + zap.String("request_id", strings.TrimSpace(result.Request.RequestID)), + zap.String("chat_id", chatID), + zap.String("status", strings.TrimSpace(string(result.Request.Status)))) + + notifyCtx := context.Background() + if ctx != nil { + notifyCtx = ctx + } + notifyCtx, notifyCancel := context.WithTimeout(notifyCtx, 15*time.Second) + defer notifyCancel() + + if err := s.notify(notifyCtx, chatID, text); err != nil { + s.logger.Warn("Failed to notify treasury execution result", + zap.Error(err), + zap.String("request_id", strings.TrimSpace(result.Request.RequestID)), + zap.String("chat_id", chatID), + zap.String("status", strings.TrimSpace(string(result.Request.Status)))) + return + } + s.logger.Info("Treasury execution notification sent", + zap.String("request_id", strings.TrimSpace(result.Request.RequestID)), + zap.String("chat_id", chatID), + zap.String("status", strings.TrimSpace(string(result.Request.Status)))) +} + +func executionMessage(result *ExecutionResult) string { + if result == nil || result.Request == nil { + return "" + } + request := result.Request + switch request.Status { + case storagemodel.TreasuryRequestStatusExecuted: + op := "Funding" + sign := "+" + if request.OperationType == storagemodel.TreasuryOperationWithdraw { + op = "Withdrawal" + sign = "-" + } + balanceAmount := "unavailable" + balanceCurrency := strings.TrimSpace(request.Currency) + if result.NewBalance != nil { + if strings.TrimSpace(result.NewBalance.Amount) != "" { + balanceAmount = strings.TrimSpace(result.NewBalance.Amount) + } + if strings.TrimSpace(result.NewBalance.Currency) != "" { + balanceCurrency = strings.TrimSpace(result.NewBalance.Currency) + } + } + return "*" + op + " completed*\n\n" + + "*Account:* " + markdownCode(requestAccountCode(request)) + "\n" + + "*Amount:* " + markdownCode(sign+strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" + + "*New balance:* " + markdownCode(balanceAmount+" "+balanceCurrency) + "\n\n" + + "*Reference:* " + markdownCode(strings.TrimSpace(request.RequestID)) + case storagemodel.TreasuryRequestStatusFailed: + reason := strings.TrimSpace(request.ErrorMessage) + if reason == "" && result.ExecutionError != nil { + reason = strings.TrimSpace(result.ExecutionError.Error()) + } + if reason == "" { + reason = "Unknown error." + } + return "*Execution failed*\n\n" + + "*Account:* " + markdownCode(requestAccountCode(request)) + "\n" + + "*Amount:* " + markdownCode(strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" + + "*Status:* " + markdownCode("FAILED") + "\n" + + "*Reason:* " + markdownCode(compactForMarkdown(reason)) + "\n\n" + + "*Request ID:* " + markdownCode(strings.TrimSpace(request.RequestID)) + default: + return "" + } +} + +func requestAccountCode(request *storagemodel.TreasuryRequest) string { + if request == nil { + return "" + } + if code := strings.TrimSpace(request.LedgerAccountCode); code != "" { + return code + } + return strings.TrimSpace(request.LedgerAccountID) +} + +func markdownCode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + value = "N/A" + } + value = strings.ReplaceAll(value, "`", "'") + return "`" + value + "`" +} + +func compactForMarkdown(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "Unknown error." + } + value = strings.ReplaceAll(value, "\r\n", " ") + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + return strings.Join(strings.Fields(value), " ") +} diff --git a/api/gateway/chsettle/internal/service/treasury/service.go b/api/gateway/chsettle/internal/service/treasury/service.go new file mode 100644 index 00000000..696b8e93 --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/service.go @@ -0,0 +1,457 @@ +package treasury + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger" + "github.com/tech/sendico/gateway/chsettle/storage" + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +var ErrActiveTreasuryRequest = errors.New("active treasury request exists") + +type CreateRequestInput struct { + OperationType storagemodel.TreasuryOperationType + TelegramUserID string + LedgerAccountID string + ChatID string + Amount string +} + +type AccountProfile struct { + AccountID string + AccountCode string + Currency string +} + +type ExecutionResult struct { + Request *storagemodel.TreasuryRequest + NewBalance *ledger.Balance + ExecutionError error +} + +type Service struct { + logger mlogger.Logger + repo storage.TreasuryRequestsStore + ledger ledger.Client + + validator *Validator + executionDelay time.Duration +} + +func NewService( + logger mlogger.Logger, + repo storage.TreasuryRequestsStore, + ledgerClient ledger.Client, + executionDelay time.Duration, + maxPerOperation string, + maxDaily string, +) (*Service, error) { + if logger == nil { + return nil, merrors.InvalidArgument("logger is required", "logger") + } + if repo == nil { + return nil, merrors.InvalidArgument("treasury repository is required", "repo") + } + if ledgerClient == nil { + return nil, merrors.InvalidArgument("ledger client is required", "ledger_client") + } + if executionDelay <= 0 { + executionDelay = 30 * time.Second + } + validator, err := NewValidator(repo, maxPerOperation, maxDaily) + if err != nil { + return nil, err + } + return &Service{ + logger: logger.Named("treasury_service"), + repo: repo, + ledger: ledgerClient, + validator: validator, + executionDelay: executionDelay, + }, nil +} + +func (s *Service) ExecutionDelay() time.Duration { + if s == nil { + return 0 + } + return s.executionDelay +} + +func (s *Service) MaxPerOperationLimit() string { + if s == nil || s.validator == nil { + return "" + } + return s.validator.MaxPerOperation() +} + +func (s *Service) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) { + if s == nil || s.repo == nil { + return nil, merrors.Internal("treasury service unavailable") + } + return s.repo.FindActiveByLedgerAccountID(ctx, ledgerAccountID) +} + +func (s *Service) GetRequest(ctx context.Context, requestID string) (*storagemodel.TreasuryRequest, error) { + if s == nil || s.repo == nil { + return nil, merrors.Internal("treasury service unavailable") + } + return s.repo.FindByRequestID(ctx, requestID) +} + +func (s *Service) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error) { + if s == nil || s.ledger == nil { + return nil, merrors.Internal("treasury service unavailable") + } + ledgerAccountID = strings.TrimSpace(ledgerAccountID) + if ledgerAccountID == "" { + return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id") + } + + account, err := s.ledger.GetAccount(ctx, ledgerAccountID) + if err != nil { + return nil, err + } + if account == nil { + return nil, merrors.NoData("ledger account not found") + } + return &AccountProfile{ + AccountID: ledgerAccountID, + AccountCode: resolveAccountCode(account, ledgerAccountID), + Currency: strings.ToUpper(strings.TrimSpace(account.Currency)), + }, nil +} + +func (s *Service) CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) { + if s == nil || s.repo == nil || s.ledger == nil || s.validator == nil { + return nil, merrors.Internal("treasury service unavailable") + } + input.TelegramUserID = strings.TrimSpace(input.TelegramUserID) + input.LedgerAccountID = strings.TrimSpace(input.LedgerAccountID) + input.ChatID = strings.TrimSpace(input.ChatID) + input.Amount = strings.TrimSpace(input.Amount) + + switch input.OperationType { + case storagemodel.TreasuryOperationFund, storagemodel.TreasuryOperationWithdraw: + default: + return nil, merrors.InvalidArgument("treasury operation is invalid", "operation_type") + } + if input.TelegramUserID == "" { + return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id") + } + if input.LedgerAccountID == "" { + return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id") + } + if input.ChatID == "" { + return nil, merrors.InvalidArgument("chat_id is required", "chat_id") + } + + active, err := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID) + if err != nil { + return nil, err + } + if active != nil { + return active, ErrActiveTreasuryRequest + } + + amountRat, normalizedAmount, err := s.validator.ValidateAmount(input.Amount) + if err != nil { + return nil, err + } + if err := s.validator.ValidateDailyLimit(ctx, input.LedgerAccountID, amountRat, time.Now()); err != nil { + return nil, err + } + + account, err := s.ledger.GetAccount(ctx, input.LedgerAccountID) + if err != nil { + return nil, err + } + if account == nil || strings.TrimSpace(account.Currency) == "" { + return nil, merrors.Internal("ledger account currency is unavailable") + } + if strings.TrimSpace(account.OrganizationRef) == "" { + return nil, merrors.Internal("ledger account organization is unavailable") + } + + requestID := newRequestID() + record := &storagemodel.TreasuryRequest{ + RequestID: requestID, + OperationType: input.OperationType, + TelegramUserID: input.TelegramUserID, + LedgerAccountID: input.LedgerAccountID, + LedgerAccountCode: resolveAccountCode(account, input.LedgerAccountID), + OrganizationRef: account.OrganizationRef, + ChatID: input.ChatID, + Amount: normalizedAmount, + Currency: strings.ToUpper(strings.TrimSpace(account.Currency)), + Status: storagemodel.TreasuryRequestStatusCreated, + IdempotencyKey: fmt.Sprintf("chsettle:%s", requestID), + Active: true, + } + if err := s.repo.Create(ctx, record); err != nil { + if errors.Is(err, storage.ErrDuplicate) { + active, fetchErr := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID) + if fetchErr != nil { + return nil, fetchErr + } + if active != nil { + return active, ErrActiveTreasuryRequest + } + return nil, err + } + return nil, err + } + + s.logRequest(record, "created", nil) + return record, nil +} + +func (s *Service) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) { + requestID = strings.TrimSpace(requestID) + telegramUserID = strings.TrimSpace(telegramUserID) + if requestID == "" { + return nil, merrors.InvalidArgument("request_id is required", "request_id") + } + record, err := s.repo.FindByRequestID(ctx, requestID) + if err != nil { + return nil, err + } + if record == nil { + return nil, merrors.NoData("treasury request not found") + } + if telegramUserID != "" && record.TelegramUserID != telegramUserID { + return nil, merrors.Unauthorized("treasury request ownership mismatch") + } + + switch record.Status { + case storagemodel.TreasuryRequestStatusScheduled: + return record, nil + case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed: + now := time.Now() + record.ConfirmedAt = now + record.ScheduledAt = now.Add(s.executionDelay) + record.Status = storagemodel.TreasuryRequestStatusScheduled + record.Active = true + record.ErrorMessage = "" + default: + return nil, merrors.InvalidArgument("treasury request cannot be confirmed in current status", "status") + } + if err := s.repo.Update(ctx, record); err != nil { + return nil, err + } + s.logRequest(record, "scheduled", nil) + return record, nil +} + +func (s *Service) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) { + requestID = strings.TrimSpace(requestID) + telegramUserID = strings.TrimSpace(telegramUserID) + if requestID == "" { + return nil, merrors.InvalidArgument("request_id is required", "request_id") + } + record, err := s.repo.FindByRequestID(ctx, requestID) + if err != nil { + return nil, err + } + if record == nil { + return nil, merrors.NoData("treasury request not found") + } + if telegramUserID != "" && record.TelegramUserID != telegramUserID { + return nil, merrors.Unauthorized("treasury request ownership mismatch") + } + + switch record.Status { + case storagemodel.TreasuryRequestStatusCancelled: + return record, nil + case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed, storagemodel.TreasuryRequestStatusScheduled: + record.Status = storagemodel.TreasuryRequestStatusCancelled + record.CancelledAt = time.Now() + record.Active = false + default: + return nil, merrors.InvalidArgument("treasury request cannot be cancelled in current status", "status") + } + + if err := s.repo.Update(ctx, record); err != nil { + return nil, err + } + s.logRequest(record, "cancelled", nil) + return record, nil +} + +func (s *Service) ExecuteRequest(ctx context.Context, requestID string) (*ExecutionResult, error) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return nil, merrors.InvalidArgument("request_id is required", "request_id") + } + record, err := s.repo.FindByRequestID(ctx, requestID) + if err != nil { + return nil, err + } + if record == nil { + return nil, nil + } + + switch record.Status { + case storagemodel.TreasuryRequestStatusExecuted, + storagemodel.TreasuryRequestStatusCancelled, + storagemodel.TreasuryRequestStatusFailed: + return nil, nil + case storagemodel.TreasuryRequestStatusScheduled: + claimed, err := s.repo.ClaimScheduled(ctx, requestID) + if err != nil { + return nil, err + } + if !claimed { + return nil, nil + } + record, err = s.repo.FindByRequestID(ctx, requestID) + if err != nil { + return nil, err + } + if record == nil { + return nil, nil + } + } + + if record.Status != storagemodel.TreasuryRequestStatusConfirmed { + return nil, nil + } + return s.executeClaimed(ctx, record) +} + +func (s *Service) executeClaimed(ctx context.Context, record *storagemodel.TreasuryRequest) (*ExecutionResult, error) { + if record == nil { + return nil, merrors.InvalidArgument("treasury request is required", "request") + } + postReq := ledger.PostRequest{ + AccountID: record.LedgerAccountID, + OrganizationRef: record.OrganizationRef, + Amount: record.Amount, + Currency: record.Currency, + Reference: record.RequestID, + IdempotencyKey: record.IdempotencyKey, + } + + var ( + opResult *ledger.OperationResult + err error + ) + switch record.OperationType { + case storagemodel.TreasuryOperationFund: + opResult, err = s.ledger.ExternalCredit(ctx, postReq) + case storagemodel.TreasuryOperationWithdraw: + opResult, err = s.ledger.ExternalDebit(ctx, postReq) + default: + err = merrors.InvalidArgument("treasury operation is invalid", "operation_type") + } + now := time.Now() + if err != nil { + record.Status = storagemodel.TreasuryRequestStatusFailed + record.Active = false + record.ExecutedAt = now + record.ErrorMessage = strings.TrimSpace(err.Error()) + if saveErr := s.repo.Update(ctx, record); saveErr != nil { + return nil, saveErr + } + s.logRequest(record, "failed", err) + return &ExecutionResult{ + Request: record, + ExecutionError: err, + }, nil + } + + if opResult != nil { + record.LedgerReference = strings.TrimSpace(opResult.Reference) + } + record.Status = storagemodel.TreasuryRequestStatusExecuted + record.Active = false + record.ExecutedAt = now + record.ErrorMessage = "" + + balance, balanceErr := s.ledger.GetBalance(ctx, record.LedgerAccountID) + if balanceErr != nil { + record.ErrorMessage = strings.TrimSpace(balanceErr.Error()) + } + + if saveErr := s.repo.Update(ctx, record); saveErr != nil { + return nil, saveErr + } + s.logRequest(record, "executed", nil) + return &ExecutionResult{ + Request: record, + NewBalance: balance, + ExecutionError: balanceErr, + }, nil +} + +func (s *Service) DueRequests(ctx context.Context, statuses []storagemodel.TreasuryRequestStatus, now time.Time, limit int64) ([]storagemodel.TreasuryRequest, error) { + if s == nil || s.repo == nil { + return nil, merrors.Internal("treasury service unavailable") + } + return s.repo.FindDueByStatus(ctx, statuses, now, limit) +} + +func (s *Service) ScheduledRequests(ctx context.Context, limit int64) ([]storagemodel.TreasuryRequest, error) { + if s == nil || s.repo == nil { + return nil, merrors.Internal("treasury service unavailable") + } + return s.repo.FindDueByStatus( + ctx, + []storagemodel.TreasuryRequestStatus{storagemodel.TreasuryRequestStatusScheduled}, + time.Now().Add(10*365*24*time.Hour), + limit, + ) +} + +func (s *Service) ParseAmount(value string) (*big.Rat, error) { + return parseAmountRat(value) +} + +func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string, err error) { + if s == nil || s.logger == nil || record == nil { + return + } + fields := []zap.Field{ + zap.String("request_id", strings.TrimSpace(record.RequestID)), + zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)), + zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)), + zap.String("ledger_account_code", strings.TrimSpace(record.LedgerAccountCode)), + zap.String("chat_id", strings.TrimSpace(record.ChatID)), + zap.String("operation_type", strings.TrimSpace(string(record.OperationType))), + zap.String("amount", strings.TrimSpace(record.Amount)), + zap.String("currency", strings.TrimSpace(record.Currency)), + zap.String("status", status), + zap.String("ledger_reference", strings.TrimSpace(record.LedgerReference)), + zap.String("error_message", strings.TrimSpace(record.ErrorMessage)), + } + if err != nil { + fields = append(fields, zap.Error(err)) + } + s.logger.Info("treasury_request", fields...) +} + +func newRequestID() string { + return "TG-TREASURY-" + strings.ToUpper(bson.NewObjectID().Hex()) +} + +func resolveAccountCode(account *ledger.Account, fallbackAccountID string) string { + if account != nil { + if code := strings.TrimSpace(account.AccountCode); code != "" { + return code + } + if code := strings.TrimSpace(account.AccountID); code != "" { + return code + } + } + return strings.TrimSpace(fallbackAccountID) +} diff --git a/api/gateway/chsettle/internal/service/treasury/validator.go b/api/gateway/chsettle/internal/service/treasury/validator.go new file mode 100644 index 00000000..87e86d71 --- /dev/null +++ b/api/gateway/chsettle/internal/service/treasury/validator.go @@ -0,0 +1,178 @@ +package treasury + +import ( + "context" + "math/big" + "regexp" + "strings" + "time" + + "github.com/tech/sendico/gateway/chsettle/storage" + storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +var treasuryAmountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`) + +type LimitKind string + +const ( + LimitKindPerOperation LimitKind = "per_operation" + LimitKindDaily LimitKind = "daily" +) + +type LimitError struct { + Kind LimitKind + Max string +} + +func (e *LimitError) Error() string { + if e == nil { + return "limit exceeded" + } + switch e.Kind { + case LimitKindPerOperation: + return "max amount per operation exceeded" + case LimitKindDaily: + return "max daily amount exceeded" + default: + return "limit exceeded" + } +} + +func (e *LimitError) LimitKind() string { + if e == nil { + return "" + } + return string(e.Kind) +} + +func (e *LimitError) LimitMax() string { + if e == nil { + return "" + } + return e.Max +} + +type Validator struct { + repo storage.TreasuryRequestsStore + + maxPerOperation *big.Rat + maxDaily *big.Rat + + maxPerOperationRaw string + maxDailyRaw string +} + +func NewValidator(repo storage.TreasuryRequestsStore, maxPerOperation string, maxDaily string) (*Validator, error) { + validator := &Validator{ + repo: repo, + maxPerOperationRaw: strings.TrimSpace(maxPerOperation), + maxDailyRaw: strings.TrimSpace(maxDaily), + } + if validator.maxPerOperationRaw != "" { + value, err := parseAmountRat(validator.maxPerOperationRaw) + if err != nil { + return nil, merrors.InvalidArgument("treasury max_amount_per_operation is invalid", "treasury.limits.max_amount_per_operation") + } + validator.maxPerOperation = value + } + if validator.maxDailyRaw != "" { + value, err := parseAmountRat(validator.maxDailyRaw) + if err != nil { + return nil, merrors.InvalidArgument("treasury max_daily_amount is invalid", "treasury.limits.max_daily_amount") + } + validator.maxDaily = value + } + return validator, nil +} + +func (v *Validator) MaxPerOperation() string { + if v == nil { + return "" + } + return v.maxPerOperationRaw +} + +func (v *Validator) MaxDaily() string { + if v == nil { + return "" + } + return v.maxDailyRaw +} + +func (v *Validator) ValidateAmount(amount string) (*big.Rat, string, error) { + amount = strings.TrimSpace(amount) + value, err := parseAmountRat(amount) + if err != nil { + return nil, "", err + } + if v != nil && v.maxPerOperation != nil && value.Cmp(v.maxPerOperation) > 0 { + return nil, "", &LimitError{ + Kind: LimitKindPerOperation, + Max: v.maxPerOperationRaw, + } + } + return value, amount, nil +} + +func (v *Validator) ValidateDailyLimit(ctx context.Context, ledgerAccountID string, amount *big.Rat, now time.Time) error { + if v == nil || v.maxDaily == nil || v.repo == nil { + return nil + } + if amount == nil { + return merrors.InvalidArgument("amount is required", "amount") + } + dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC) + dayEnd := dayStart.Add(24 * time.Hour) + + records, err := v.repo.ListByAccountAndStatuses( + ctx, + ledgerAccountID, + []storagemodel.TreasuryRequestStatus{ + storagemodel.TreasuryRequestStatusCreated, + storagemodel.TreasuryRequestStatusConfirmed, + storagemodel.TreasuryRequestStatusScheduled, + storagemodel.TreasuryRequestStatusExecuted, + }, + dayStart, + dayEnd, + ) + if err != nil { + return err + } + total := new(big.Rat) + for _, record := range records { + next, err := parseAmountRat(record.Amount) + if err != nil { + return merrors.Internal("treasury request amount is invalid") + } + total.Add(total, next) + } + total.Add(total, amount) + if total.Cmp(v.maxDaily) > 0 { + return &LimitError{ + Kind: LimitKindDaily, + Max: v.maxDailyRaw, + } + } + return nil +} + +func parseAmountRat(value string) (*big.Rat, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, merrors.InvalidArgument("amount is required", "amount") + } + if !treasuryAmountPattern.MatchString(value) { + return nil, merrors.InvalidArgument("amount format is invalid", "amount") + } + amount := new(big.Rat) + if _, ok := amount.SetString(value); !ok { + return nil, merrors.InvalidArgument("amount format is invalid", "amount") + } + if amount.Sign() <= 0 { + return nil, merrors.InvalidArgument("amount must be positive", "amount") + } + return amount, nil +} diff --git a/api/gateway/chsettle/main.go b/api/gateway/chsettle/main.go new file mode 100644 index 00000000..bd474a9a --- /dev/null +++ b/api/gateway/chsettle/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/gateway/chsettle/internal/appversion" + si "github.com/tech/sendico/gateway/chsettle/internal/server" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" +) + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("gateway", appversion.Create(), factory) +} diff --git a/api/gateway/chsettle/storage/model/execution.go b/api/gateway/chsettle/storage/model/execution.go new file mode 100644 index 00000000..0b2f2f08 --- /dev/null +++ b/api/gateway/chsettle/storage/model/execution.go @@ -0,0 +1,65 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +type PaymentStatus string + +const ( + PaymentStatusCreated PaymentStatus = "created" // created + PaymentStatusProcessing PaymentStatus = "processing" // processing + PaymentStatusWaiting PaymentStatus = "waiting" // waiting external action + PaymentStatusSuccess PaymentStatus = "success" // final success + PaymentStatusFailed PaymentStatus = "failed" // final failure + PaymentStatusCancelled PaymentStatus = "cancelled" // cancelled final +) + +type PaymentRecord struct { + storable.Base `bson:",inline" json:",inline"` + OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + ConfirmationRef string `bson:"confirmationRef,omitempty" json:"confirmation_ref,omitempty"` + ConfirmationMessageID string `bson:"confirmationMessageId,omitempty" json:"confirmation_message_id,omitempty"` + ConfirmationReplyMessageID string `bson:"confirmationReplyMessageId,omitempty" json:"confirmation_reply_message_id,omitempty"` + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"` + PaymentRef string `bson:"paymentRef,omitempty" json:"payment_ref,omitempty"` + Scenario string `bson:"scenario,omitempty" json:"scenario,omitempty"` + OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"` + TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"` + RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"` + ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"` + Status PaymentStatus `bson:"status,omitempty" json:"status,omitempty"` + FailureReason string `bson:"failureReason,omitempty" json:"Failure_reason,omitempty"` + ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"` + ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expires_at,omitempty"` + ExpiredAt time.Time `bson:"expiredAt,omitempty" json:"expired_at,omitempty"` +} + +type TelegramConfirmation struct { + storable.Base `bson:",inline" json:",inline"` + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"` + ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"` +} + +type PendingConfirmation struct { + storable.Base `bson:",inline" json:",inline"` + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"` + TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"` + AcceptedUserIDs []string `bson:"acceptedUserIds,omitempty" json:"accepted_user_ids,omitempty"` + RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"` + SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"` + Rail string `bson:"rail,omitempty" json:"rail,omitempty"` + Clarified bool `bson:"clarified,omitempty" json:"clarified,omitempty"` + ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expires_at,omitempty"` +} diff --git a/api/gateway/chsettle/storage/model/storable.go b/api/gateway/chsettle/storage/model/storable.go new file mode 100644 index 00000000..aa6fb054 --- /dev/null +++ b/api/gateway/chsettle/storage/model/storable.go @@ -0,0 +1,29 @@ +package model + +const ( + paymentsCollection = "payments" + telegramConfirmationsCollection = "telegram_confirmations" + pendingConfirmationsCollection = "pending_confirmations" + treasuryRequestsCollection = "treasury_requests" + treasuryTelegramUsersCollection = "treasury_telegram_users" +) + +func (*PaymentRecord) Collection() string { + return paymentsCollection +} + +func (*TelegramConfirmation) Collection() string { + return telegramConfirmationsCollection +} + +func (*PendingConfirmation) Collection() string { + return pendingConfirmationsCollection +} + +func (*TreasuryRequest) Collection() string { + return treasuryRequestsCollection +} + +func (*TreasuryTelegramUser) Collection() string { + return treasuryTelegramUsersCollection +} diff --git a/api/gateway/chsettle/storage/model/treasury.go b/api/gateway/chsettle/storage/model/treasury.go new file mode 100644 index 00000000..2496e550 --- /dev/null +++ b/api/gateway/chsettle/storage/model/treasury.go @@ -0,0 +1,59 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/db/storable" +) + +type TreasuryOperationType string + +const ( + TreasuryOperationFund TreasuryOperationType = "fund" + TreasuryOperationWithdraw TreasuryOperationType = "withdraw" +) + +type TreasuryRequestStatus string + +const ( + TreasuryRequestStatusCreated TreasuryRequestStatus = "created" + TreasuryRequestStatusConfirmed TreasuryRequestStatus = "confirmed" + TreasuryRequestStatusScheduled TreasuryRequestStatus = "scheduled" + TreasuryRequestStatusExecuted TreasuryRequestStatus = "executed" + TreasuryRequestStatusCancelled TreasuryRequestStatus = "cancelled" + TreasuryRequestStatusFailed TreasuryRequestStatus = "failed" +) + +type TreasuryRequest struct { + storable.Base `bson:",inline" json:",inline"` + + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + OperationType TreasuryOperationType `bson:"operationType,omitempty" json:"operation_type,omitempty"` + TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"` + LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"` + LedgerAccountCode string `bson:"ledgerAccountCode,omitempty" json:"ledger_account_code,omitempty"` + OrganizationRef string `bson:"organizationRef,omitempty" json:"organization_ref,omitempty"` + ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"` + Amount string `bson:"amount,omitempty" json:"amount,omitempty"` + Currency string `bson:"currency,omitempty" json:"currency,omitempty"` + Status TreasuryRequestStatus `bson:"status,omitempty" json:"status,omitempty"` + + ConfirmedAt time.Time `bson:"confirmedAt,omitempty" json:"confirmed_at,omitempty"` + ScheduledAt time.Time `bson:"scheduledAt,omitempty" json:"scheduled_at,omitempty"` + ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"` + CancelledAt time.Time `bson:"cancelledAt,omitempty" json:"cancelled_at,omitempty"` + + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + LedgerReference string `bson:"ledgerReference,omitempty" json:"ledger_reference,omitempty"` + ErrorMessage string `bson:"errorMessage,omitempty" json:"error_message,omitempty"` + + Active bool `bson:"active,omitempty" json:"active,omitempty"` +} + +type TreasuryTelegramUser struct { + storable.Base `bson:",inline" json:",inline"` + + TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"` + LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"` + AllowedChatIDs []string `bson:"allowedChatIds,omitempty" json:"allowed_chat_ids,omitempty"` +} diff --git a/api/gateway/chsettle/storage/mongo/repository.go b/api/gateway/chsettle/storage/mongo/repository.go new file mode 100644 index 00000000..094bb03c --- /dev/null +++ b/api/gateway/chsettle/storage/mongo/repository.go @@ -0,0 +1,132 @@ +package mongo + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/chsettle/storage" + "github.com/tech/sendico/gateway/chsettle/storage/mongo/store" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/db/transaction" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type Repository struct { + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + txFactory transaction.Factory + + payments storage.PaymentsStore + tg storage.TelegramConfirmationsStore + pending storage.PendingConfirmationsStore + treasury storage.TreasuryRequestsStore + users storage.TreasuryTelegramUsersStore + outbox gatewayoutbox.Store +} + +func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { + if logger == nil { + logger = zap.NewNop() + } + if conn == nil { + return nil, merrors.InvalidArgument("mongo connection is nil") + } + client := conn.Client() + if client == nil { + return nil, merrors.Internal("mongo client is not initialised") + } + db := conn.Database() + if db == nil { + return nil, merrors.Internal("mongo database is not initialised") + } + dbName := db.Name() + logger = logger.Named("storage").Named("mongo") + if dbName != "" { + logger = logger.With(zap.String("database", dbName)) + } + result := &Repository{ + logger: logger, + conn: conn, + db: db, + txFactory: newMongoTransactionFactory(client), + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := result.conn.Ping(ctx); err != nil { + result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err)) + return nil, err + } + paymentsStore, err := store.NewPayments(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise payments store", zap.Error(err), zap.String("store", "payments")) + return nil, err + } + tgStore, err := store.NewTelegramConfirmations(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err), zap.String("store", "telegram_confirmations")) + return nil, err + } + pendingStore, err := store.NewPendingConfirmations(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise pending confirmations store", zap.Error(err), zap.String("store", "pending_confirmations")) + return nil, err + } + treasuryStore, err := store.NewTreasuryRequests(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise treasury requests store", zap.Error(err), zap.String("store", "treasury_requests")) + return nil, err + } + treasuryUsersStore, err := store.NewTreasuryTelegramUsers(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise treasury telegram users store", zap.Error(err), zap.String("store", "treasury_telegram_users")) + return nil, err + } + outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox")) + return nil, err + } + result.payments = paymentsStore + result.tg = tgStore + result.pending = pendingStore + result.treasury = treasuryStore + result.users = treasuryUsersStore + result.outbox = outboxStore + result.logger.Info("Payment gateway MongoDB storage initialised") + return result, nil +} + +func (r *Repository) Payments() storage.PaymentsStore { + return r.payments +} + +func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore { + return r.tg +} + +func (r *Repository) PendingConfirmations() storage.PendingConfirmationsStore { + return r.pending +} + +func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore { + return r.treasury +} + +func (r *Repository) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore { + return r.users +} + +func (r *Repository) Outbox() gatewayoutbox.Store { + return r.outbox +} + +func (r *Repository) TransactionFactory() transaction.Factory { + return r.txFactory +} + +var _ storage.Repository = (*Repository)(nil) diff --git a/api/gateway/chsettle/storage/mongo/store/payments.go b/api/gateway/chsettle/storage/mongo/store/payments.go new file mode 100644 index 00000000..22f4e453 --- /dev/null +++ b/api/gateway/chsettle/storage/mongo/store/payments.go @@ -0,0 +1,159 @@ +package store + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/gateway/chsettle/storage" + "github.com/tech/sendico/gateway/chsettle/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/mongo" + "go.uber.org/zap" +) + +const ( + paymentsCollection = "payments" + fieldIdempotencyKey = "idempotencyKey" + fieldOperationRef = "operationRef" +) + +type Payments struct { + logger mlogger.Logger + repo repository.Repository +} + +func NewPayments(logger mlogger.Logger, db *mongo.Database) (*Payments, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("payments").With(zap.String("collection", paymentsCollection)) + + repo := repository.CreateMongoRepository(db, paymentsCollection) + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldIdempotencyKey, Sort: ri.Asc}}, + Unique: true, + }); err != nil { + logger.Error("Failed to create payments idempotency index", zap.Error(err), zap.String("index_field", fieldIdempotencyKey)) + return nil, err + } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldOperationRef, Sort: ri.Asc}}, + Unique: true, + Sparse: true, + }); err != nil { + logger.Error("Failed to create payments operation index", zap.Error(err), zap.String("index_field", fieldOperationRef)) + return nil, err + } + + p := &Payments{ + logger: logger, + repo: repo, + } + p.logger.Debug("Payments store initialised") + return p, nil +} + +func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentRecord, error) { + key = strings.TrimSpace(key) + if key == "" { + return nil, merrors.InvalidArgument("idempotency key is required", "idempotency_key") + } + var result model.PaymentRecord + err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldIdempotencyKey, key), &result) + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + p.logger.Warn("Payment record lookup failed", zap.String("idempotency_key", key), zap.Error(err)) + } + return nil, err + } + return &result, nil +} + +func (p *Payments) FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error) { + key = strings.TrimSpace(key) + if key == "" { + return nil, merrors.InvalidArgument("operation reference is required", "operation_ref") + } + var result model.PaymentRecord + err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldOperationRef, key), &result) + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + p.logger.Warn("Payment record lookup by operation ref failed", zap.String("operation_ref", key), zap.Error(err)) + } + return nil, err + } + return &result, nil +} + +func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) error { + if record == nil { + return merrors.InvalidArgument("payment record is nil", "record") + } + record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey) + record.QuoteRef = strings.TrimSpace(record.QuoteRef) + record.OutgoingLeg = strings.TrimSpace(record.OutgoingLeg) + record.TargetChatID = strings.TrimSpace(record.TargetChatID) + record.IntentRef = strings.TrimSpace(record.IntentRef) + record.OperationRef = strings.TrimSpace(record.OperationRef) + if record.IntentRef == "" { + return merrors.InvalidArgument("intention reference is required", "intent_ref") + } + if record.IdempotencyKey == "" { + return merrors.InvalidArgument("idempotency key is required", "idempotency_key") + } + if record.IntentRef == "" { + return merrors.InvalidArgument("intention reference key is required", "intent_ref") + } + + existing, err := p.FindByIdempotencyKey(ctx, record.IdempotencyKey) + if err != nil { + return err + } + if existing != nil { + record.ID = existing.ID + if record.CreatedAt.IsZero() { + record.CreatedAt = existing.CreatedAt + } + } + + err = p.repo.Upsert(ctx, record) + if mongo.IsDuplicateKeyError(err) { + // Concurrent insert by idempotency key: resolve existing ID and retry replace-by-ID. + existing, lookupErr := p.FindByIdempotencyKey(ctx, record.IdempotencyKey) + if lookupErr != nil { + err = lookupErr + } else if existing != nil { + record.ID = existing.ID + if record.CreatedAt.IsZero() { + record.CreatedAt = existing.CreatedAt + } + err = p.repo.Upsert(ctx, record) + } + } + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + p.logger.Warn("Failed to upsert payment record", + zap.String("idempotency_key", record.IdempotencyKey), + zap.String("intent_ref", record.IntentRef), + zap.String("quote_ref", record.QuoteRef), + zap.Error(err)) + } + return err + } + return nil +} + +var _ storage.PaymentsStore = (*Payments)(nil) diff --git a/api/gateway/chsettle/storage/mongo/store/payments_test.go b/api/gateway/chsettle/storage/mongo/store/payments_test.go new file mode 100644 index 00000000..430d59dc --- /dev/null +++ b/api/gateway/chsettle/storage/mongo/store/payments_test.go @@ -0,0 +1,245 @@ +package store + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/db/repository" + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type fakePaymentsRepo struct { + repository.Repository + + records map[string]*model.PaymentRecord + findErrByCall map[int]error + duplicateWhenZeroID bool + findCalls int + upsertCalls int + upsertIDs []bson.ObjectID + upsertIdempotencyKey []string +} + +func (f *fakePaymentsRepo) FindOneByFilter(_ context.Context, query repository.FilterQuery, result storable.Storable) error { + f.findCalls++ + if err, ok := f.findErrByCall[f.findCalls]; ok { + return err + } + + rec, ok := result.(*model.PaymentRecord) + if !ok { + return merrors.InvalidDataType("expected *model.PaymentRecord") + } + + doc := query.BuildQuery() + if key := stringField(doc, fieldIdempotencyKey); key != "" { + stored, ok := f.records[key] + if !ok { + return merrors.NoData("payment not found by filter") + } + *rec = *stored + return nil + } + if operationRef := stringField(doc, fieldOperationRef); operationRef != "" { + for _, stored := range f.records { + if strings.TrimSpace(stored.OperationRef) == operationRef { + *rec = *stored + return nil + } + } + return merrors.NoData("payment not found by operation ref") + } + + return merrors.NoData("payment not found") +} + +func (f *fakePaymentsRepo) Upsert(_ context.Context, obj storable.Storable) error { + f.upsertCalls++ + + rec, ok := obj.(*model.PaymentRecord) + if !ok { + return merrors.InvalidDataType("expected *model.PaymentRecord") + } + f.upsertIDs = append(f.upsertIDs, rec.ID) + f.upsertIdempotencyKey = append(f.upsertIdempotencyKey, rec.IdempotencyKey) + + if f.duplicateWhenZeroID && rec.ID.IsZero() { + if _, exists := f.records[rec.IdempotencyKey]; exists { + return mongo.WriteException{ + WriteErrors: mongo.WriteErrors{ + { + Code: 11000, + Message: "E11000 duplicate key error collection: chsettle_gateway.payments", + }, + }, + } + } + } + + copyRec := *rec + if copyRec.ID.IsZero() { + copyRec.ID = bson.NewObjectID() + } + if copyRec.CreatedAt.IsZero() { + copyRec.CreatedAt = time.Now().UTC() + } + copyRec.UpdatedAt = time.Now().UTC() + if f.records == nil { + f.records = map[string]*model.PaymentRecord{} + } + f.records[copyRec.IdempotencyKey] = ©Rec + *rec = copyRec + return nil +} + +func TestPaymentsUpsert_ReusesExistingIDFromIdempotencyLookup(t *testing.T) { + key := "idem-existing" + existingID := bson.NewObjectID() + existingCreatedAt := time.Date(2026, 3, 6, 10, 0, 0, 0, time.UTC) + + repo := &fakePaymentsRepo{ + records: map[string]*model.PaymentRecord{ + key: { + Base: storable.Base{ + ID: existingID, + CreatedAt: existingCreatedAt, + UpdatedAt: existingCreatedAt, + }, + IdempotencyKey: key, + IntentRef: "pi-old", + }, + }, + duplicateWhenZeroID: true, + } + store := &Payments{logger: zap.NewNop(), repo: repo} + + record := &model.PaymentRecord{ + IdempotencyKey: key, + IntentRef: "pi-new", + QuoteRef: "quote-new", + } + + if err := store.Upsert(context.Background(), record); err != nil { + t.Fatalf("upsert failed: %v", err) + } + + if repo.upsertCalls != 1 { + t.Fatalf("expected one upsert call, got %d", repo.upsertCalls) + } + if len(repo.upsertIDs) != 1 || repo.upsertIDs[0] != existingID { + t.Fatalf("expected upsert to reuse existing id %s, got %+v", existingID.Hex(), repo.upsertIDs) + } + if record.ID != existingID { + t.Fatalf("record ID mismatch: got %s want %s", record.ID.Hex(), existingID.Hex()) + } +} + +func TestPaymentsUpsert_RetriesAfterDuplicateKeyRace(t *testing.T) { + key := "idem-race" + existingID := bson.NewObjectID() + + repo := &fakePaymentsRepo{ + records: map[string]*model.PaymentRecord{ + key: { + Base: storable.Base{ + ID: existingID, + CreatedAt: time.Date(2026, 3, 6, 10, 1, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 3, 6, 10, 1, 0, 0, time.UTC), + }, + IdempotencyKey: key, + IntentRef: "pi-existing", + }, + }, + findErrByCall: map[int]error{ + 1: merrors.NoData("payment not found by filter"), + }, + duplicateWhenZeroID: true, + } + store := &Payments{logger: zap.NewNop(), repo: repo} + + record := &model.PaymentRecord{ + IdempotencyKey: key, + IntentRef: "pi-new", + QuoteRef: "quote-new", + } + + if err := store.Upsert(context.Background(), record); err != nil { + t.Fatalf("upsert failed: %v", err) + } + + if repo.upsertCalls != 2 { + t.Fatalf("expected two upsert calls, got %d", repo.upsertCalls) + } + if len(repo.upsertIDs) != 2 { + t.Fatalf("expected two upsert IDs, got %d", len(repo.upsertIDs)) + } + if !repo.upsertIDs[0].IsZero() { + t.Fatalf("expected first upsert to use zero id due stale read, got %s", repo.upsertIDs[0].Hex()) + } + if repo.upsertIDs[1] != existingID { + t.Fatalf("expected retry to use existing id %s, got %s", existingID.Hex(), repo.upsertIDs[1].Hex()) + } +} + +func TestPaymentsUpsert_PropagatesNoSuchTransactionAfterDuplicate(t *testing.T) { + key := "idem-nosuchtx" + + repo := &fakePaymentsRepo{ + records: map[string]*model.PaymentRecord{ + key: { + Base: storable.Base{ + ID: bson.NewObjectID(), + CreatedAt: time.Date(2026, 3, 6, 10, 2, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 3, 6, 10, 2, 0, 0, time.UTC), + }, + IdempotencyKey: key, + IntentRef: "pi-existing", + }, + }, + findErrByCall: map[int]error{ + 1: merrors.NoData("payment not found by filter"), + 2: mongo.CommandError{ + Code: 251, + Name: "NoSuchTransaction", + Message: "Transaction with { txnNumber: 2 } has been aborted.", + }, + }, + duplicateWhenZeroID: true, + } + store := &Payments{logger: zap.NewNop(), repo: repo} + + record := &model.PaymentRecord{ + IdempotencyKey: key, + IntentRef: "pi-new", + QuoteRef: "quote-new", + } + + err := store.Upsert(context.Background(), record) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "NoSuchTransaction") { + t.Fatalf("expected NoSuchTransaction error, got %v", err) + } + if repo.upsertCalls != 1 { + t.Fatalf("expected one upsert attempt before lookup failure, got %d", repo.upsertCalls) + } +} + +func stringField(doc bson.D, key string) string { + for _, entry := range doc { + if entry.Key != key { + continue + } + res, _ := entry.Value.(string) + return strings.TrimSpace(res) + } + return "" +} diff --git a/api/gateway/chsettle/storage/mongo/store/pending_confirmations.go b/api/gateway/chsettle/storage/mongo/store/pending_confirmations.go new file mode 100644 index 00000000..8a361684 --- /dev/null +++ b/api/gateway/chsettle/storage/mongo/store/pending_confirmations.go @@ -0,0 +1,205 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/gateway/chsettle/storage" + "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/db/repository" + "github.com/tech/sendico/pkg/db/repository/builder" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + mutil "github.com/tech/sendico/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +const ( + pendingConfirmationsCollection = "pending_confirmations" + fieldPendingRequestID = "requestId" + fieldPendingMessageID = "messageId" + fieldPendingExpiresAt = "expiresAt" +) + +type PendingConfirmations struct { + logger mlogger.Logger + repo repository.Repository +} + +func NewPendingConfirmations(logger mlogger.Logger, db *mongo.Database) (*PendingConfirmations, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("pending_confirmations").With(zap.String("collection", pendingConfirmationsCollection)) + + repo := repository.CreateMongoRepository(db, pendingConfirmationsCollection) + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldPendingRequestID, Sort: ri.Asc}}, + Unique: true, + }); err != nil { + logger.Error("Failed to create pending confirmations request_id index", zap.Error(err), zap.String("index_field", fieldPendingRequestID)) + return nil, err + } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldPendingMessageID, Sort: ri.Asc}}, + }); err != nil { + logger.Error("Failed to create pending confirmations message_id index", zap.Error(err), zap.String("index_field", fieldPendingMessageID)) + return nil, err + } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldPendingExpiresAt, Sort: ri.Asc}}, + }); err != nil { + logger.Error("Failed to create pending confirmations expires_at index", zap.Error(err), zap.String("index_field", fieldPendingExpiresAt)) + return nil, err + } + + p := &PendingConfirmations{ + logger: logger, + repo: repo, + } + return p, nil +} + +func (p *PendingConfirmations) Upsert(ctx context.Context, record *model.PendingConfirmation) error { + if record == nil { + return merrors.InvalidArgument("pending confirmation is nil", "record") + } + record.RequestID = strings.TrimSpace(record.RequestID) + record.MessageID = strings.TrimSpace(record.MessageID) + record.TargetChatID = strings.TrimSpace(record.TargetChatID) + record.SourceService = strings.TrimSpace(record.SourceService) + record.Rail = strings.TrimSpace(record.Rail) + if record.RequestID == "" { + return merrors.InvalidArgument("request_id is required", "request_id") + } + if record.TargetChatID == "" { + return merrors.InvalidArgument("target_chat_id is required", "target_chat_id") + } + if record.ExpiresAt.IsZero() { + return merrors.InvalidArgument("expires_at is required", "expires_at") + } + + filter := repository.Filter(fieldPendingRequestID, record.RequestID) + err := p.repo.Insert(ctx, record, filter) + if errors.Is(err, merrors.ErrDataConflict) { + patch := repository.Patch(). + Set(repository.Field(fieldPendingMessageID), record.MessageID). + Set(repository.Field("targetChatId"), record.TargetChatID). + Set(repository.Field("acceptedUserIds"), record.AcceptedUserIDs). + Set(repository.Field("requestedMoney"), record.RequestedMoney). + Set(repository.Field("sourceService"), record.SourceService). + Set(repository.Field("rail"), record.Rail). + Set(repository.Field("clarified"), record.Clarified). + Set(repository.Field(fieldPendingExpiresAt), record.ExpiresAt) + _, err = p.repo.PatchMany(ctx, filter, patch) + } + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + p.logger.Warn("Failed to upsert pending confirmation", zap.Error(err), zap.String("request_id", record.RequestID)) + } + return err +} + +func (p *PendingConfirmations) FindByRequestID(ctx context.Context, requestID string) (*model.PendingConfirmation, error) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return nil, merrors.InvalidArgument("request_id is required", "request_id") + } + var result model.PendingConfirmation + err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldPendingRequestID, requestID), &result) + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + if err != nil { + return nil, err + } + return &result, nil +} + +func (p *PendingConfirmations) FindByMessageID(ctx context.Context, messageID string) (*model.PendingConfirmation, error) { + messageID = strings.TrimSpace(messageID) + if messageID == "" { + return nil, merrors.InvalidArgument("message_id is required", "message_id") + } + var result model.PendingConfirmation + err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldPendingMessageID, messageID), &result) + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + if err != nil { + return nil, err + } + return &result, nil +} + +func (p *PendingConfirmations) MarkClarified(ctx context.Context, requestID string) error { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return merrors.InvalidArgument("request_id is required", "request_id") + } + patch := repository.Patch(). + Set(repository.Field("clarified"), true) + _, err := p.repo.PatchMany(ctx, repository.Filter(fieldPendingRequestID, requestID), patch) + return err +} + +func (p *PendingConfirmations) AttachMessage(ctx context.Context, requestID string, messageID string) error { + requestID = strings.TrimSpace(requestID) + messageID = strings.TrimSpace(messageID) + if requestID == "" { + return merrors.InvalidArgument("request_id is required", "request_id") + } + if messageID == "" { + return merrors.InvalidArgument("message_id is required", "message_id") + } + + filter := repository.Filter(fieldPendingRequestID, requestID).And( + repository.Query().Or( + repository.Exists(repository.Field(fieldPendingMessageID), false), + repository.Filter(fieldPendingMessageID, ""), + repository.Filter(fieldPendingMessageID, messageID), + ), + ) + patch := repository.Patch(). + Set(repository.Field(fieldPendingMessageID), messageID) + updated, err := p.repo.PatchMany(ctx, filter, patch) + if err != nil { + return err + } + if updated == 0 { + return merrors.NoData("pending confirmation not found") + } + return nil +} + +func (p *PendingConfirmations) DeleteByRequestID(ctx context.Context, requestID string) error { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return merrors.InvalidArgument("request_id is required", "request_id") + } + return p.repo.DeleteMany(ctx, repository.Filter(fieldPendingRequestID, requestID)) +} + +func (p *PendingConfirmations) ListExpired(ctx context.Context, now time.Time, limit int64) ([]model.PendingConfirmation, error) { + if limit <= 0 { + limit = 100 + } + query := repository.Query(). + Comparison(repository.Field(fieldPendingExpiresAt), builder.Lte, now). + Sort(repository.Field(fieldPendingExpiresAt), true). + Limit(&limit) + + items, err := mutil.GetObjects[model.PendingConfirmation](ctx, p.logger, query, nil, p.repo) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + return nil, err + } + return items, nil +} + +var _ storage.PendingConfirmationsStore = (*PendingConfirmations)(nil) diff --git a/api/gateway/chsettle/storage/mongo/store/telegram_confirmations.go b/api/gateway/chsettle/storage/mongo/store/telegram_confirmations.go new file mode 100644 index 00000000..a7c591ad --- /dev/null +++ b/api/gateway/chsettle/storage/mongo/store/telegram_confirmations.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/gateway/chsettle/storage" + "github.com/tech/sendico/gateway/chsettle/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/mongo" + "go.uber.org/zap" +) + +const ( + telegramCollection = "telegram_confirmations" + fieldRequestID = "requestId" +) + +type TelegramConfirmations struct { + logger mlogger.Logger + repo repository.Repository +} + +func NewTelegramConfirmations(logger mlogger.Logger, db *mongo.Database) (*TelegramConfirmations, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("telegram_confirmations").With(zap.String("collection", telegramCollection)) + + repo := repository.CreateMongoRepository(db, telegramCollection) + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldRequestID, Sort: ri.Asc}}, + Unique: true, + }); err != nil { + logger.Error("Failed to create telegram confirmations request_id index", zap.Error(err), zap.String("index_field", fieldRequestID)) + return nil, err + } + + t := &TelegramConfirmations{ + logger: logger, + repo: repo, + } + t.logger.Debug("Telegram confirmations store initialised") + return t, nil +} + +func (t *TelegramConfirmations) Upsert(ctx context.Context, record *model.TelegramConfirmation) error { + if record == nil { + return merrors.InvalidArgument("telegram confirmation is nil", "record") + } + record.RequestID = strings.TrimSpace(record.RequestID) + record.PaymentIntentID = strings.TrimSpace(record.PaymentIntentID) + record.QuoteRef = strings.TrimSpace(record.QuoteRef) + if record.RequestID == "" { + return merrors.InvalidArgument("request_id is required", "request_id") + } + if record.ReceivedAt.IsZero() { + record.ReceivedAt = time.Now() + } + filter := repository.Filter(fieldRequestID, record.RequestID) + err := t.repo.Insert(ctx, record, filter) + if errors.Is(err, merrors.ErrDataConflict) { + patch := repository.Patch(). + Set(repository.Field("paymentIntentId"), record.PaymentIntentID). + Set(repository.Field("quoteRef"), record.QuoteRef). + Set(repository.Field("rawReply"), record.RawReply). + Set(repository.Field("receivedAt"), record.ReceivedAt) + _, err = t.repo.PatchMany(ctx, filter, patch) + } + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + fields := []zap.Field{zap.String("request_id", record.RequestID)} + if record.PaymentIntentID != "" { + fields = append(fields, zap.String("payment_intent_id", record.PaymentIntentID)) + } + if record.QuoteRef != "" { + fields = append(fields, zap.String("quote_ref", record.QuoteRef)) + } + t.logger.Warn("Failed to upsert telegram confirmation", append(fields, zap.Error(err))...) + } + return err +} + +var _ storage.TelegramConfirmationsStore = (*TelegramConfirmations)(nil) diff --git a/api/gateway/chsettle/storage/mongo/store/treasury_requests.go b/api/gateway/chsettle/storage/mongo/store/treasury_requests.go new file mode 100644 index 00000000..a803144f --- /dev/null +++ b/api/gateway/chsettle/storage/mongo/store/treasury_requests.go @@ -0,0 +1,402 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/gateway/chsettle/storage" + "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/db/repository" + "github.com/tech/sendico/pkg/db/repository/builder" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + mutil "github.com/tech/sendico/pkg/mutil/db" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +const ( + treasuryRequestsCollection = "treasury_requests" + + fieldTreasuryRequestID = "requestId" + fieldTreasuryLedgerAccount = "ledgerAccountId" + fieldTreasuryIdempotencyKey = "idempotencyKey" + fieldTreasuryStatus = "status" + fieldTreasuryScheduledAt = "scheduledAt" + fieldTreasuryCreatedAt = "createdAt" + fieldTreasuryActive = "active" +) + +type TreasuryRequests struct { + logger mlogger.Logger + repo repository.Repository +} + +func NewTreasuryRequests(logger mlogger.Logger, db *mongo.Database) (*TreasuryRequests, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("treasury_requests").With(zap.String("collection", treasuryRequestsCollection)) + + repo := repository.CreateMongoRepository(db, treasuryRequestsCollection) + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldTreasuryRequestID, Sort: ri.Asc}}, + Unique: true, + }); err != nil { + logger.Error("Failed to create treasury requests request_id index", zap.Error(err), zap.String("index_field", fieldTreasuryRequestID)) + return nil, err + } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldTreasuryIdempotencyKey, Sort: ri.Asc}}, + Unique: true, + }); err != nil { + logger.Error("Failed to create treasury requests idempotency index", zap.Error(err), zap.String("index_field", fieldTreasuryIdempotencyKey)) + return nil, err + } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{ + {Field: fieldTreasuryLedgerAccount, Sort: ri.Asc}, + {Field: fieldTreasuryActive, Sort: ri.Asc}, + }, + Unique: true, + PartialFilter: repository.Filter(fieldTreasuryActive, true), + }); err != nil { + logger.Error("Failed to create treasury requests active-account index", zap.Error(err)) + return nil, err + } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{ + {Field: fieldTreasuryStatus, Sort: ri.Asc}, + {Field: fieldTreasuryScheduledAt, Sort: ri.Asc}, + }, + }); err != nil { + logger.Error("Failed to create treasury requests execution index", zap.Error(err)) + return nil, err + } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{ + {Field: fieldTreasuryLedgerAccount, Sort: ri.Asc}, + {Field: fieldTreasuryCreatedAt, Sort: ri.Asc}, + }, + }); err != nil { + logger.Error("Failed to create treasury requests daily-amount index", zap.Error(err)) + return nil, err + } + + t := &TreasuryRequests{ + logger: logger, + repo: repo, + } + t.logger.Debug("Treasury requests store initialised") + return t, nil +} + +func (t *TreasuryRequests) Create(ctx context.Context, record *model.TreasuryRequest) error { + if record == nil { + return merrors.InvalidArgument("treasury request is nil", "record") + } + record.RequestID = strings.TrimSpace(record.RequestID) + record.TelegramUserID = strings.TrimSpace(record.TelegramUserID) + record.LedgerAccountID = strings.TrimSpace(record.LedgerAccountID) + record.LedgerAccountCode = strings.TrimSpace(record.LedgerAccountCode) + record.OrganizationRef = strings.TrimSpace(record.OrganizationRef) + record.ChatID = strings.TrimSpace(record.ChatID) + record.Amount = strings.TrimSpace(record.Amount) + record.Currency = strings.ToUpper(strings.TrimSpace(record.Currency)) + record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey) + record.LedgerReference = strings.TrimSpace(record.LedgerReference) + record.ErrorMessage = strings.TrimSpace(record.ErrorMessage) + + if record.RequestID == "" { + return merrors.InvalidArgument("request_id is required", "request_id") + } + if record.TelegramUserID == "" { + return merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id") + } + if record.LedgerAccountID == "" { + return merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id") + } + if record.Amount == "" { + return merrors.InvalidArgument("amount is required", "amount") + } + if record.Currency == "" { + return merrors.InvalidArgument("currency is required", "currency") + } + if record.IdempotencyKey == "" { + return merrors.InvalidArgument("idempotency_key is required", "idempotency_key") + } + if record.Status == "" { + return merrors.InvalidArgument("status is required", "status") + } + + err := t.repo.Insert(ctx, record, repository.Filter(fieldTreasuryRequestID, record.RequestID)) + if errors.Is(err, merrors.ErrDataConflict) { + return storage.ErrDuplicate + } + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.logger.Warn("Failed to create treasury request", zap.Error(err), zap.String("request_id", record.RequestID)) + return err + } + t.logger.Info("Treasury request created", + zap.String("request_id", record.RequestID), + zap.String("telegram_user_id", record.TelegramUserID), + zap.String("chat_id", record.ChatID), + zap.String("ledger_account_id", record.LedgerAccountID), + zap.String("ledger_account_code", record.LedgerAccountCode), + zap.String("operation_type", strings.TrimSpace(string(record.OperationType))), + zap.String("status", strings.TrimSpace(string(record.Status))), + zap.String("amount", record.Amount), + zap.String("currency", record.Currency)) + return err +} + +func (t *TreasuryRequests) FindByRequestID(ctx context.Context, requestID string) (*model.TreasuryRequest, error) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return nil, merrors.InvalidArgument("request_id is required", "request_id") + } + var result model.TreasuryRequest + err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryRequestID, requestID), &result) + if errors.Is(err, merrors.ErrNoData) { + t.logger.Debug("Treasury request not found", zap.String("request_id", requestID)) + return nil, nil + } + if err != nil { + t.logger.Warn("Failed to load treasury request", zap.Error(err), zap.String("request_id", requestID)) + return nil, err + } + t.logger.Debug("Treasury request loaded", + zap.String("request_id", requestID), + zap.String("status", strings.TrimSpace(string(result.Status))), + zap.String("ledger_account_id", strings.TrimSpace(result.LedgerAccountID))) + return &result, nil +} + +func (t *TreasuryRequests) FindActiveByLedgerAccountID(ctx context.Context, ledgerAccountID string) (*model.TreasuryRequest, error) { + ledgerAccountID = strings.TrimSpace(ledgerAccountID) + if ledgerAccountID == "" { + return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id") + } + var result model.TreasuryRequest + query := repository.Query(). + Filter(repository.Field(fieldTreasuryLedgerAccount), ledgerAccountID). + Filter(repository.Field(fieldTreasuryActive), true) + err := t.repo.FindOneByFilter(ctx, query, &result) + if errors.Is(err, merrors.ErrNoData) { + t.logger.Debug("Active treasury request not found", zap.String("ledger_account_id", ledgerAccountID)) + return nil, nil + } + if err != nil { + t.logger.Warn("Failed to load active treasury request", zap.Error(err), zap.String("ledger_account_id", ledgerAccountID)) + return nil, err + } + t.logger.Debug("Active treasury request loaded", + zap.String("request_id", strings.TrimSpace(result.RequestID)), + zap.String("ledger_account_id", ledgerAccountID), + zap.String("status", strings.TrimSpace(string(result.Status)))) + return &result, nil +} + +func (t *TreasuryRequests) FindDueByStatus(ctx context.Context, statuses []model.TreasuryRequestStatus, now time.Time, limit int64) ([]model.TreasuryRequest, error) { + if len(statuses) == 0 { + return nil, nil + } + if limit <= 0 { + limit = 100 + } + statusValues := make([]any, 0, len(statuses)) + for _, status := range statuses { + next := strings.TrimSpace(string(status)) + if next == "" { + continue + } + statusValues = append(statusValues, next) + } + if len(statusValues) == 0 { + return nil, nil + } + query := repository.Query(). + In(repository.Field(fieldTreasuryStatus), statusValues...). + Comparison(repository.Field(fieldTreasuryScheduledAt), builder.Lte, now). + Sort(repository.Field(fieldTreasuryScheduledAt), true). + Limit(&limit) + + result, err := mutil.GetObjects[model.TreasuryRequest](ctx, t.logger, query, nil, t.repo) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + t.logger.Warn("Failed to list due treasury requests", + zap.Error(err), + zap.Any("statuses", statusValues), + zap.Time("scheduled_before", now), + zap.Int64("limit", limit)) + return nil, err + } + t.logger.Debug("Due treasury requests loaded", + zap.Any("statuses", statusValues), + zap.Time("scheduled_before", now), + zap.Int64("limit", limit), + zap.Int("count", len(result))) + return result, nil +} + +func (t *TreasuryRequests) ClaimScheduled(ctx context.Context, requestID string) (bool, error) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return false, merrors.InvalidArgument("request_id is required", "request_id") + } + patch := repository.Patch(). + Set(repository.Field(fieldTreasuryStatus), string(model.TreasuryRequestStatusConfirmed)) + updated, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, requestID).And( + repository.Filter(fieldTreasuryStatus, string(model.TreasuryRequestStatusScheduled)), + ), patch) + if err != nil { + t.logger.Warn("Failed to claim scheduled treasury request", zap.Error(err), zap.String("request_id", requestID)) + return false, err + } + if updated > 0 { + t.logger.Info("Scheduled treasury request claimed", zap.String("request_id", requestID)) + } else { + t.logger.Debug("Scheduled treasury request claim skipped", zap.String("request_id", requestID)) + } + return updated > 0, nil +} + +func (t *TreasuryRequests) Update(ctx context.Context, record *model.TreasuryRequest) error { + if record == nil { + return merrors.InvalidArgument("treasury request is nil", "record") + } + record.RequestID = strings.TrimSpace(record.RequestID) + record.TelegramUserID = strings.TrimSpace(record.TelegramUserID) + record.LedgerAccountID = strings.TrimSpace(record.LedgerAccountID) + record.LedgerAccountCode = strings.TrimSpace(record.LedgerAccountCode) + record.OrganizationRef = strings.TrimSpace(record.OrganizationRef) + record.ChatID = strings.TrimSpace(record.ChatID) + record.Amount = strings.TrimSpace(record.Amount) + record.Currency = strings.ToUpper(strings.TrimSpace(record.Currency)) + record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey) + record.LedgerReference = strings.TrimSpace(record.LedgerReference) + record.ErrorMessage = strings.TrimSpace(record.ErrorMessage) + if record.RequestID == "" { + return merrors.InvalidArgument("request_id is required", "request_id") + } + existing, err := t.FindByRequestID(ctx, record.RequestID) + if err != nil { + return err + } + if existing == nil { + return merrors.NoData("treasury request not found") + } + + patch := repository.Patch(). + Set(repository.Field("operationType"), record.OperationType). + Set(repository.Field("telegramUserId"), record.TelegramUserID). + Set(repository.Field("ledgerAccountId"), record.LedgerAccountID). + Set(repository.Field("organizationRef"), record.OrganizationRef). + Set(repository.Field("chatId"), record.ChatID). + Set(repository.Field("amount"), record.Amount). + Set(repository.Field("currency"), record.Currency). + Set(repository.Field(fieldTreasuryStatus), record.Status). + Set(repository.Field(fieldTreasuryIdempotencyKey), record.IdempotencyKey). + Set(repository.Field(fieldTreasuryActive), record.Active) + if record.LedgerAccountCode != "" { + patch = patch.Set(repository.Field("ledgerAccountCode"), record.LedgerAccountCode) + } else { + patch = patch.Unset(repository.Field("ledgerAccountCode")) + } + if !record.ConfirmedAt.IsZero() { + patch = patch.Set(repository.Field("confirmedAt"), record.ConfirmedAt) + } else { + patch = patch.Unset(repository.Field("confirmedAt")) + } + if !record.ScheduledAt.IsZero() { + patch = patch.Set(repository.Field("scheduledAt"), record.ScheduledAt) + } else { + patch = patch.Unset(repository.Field("scheduledAt")) + } + if !record.ExecutedAt.IsZero() { + patch = patch.Set(repository.Field("executedAt"), record.ExecutedAt) + } else { + patch = patch.Unset(repository.Field("executedAt")) + } + if !record.CancelledAt.IsZero() { + patch = patch.Set(repository.Field("cancelledAt"), record.CancelledAt) + } else { + patch = patch.Unset(repository.Field("cancelledAt")) + } + if record.LedgerReference != "" { + patch = patch.Set(repository.Field("ledgerReference"), record.LedgerReference) + } else { + patch = patch.Unset(repository.Field("ledgerReference")) + } + if record.ErrorMessage != "" { + patch = patch.Set(repository.Field("errorMessage"), record.ErrorMessage) + } else { + patch = patch.Unset(repository.Field("errorMessage")) + } + if _, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, record.RequestID), patch); err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.logger.Warn("Failed to update treasury request", zap.Error(err), zap.String("request_id", record.RequestID)) + } + return err + } + t.logger.Info("Treasury request updated", + zap.String("request_id", record.RequestID), + zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)), + zap.String("chat_id", strings.TrimSpace(record.ChatID)), + zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)), + zap.String("ledger_account_code", strings.TrimSpace(record.LedgerAccountCode)), + zap.String("operation_type", strings.TrimSpace(string(record.OperationType))), + zap.String("status", strings.TrimSpace(string(record.Status))), + zap.String("amount", strings.TrimSpace(record.Amount)), + zap.String("currency", strings.TrimSpace(record.Currency)), + zap.String("error_message", strings.TrimSpace(record.ErrorMessage))) + return nil +} + +func (t *TreasuryRequests) ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]model.TreasuryRequest, error) { + ledgerAccountID = strings.TrimSpace(ledgerAccountID) + if ledgerAccountID == "" { + return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id") + } + statusValues := make([]any, 0, len(statuses)) + for _, status := range statuses { + next := strings.TrimSpace(string(status)) + if next == "" { + continue + } + statusValues = append(statusValues, next) + } + if len(statusValues) == 0 { + return nil, nil + } + query := repository.Query(). + Filter(repository.Field(fieldTreasuryLedgerAccount), ledgerAccountID). + In(repository.Field(fieldTreasuryStatus), statusValues...). + Comparison(repository.Field(fieldTreasuryCreatedAt), builder.Gte, dayStart). + Comparison(repository.Field(fieldTreasuryCreatedAt), builder.Lt, dayEnd) + + result, err := mutil.GetObjects[model.TreasuryRequest](ctx, t.logger, query, nil, t.repo) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + t.logger.Warn("Failed to list treasury requests by account and statuses", + zap.Error(err), + zap.String("ledger_account_id", ledgerAccountID), + zap.Any("statuses", statusValues), + zap.Time("day_start", dayStart), + zap.Time("day_end", dayEnd)) + return nil, err + } + t.logger.Debug("Treasury requests loaded by account and statuses", + zap.String("ledger_account_id", ledgerAccountID), + zap.Any("statuses", statusValues), + zap.Time("day_start", dayStart), + zap.Time("day_end", dayEnd), + zap.Int("count", len(result))) + return result, nil +} + +var _ storage.TreasuryRequestsStore = (*TreasuryRequests)(nil) diff --git a/api/gateway/chsettle/storage/mongo/store/treasury_telegram_users.go b/api/gateway/chsettle/storage/mongo/store/treasury_telegram_users.go new file mode 100644 index 00000000..271bae10 --- /dev/null +++ b/api/gateway/chsettle/storage/mongo/store/treasury_telegram_users.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/gateway/chsettle/storage" + "github.com/tech/sendico/gateway/chsettle/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/mongo" + "go.uber.org/zap" +) + +const ( + treasuryTelegramUsersCollection = "treasury_telegram_users" + fieldTreasuryTelegramUserID = "telegramUserId" +) + +type TreasuryTelegramUsers struct { + logger mlogger.Logger + repo repository.Repository +} + +func NewTreasuryTelegramUsers(logger mlogger.Logger, db *mongo.Database) (*TreasuryTelegramUsers, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("treasury_telegram_users").With(zap.String("collection", treasuryTelegramUsersCollection)) + + repo := repository.CreateMongoRepository(db, treasuryTelegramUsersCollection) + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldTreasuryTelegramUserID, Sort: ri.Asc}}, + Unique: true, + }); err != nil { + logger.Error("Failed to create treasury telegram users user_id index", zap.Error(err), zap.String("index_field", fieldTreasuryTelegramUserID)) + return nil, err + } + + return &TreasuryTelegramUsers{ + logger: logger, + repo: repo, + }, nil +} + +func (t *TreasuryTelegramUsers) FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error) { + telegramUserID = strings.TrimSpace(telegramUserID) + if telegramUserID == "" { + return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id") + } + var result model.TreasuryTelegramUser + err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryTelegramUserID, telegramUserID), &result) + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + t.logger.Warn("Failed to load treasury telegram user", zap.Error(err), zap.String("telegram_user_id", telegramUserID)) + } + return nil, err + } + result.TelegramUserID = strings.TrimSpace(result.TelegramUserID) + result.LedgerAccountID = strings.TrimSpace(result.LedgerAccountID) + if len(result.AllowedChatIDs) > 0 { + normalized := make([]string, 0, len(result.AllowedChatIDs)) + for _, next := range result.AllowedChatIDs { + next = strings.TrimSpace(next) + if next == "" { + continue + } + normalized = append(normalized, next) + } + result.AllowedChatIDs = normalized + } + if result.TelegramUserID == "" || result.LedgerAccountID == "" { + return nil, nil + } + return &result, nil +} + +var _ storage.TreasuryTelegramUsersStore = (*TreasuryTelegramUsers)(nil) diff --git a/api/gateway/chsettle/storage/mongo/transaction.go b/api/gateway/chsettle/storage/mongo/transaction.go new file mode 100644 index 00000000..52bad57e --- /dev/null +++ b/api/gateway/chsettle/storage/mongo/transaction.go @@ -0,0 +1,38 @@ +package mongo + +import ( + "context" + + "github.com/tech/sendico/pkg/db/transaction" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type mongoTransactionFactory struct { + client *mongo.Client +} + +func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction { + return &mongoTransaction{client: f.client} +} + +type mongoTransaction struct { + client *mongo.Client +} + +func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + session, err := t.client.StartSession() + if err != nil { + return nil, err + } + defer session.EndSession(ctx) + + run := func(sessCtx context.Context) (any, error) { + return cb(sessCtx) + } + + return session.WithTransaction(ctx, run) +} + +func newMongoTransactionFactory(client *mongo.Client) transaction.Factory { + return &mongoTransactionFactory{client: client} +} diff --git a/api/gateway/chsettle/storage/storage.go b/api/gateway/chsettle/storage/storage.go new file mode 100644 index 00000000..40b66f43 --- /dev/null +++ b/api/gateway/chsettle/storage/storage.go @@ -0,0 +1,53 @@ +package storage + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/chsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate record") + +type Repository interface { + Payments() PaymentsStore + TelegramConfirmations() TelegramConfirmationsStore + PendingConfirmations() PendingConfirmationsStore + TreasuryRequests() TreasuryRequestsStore + TreasuryTelegramUsers() TreasuryTelegramUsersStore +} + +type PaymentsStore interface { + FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentRecord, error) + FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error) + Upsert(ctx context.Context, record *model.PaymentRecord) error +} + +type TelegramConfirmationsStore interface { + Upsert(ctx context.Context, record *model.TelegramConfirmation) error +} + +type PendingConfirmationsStore interface { + Upsert(ctx context.Context, record *model.PendingConfirmation) error + FindByRequestID(ctx context.Context, requestID string) (*model.PendingConfirmation, error) + FindByMessageID(ctx context.Context, messageID string) (*model.PendingConfirmation, error) + MarkClarified(ctx context.Context, requestID string) error + AttachMessage(ctx context.Context, requestID string, messageID string) error + DeleteByRequestID(ctx context.Context, requestID string) error + ListExpired(ctx context.Context, now time.Time, limit int64) ([]model.PendingConfirmation, error) +} + +type TreasuryRequestsStore interface { + Create(ctx context.Context, record *model.TreasuryRequest) error + FindByRequestID(ctx context.Context, requestID string) (*model.TreasuryRequest, error) + FindActiveByLedgerAccountID(ctx context.Context, ledgerAccountID string) (*model.TreasuryRequest, error) + FindDueByStatus(ctx context.Context, statuses []model.TreasuryRequestStatus, now time.Time, limit int64) ([]model.TreasuryRequest, error) + ClaimScheduled(ctx context.Context, requestID string) (bool, error) + Update(ctx context.Context, record *model.TreasuryRequest) error + ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]model.TreasuryRequest, error) +} + +type TreasuryTelegramUsersStore interface { + FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error) +}