diff --git a/api/gateway/chain/config.yml b/api/gateway/chain/config.yml index 0426314..3ad566a 100644 --- a/api/gateway/chain/config.yml +++ b/api/gateway/chain/config.yml @@ -8,7 +8,7 @@ grpc: enable_health: true metrics: - address: ":9403" + address: ":9406" database: driver: mongodb diff --git a/api/gateway/mntx/config.yml b/api/gateway/mntx/config.yml index ba2d1a4..2d14e11 100644 --- a/api/gateway/mntx/config.yml +++ b/api/gateway/mntx/config.yml @@ -35,10 +35,10 @@ monetix: gateway: id: "monetix" is_enabled: true - # network: "VISA_DIRECT" - # currencies: ["RUB"] - # limits: - # min_amount: "0" + network: "VISA_DIRECT" + currencies: ["RUB"] + limits: + min_amount: "0" http: callback: diff --git a/api/gateway/tgsettle/.gitignore b/api/gateway/tgsettle/.gitignore new file mode 100644 index 0000000..4cb3c7e --- /dev/null +++ b/api/gateway/tgsettle/.gitignore @@ -0,0 +1 @@ +/mntx-gateway diff --git a/api/gateway/tgsettle/config.yml b/api/gateway/tgsettle/config.yml new file mode 100644 index 0000000..7a10c6e --- /dev/null +++ b/api/gateway/tgsettle/config.yml @@ -0,0 +1,40 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50080" + enable_reflection: true + enable_health: true + +metrics: + address: ":9406" + +database: + driver: mongodb + settings: + host_env: TGSETTLE_GATEWAY_MONGO_HOST + port_env: TGSETTLE_GATEWAY_MONGO_PORT + database_env: TGSETTLE_GATEWAY_MONGO_DATABASE + user_env: TGSETTLE_GATEWAY_MONGO_USER + password_env: TGSETTLE_GATEWAY_MONGO_PASSWORD + auth_source_env: TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: TGSETTLE_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: TGSettle Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + +gateway: + rail: "card" + target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID + timeout_seconds: 120 + accepted_user_ids: [] diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod new file mode 100644 index 0000000..fa9e07e --- /dev/null +++ b/api/gateway/tgsettle/go.mod @@ -0,0 +1,51 @@ +module github.com/tech/sendico/gateway/tgsettle + +go 1.25.3 + +replace github.com/tech/sendico/pkg => ../../pkg + +require ( + github.com/tech/sendico/pkg v0.1.0 + go.mongodb.org/mongo-driver v1.17.6 + go.uber.org/zap v1.27.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-chi/chi/v5 v5.2.3 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.2 // 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/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nkeys v0.4.12 // 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.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum new file mode 100644 index 0000000..fbbbd30 --- /dev/null +++ b/api/gateway/tgsettle/go.sum @@ -0,0 +1,225 @@ +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.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/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/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E= +github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA= +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.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +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/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/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/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= +github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= +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.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.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 v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/gateway/tgsettle/internal/appversion/version.go b/api/gateway/tgsettle/internal/appversion/version.go new file mode 100644 index 0000000..3de8c14 --- /dev/null +++ b/api/gateway/tgsettle/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/tgsettle/internal/server/internal/serverimp.go b/api/gateway/tgsettle/internal/server/internal/serverimp.go new file mode 100644 index 0000000..53e2b4c --- /dev/null +++ b/api/gateway/tgsettle/internal/server/internal/serverimp.go @@ -0,0 +1,136 @@ +package serverimp + +import ( + "context" + "os" + "time" + + "github.com/tech/sendico/gateway/tgsettle/internal/service/gateway" + "github.com/tech/sendico/gateway/tgsettle/storage" + gatewaymongo "github.com/tech/sendico/gateway/tgsettle/storage/mongo" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/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 +} + +type config struct { + *grpcapp.Config `yaml:",inline"` + Gateway gatewayConfig `yaml:"gateway"` +} + +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"` +} + +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() + } + 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 { + 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)) + } + } + + 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) { + gwCfg := gateway.Config{ + Rail: cfg.Gateway.Rail, + TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv, + TimeoutSeconds: cfg.Gateway.TimeoutSeconds, + AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs, + } + svc := gateway.NewService(logger, repo, producer, broker, gwCfg) + i.service = svc + return svc, nil + } + + app, err := grpcapp.NewApp(i.logger, "tgsettle_gateway", cfg.Config, i.debug, repoFactory, serviceFactory) + if err != nil { + return err + } + i.app = app + return i.app.Start() +} + +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.Gateway.Rail == "" { + return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail") + } + return cfg, nil +} diff --git a/api/gateway/tgsettle/internal/server/server.go b/api/gateway/tgsettle/internal/server/server.go new file mode 100644 index 0000000..7e56dd7 --- /dev/null +++ b/api/gateway/tgsettle/internal/server/server.go @@ -0,0 +1,11 @@ +package server + +import ( + serverimp "github.com/tech/sendico/gateway/tgsettle/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/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go new file mode 100644 index 0000000..ec4e5ad --- /dev/null +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -0,0 +1,334 @@ +package gateway + +import ( + "context" + "os" + "strings" + "sync" + + "github.com/tech/sendico/gateway/tgsettle/storage" + storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" + "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" + paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" +) + +const ( + defaultConfirmationTimeoutSeconds = 120 + executedStatus = "executed" +) + +type Config struct { + Rail string + TargetChatIDEnv string + TimeoutSeconds int32 + AcceptedUserIDs []string +} + +type Service struct { + logger mlogger.Logger + repo storage.Repository + producer msg.Producer + broker mb.Broker + cfg Config + rail string + chatID string + announcer *discovery.Announcer + + mu sync.Mutex + pending map[string]*model.PaymentGatewayIntent + consumers []msg.Consumer +} + +func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service { + if logger != nil { + logger = logger.Named("tgsettle_gateway") + } + svc := &Service{ + logger: logger, + repo: repo, + producer: producer, + broker: broker, + cfg: cfg, + rail: strings.TrimSpace(cfg.Rail), + pending: map[string]*model.PaymentGatewayIntent{}, + } + svc.chatID = strings.TrimSpace(readEnv(cfg.TargetChatIDEnv)) + svc.startConsumers() + svc.startAnnouncer() + return svc +} + +func (s *Service) Register(_ routers.GRPC) error { + return nil +} + +func (s *Service) Shutdown() { + if s == nil { + return + } + if s.announcer != nil { + s.announcer.Stop() + } + for _, consumer := range s.consumers { + if consumer != nil { + consumer.Close() + } + } +} + +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 + } + intentProcessor := paymentgateway.NewPaymentGatewayIntentProcessor(s.logger, s.onIntent) + s.consumeProcessor(intentProcessor) + resultProcessor := confirmations.NewConfirmationResultProcessor(s.logger, string(mservice.PaymentGateway), s.rail, s.onConfirmationResult) + s.consumeProcessor(resultProcessor) +} + +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 { + s.logger.Warn("Messaging consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + } + }() +} + +func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayIntent) error { + if intent == nil { + return merrors.InvalidArgument("payment gateway intent is nil", "intent") + } + intent = normalizeIntent(intent) + if intent.IdempotencyKey == "" { + return merrors.InvalidArgument("idempotency_key is required", "idempotency_key") + } + if intent.PaymentIntentID == "" { + return merrors.InvalidArgument("payment_intent_id is required", "payment_intent_id") + } + if intent.RequestedMoney == nil || strings.TrimSpace(intent.RequestedMoney.Amount) == "" || strings.TrimSpace(intent.RequestedMoney.Currency) == "" { + return merrors.InvalidArgument("requested_money is required", "requested_money") + } + if s.repo == nil || s.repo.Payments() == nil { + return merrors.Internal("payment gateway storage unavailable") + } + + existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, intent.IdempotencyKey) + if err != nil { + return err + } + if existing != nil { + s.logger.Info("Payment gateway intent already executed", zap.String("idempotency_key", intent.IdempotencyKey)) + return nil + } + + confirmReq, err := s.buildConfirmationRequest(intent) + if err != nil { + return err + } + if err := s.sendConfirmationRequest(confirmReq); err != nil { + return err + } + s.trackIntent(confirmReq.RequestID, intent) + return 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") + } + intent := s.lookupIntent(requestID) + if intent == nil { + s.logger.Warn("Confirmation result ignored: intent not found", zap.String("request_id", requestID)) + return nil + } + + if result.RawReply != nil && s.repo != nil && s.repo.TelegramConfirmations() != nil { + _ = s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{ + RequestID: requestID, + PaymentIntentID: intent.PaymentIntentID, + QuoteRef: intent.QuoteRef, + RawReply: result.RawReply, + }) + } + + if result.Status == model.ConfirmationStatusConfirmed || result.Status == model.ConfirmationStatusClarified { + exec := &storagemodel.PaymentExecution{ + IdempotencyKey: intent.IdempotencyKey, + PaymentIntentID: intent.PaymentIntentID, + ExecutedMoney: result.Money, + QuoteRef: intent.QuoteRef, + Status: executedStatus, + } + if err := s.repo.Payments().InsertExecution(ctx, exec); err != nil && err != storage.ErrDuplicate { + return err + } + } + + s.publishExecution(intent, result) + s.removeIntent(requestID) + return nil +} + +func (s *Service) buildConfirmationRequest(intent *model.PaymentGatewayIntent) (*model.ConfirmationRequest, error) { + targetChatID := strings.TrimSpace(intent.TargetChatID) + if targetChatID == "" { + targetChatID = s.chatID + } + if targetChatID == "" { + return nil, merrors.InvalidArgument("target_chat_id is required", "target_chat_id") + } + rail := strings.TrimSpace(intent.OutgoingLeg) + if rail == "" { + rail = 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, + }, nil +} + +func (s *Service) sendConfirmationRequest(request *model.ConfirmationRequest) error { + if request == nil { + return merrors.InvalidArgument("confirmation request is nil", "request") + } + if s.producer == nil { + 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)) + return err + } + return nil +} + +func (s *Service) publishExecution(intent *model.PaymentGatewayIntent, result *model.ConfirmationResult) { + if s == nil || intent == nil || result == nil || s.producer == nil { + return + } + exec := &model.PaymentGatewayExecution{ + PaymentIntentID: intent.PaymentIntentID, + IdempotencyKey: intent.IdempotencyKey, + QuoteRef: intent.QuoteRef, + ExecutedMoney: result.Money, + Status: result.Status, + RequestID: result.RequestID, + RawReply: result.RawReply, + } + env := paymentgateway.PaymentGatewayExecution(string(mservice.PaymentGateway), exec) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish gateway execution result", zap.Error(err), zap.String("request_id", result.RequestID)) + } +} + +func (s *Service) trackIntent(requestID string, intent *model.PaymentGatewayIntent) { + if s == nil || intent == nil { + return + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return + } + s.mu.Lock() + s.pending[requestID] = intent + s.mu.Unlock() +} + +func (s *Service) lookupIntent(requestID string) *model.PaymentGatewayIntent { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.pending[requestID] +} + +func (s *Service) removeIntent(requestID string) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return + } + s.mu.Lock() + delete(s.pending, requestID) + s.mu.Unlock() +} + +func (s *Service) startAnnouncer() { + if s == nil || s.producer == nil { + return + } + caps := []string{"telegram_confirmation", "money_persistence"} + if s.rail != "" { + caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail)) + } + announce := discovery.Announcement{ + Service: string(mservice.PaymentGateway), + Rail: s.rail, + Operations: caps, + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.PaymentGateway), announce) + s.announcer.Start() +} + +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 = strings.TrimSpace(cp.OutgoingLeg) + cp.QuoteRef = strings.TrimSpace(cp.QuoteRef) + cp.TargetChatID = strings.TrimSpace(cp.TargetChatID) + if cp.RequestedMoney != nil { + cp.RequestedMoney.Amount = strings.TrimSpace(cp.RequestedMoney.Amount) + cp.RequestedMoney.Currency = strings.TrimSpace(cp.RequestedMoney.Currency) + } + return &cp +} + +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/tgsettle/internal/service/gateway/service_test.go b/api/gateway/tgsettle/internal/service/gateway/service_test.go new file mode 100644 index 0000000..6691422 --- /dev/null +++ b/api/gateway/tgsettle/internal/service/gateway/service_test.go @@ -0,0 +1,289 @@ +package gateway + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "github.com/tech/sendico/gateway/tgsettle/storage" + storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" + envelope "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/model" + notification "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" + mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +type fakePaymentsStore struct { + mu sync.Mutex + executions map[string]*storagemodel.PaymentExecution +} + +func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentExecution, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.executions[key], nil +} + +func (f *fakePaymentsStore) InsertExecution(_ context.Context, exec *storagemodel.PaymentExecution) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.executions == nil { + f.executions = map[string]*storagemodel.PaymentExecution{} + } + if _, ok := f.executions[exec.IdempotencyKey]; ok { + return storage.ErrDuplicate + } + f.executions[exec.IdempotencyKey] = exec + 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 +} + +func (f *fakeRepo) Payments() storage.PaymentsStore { + return f.payments +} + +func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore { + return f.tg +} + +type captureProducer struct { + mu sync.Mutex + confirmationRequests []*model.ConfirmationRequest + executions []*model.PaymentGatewayExecution +} + +func (c *captureProducer) SendMessage(env envelope.Envelope) error { + _, _ = env.Serialize() + switch env.GetSignature().ToString() { + case model.NewNotification(mservice.Notifications, notification.NAConfirmationRequest).ToString(): + var req model.ConfirmationRequest + if err := json.Unmarshal(env.GetData(), &req); err == nil { + c.mu.Lock() + c.confirmationRequests = append(c.confirmationRequests, &req) + c.mu.Unlock() + } + case model.NewNotification(mservice.PaymentGateway, notification.NAPaymentGatewayExecution).ToString(): + var exec model.PaymentGatewayExecution + if err := json.Unmarshal(env.GetData(), &exec); err == nil { + c.mu.Lock() + c.executions = append(c.executions, &exec) + c.mu.Unlock() + } + } + return nil +} + +func (c *captureProducer) Reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.confirmationRequests = nil + c.executions = nil +} + +func TestOnIntentCreatesConfirmationRequest(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + t.Setenv("PGS_CHAT_ID", "-100") + svc := NewService(logger, repo, prod, nil, Config{ + Rail: "card", + TargetChatIDEnv: "PGS_CHAT_ID", + TimeoutSeconds: 90, + AcceptedUserIDs: []string{"42"}, + }) + prod.Reset() + + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-1", + IdempotencyKey: "idem-1", + OutgoingLeg: "card", + QuoteRef: "quote-1", + RequestedMoney: &paymenttypes.Money{Amount: "10.50", Currency: "USD"}, + TargetChatID: "", + } + if err := svc.onIntent(context.Background(), intent); err != nil { + t.Fatalf("onIntent error: %v", err) + } + if len(prod.confirmationRequests) != 1 { + t.Fatalf("expected 1 confirmation request, got %d", len(prod.confirmationRequests)) + } + req := prod.confirmationRequests[0] + if req.RequestID != "idem-1" || req.PaymentIntentID != "pi-1" || req.QuoteRef != "quote-1" { + t.Fatalf("unexpected confirmation request fields: %#v", req) + } + if req.TargetChatID != "-100" { + t.Fatalf("expected target chat id -100, got %q", req.TargetChatID) + } + if req.RequestedMoney == nil || req.RequestedMoney.Amount != "10.50" || req.RequestedMoney.Currency != "USD" { + t.Fatalf("requested money mismatch: %#v", req.RequestedMoney) + } + if req.TimeoutSeconds != 90 { + t.Fatalf("expected timeout 90, got %d", req.TimeoutSeconds) + } + if req.SourceService != string(mservice.PaymentGateway) || req.Rail != "card" { + t.Fatalf("unexpected source/rail: %#v", req) + } +} + +func TestConfirmationResultPersistsExecutionAndReply(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-2", + IdempotencyKey: "idem-2", + QuoteRef: "quote-2", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + } + svc.trackIntent("idem-2", intent) + + result := &model.ConfirmationResult{ + RequestID: "idem-2", + Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + Status: model.ConfirmationStatusConfirmed, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2", Text: "5 EUR"}, + } + if err := svc.onConfirmationResult(context.Background(), result); err != nil { + t.Fatalf("onConfirmationResult error: %v", err) + } + if repo.payments.executions["idem-2"] == nil { + t.Fatalf("expected payment execution to be stored") + } + if repo.payments.executions["idem-2"].ExecutedMoney == nil || repo.payments.executions["idem-2"].ExecutedMoney.Amount != "5" { + t.Fatalf("executed money not stored correctly") + } + if repo.tg.records["idem-2"] == nil || repo.tg.records["idem-2"].RawReply.Text != "5 EUR" { + t.Fatalf("telegram reply not stored correctly") + } +} + +func TestClarifiedResultPersistsExecution(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-clarified", + IdempotencyKey: "idem-clarified", + QuoteRef: "quote-clarified", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "12", Currency: "USD"}, + } + svc.trackIntent("idem-clarified", intent) + + result := &model.ConfirmationResult{ + RequestID: "idem-clarified", + Money: &paymenttypes.Money{Amount: "12", Currency: "USD"}, + Status: model.ConfirmationStatusClarified, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "3", Text: "12 USD"}, + } + if err := svc.onConfirmationResult(context.Background(), result); err != nil { + t.Fatalf("onConfirmationResult error: %v", err) + } + if repo.payments.executions["idem-clarified"] == nil { + t.Fatalf("expected payment execution to be stored") + } +} + +func TestIdempotencyPreventsDuplicateWrites(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{executions: map[string]*storagemodel.PaymentExecution{ + "idem-3": {IdempotencyKey: "idem-3"}, + }}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-3", + IdempotencyKey: "idem-3", + OutgoingLeg: "card", + QuoteRef: "quote-3", + RequestedMoney: &paymenttypes.Money{Amount: "1", Currency: "USD"}, + TargetChatID: "chat", + } + if err := svc.onIntent(context.Background(), intent); err != nil { + t.Fatalf("onIntent error: %v", err) + } + if len(prod.confirmationRequests) != 0 { + t.Fatalf("expected no confirmation request for duplicate intent") + } +} + +func TestTimeoutDoesNotPersistExecution(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-4", + IdempotencyKey: "idem-4", + QuoteRef: "quote-4", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "8", Currency: "USD"}, + } + svc.trackIntent("idem-4", intent) + + result := &model.ConfirmationResult{ + RequestID: "idem-4", + Status: model.ConfirmationStatusTimeout, + } + if err := svc.onConfirmationResult(context.Background(), result); err != nil { + t.Fatalf("onConfirmationResult error: %v", err) + } + if repo.payments.executions["idem-4"] != nil { + t.Fatalf("expected no execution record for timeout") + } +} + +func TestRejectedDoesNotPersistExecution(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} + prod := &captureProducer{} + svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) + intent := &model.PaymentGatewayIntent{ + PaymentIntentID: "pi-reject", + IdempotencyKey: "idem-reject", + QuoteRef: "quote-reject", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "3", Currency: "USD"}, + } + svc.trackIntent("idem-reject", intent) + + result := &model.ConfirmationResult{ + RequestID: "idem-reject", + Status: model.ConfirmationStatusRejected, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "4", Text: "no"}, + } + if err := svc.onConfirmationResult(context.Background(), result); err != nil { + t.Fatalf("onConfirmationResult error: %v", err) + } + if repo.payments.executions["idem-reject"] != nil { + t.Fatalf("expected no execution record for rejection") + } + if repo.tg.records["idem-reject"] == nil { + t.Fatalf("expected raw reply to be stored for rejection") + } +} diff --git a/api/gateway/tgsettle/main.go b/api/gateway/tgsettle/main.go new file mode 100644 index 0000000..29cada1 --- /dev/null +++ b/api/gateway/tgsettle/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/gateway/tgsettle/internal/appversion" + si "github.com/tech/sendico/gateway/tgsettle/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/tgsettle/storage/model/execution.go b/api/gateway/tgsettle/storage/model/execution.go new file mode 100644 index 0000000..672e4f4 --- /dev/null +++ b/api/gateway/tgsettle/storage/model/execution.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type PaymentExecution struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` +} + +type TelegramConfirmation struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + 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"` +} diff --git a/api/gateway/tgsettle/storage/mongo/repository.go b/api/gateway/tgsettle/storage/mongo/repository.go new file mode 100644 index 0000000..828b699 --- /dev/null +++ b/api/gateway/tgsettle/storage/mongo/repository.go @@ -0,0 +1,68 @@ +package mongo + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/tgsettle/storage" + "github.com/tech/sendico/gateway/tgsettle/storage/mongo/store" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +type Repository struct { + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + + payments storage.PaymentsStore + tg storage.TelegramConfirmationsStore +} + +func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { + 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") + } + result := &Repository{ + logger: logger.Named("storage").Named("mongo"), + conn: conn, + db: conn.Database(), + } + 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)) + 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)) + return nil, err + } + result.payments = paymentsStore + result.tg = tgStore + 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 +} + +var _ storage.Repository = (*Repository)(nil) diff --git a/api/gateway/tgsettle/storage/mongo/store/payments.go b/api/gateway/tgsettle/storage/mongo/store/payments.go new file mode 100644 index 0000000..8d97114 --- /dev/null +++ b/api/gateway/tgsettle/storage/mongo/store/payments.go @@ -0,0 +1,82 @@ +package store + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/gateway/tgsettle/storage" + "github.com/tech/sendico/gateway/tgsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.uber.org/zap" +) + +const ( + paymentsCollection = "payments" + fieldIdempotencyKey = "idempotencyKey" +) + +type Payments struct { + logger mlogger.Logger + coll *mongo.Collection +} + +func NewPayments(logger mlogger.Logger, db *mongo.Database) (*Payments, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + p := &Payments{ + logger: logger.Named("payments"), + coll: db.Collection(paymentsCollection), + } + _, err := p.coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{{Key: fieldIdempotencyKey, Value: 1}}, + Options: options.Index().SetUnique(true), + }) + if err != nil { + p.logger.Error("Failed to create payments idempotency index", zap.Error(err)) + return nil, err + } + return p, nil +} + +func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentExecution, error) { + key = strings.TrimSpace(key) + if key == "" { + return nil, merrors.InvalidArgument("idempotency key is required", "idempotency_key") + } + var result model.PaymentExecution + err := p.coll.FindOne(ctx, bson.M{fieldIdempotencyKey: key}).Decode(&result) + if err == mongo.ErrNoDocuments { + return nil, nil + } + if err != nil { + return nil, err + } + return &result, nil +} + +func (p *Payments) InsertExecution(ctx context.Context, exec *model.PaymentExecution) error { + if exec == nil { + return merrors.InvalidArgument("payment execution is nil", "execution") + } + exec.IdempotencyKey = strings.TrimSpace(exec.IdempotencyKey) + exec.PaymentIntentID = strings.TrimSpace(exec.PaymentIntentID) + exec.QuoteRef = strings.TrimSpace(exec.QuoteRef) + if exec.ExecutedAt.IsZero() { + exec.ExecutedAt = time.Now() + } + if _, err := p.coll.InsertOne(ctx, exec); err != nil { + if mongo.IsDuplicateKeyError(err) { + return storage.ErrDuplicate + } + return err + } + return nil +} + +var _ storage.PaymentsStore = (*Payments)(nil) diff --git a/api/gateway/tgsettle/storage/mongo/store/telegram_confirmations.go b/api/gateway/tgsettle/storage/mongo/store/telegram_confirmations.go new file mode 100644 index 0000000..b38eb70 --- /dev/null +++ b/api/gateway/tgsettle/storage/mongo/store/telegram_confirmations.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/gateway/tgsettle/storage" + "github.com/tech/sendico/gateway/tgsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.uber.org/zap" +) + +const ( + telegramCollection = "telegram_confirmations" + fieldRequestID = "requestId" +) + +type TelegramConfirmations struct { + logger mlogger.Logger + coll *mongo.Collection +} + +func NewTelegramConfirmations(logger mlogger.Logger, db *mongo.Database) (*TelegramConfirmations, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + t := &TelegramConfirmations{ + logger: logger.Named("telegram_confirmations"), + coll: db.Collection(telegramCollection), + } + _, err := t.coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{ + Keys: bson.D{{Key: fieldRequestID, Value: 1}}, + Options: options.Index().SetUnique(true), + }) + if err != nil { + t.logger.Error("Failed to create telegram confirmations request_id index", zap.Error(err)) + return nil, err + } + 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() + } + update := bson.M{ + "$set": record, + } + _, err := t.coll.UpdateOne(ctx, bson.M{fieldRequestID: record.RequestID}, update, options.Update().SetUpsert(true)) + return err +} + +var _ storage.TelegramConfirmationsStore = (*TelegramConfirmations)(nil) diff --git a/api/gateway/tgsettle/storage/storage.go b/api/gateway/tgsettle/storage/storage.go new file mode 100644 index 0000000..598bbee --- /dev/null +++ b/api/gateway/tgsettle/storage/storage.go @@ -0,0 +1,24 @@ +package storage + +import ( + "context" + "errors" + + "github.com/tech/sendico/gateway/tgsettle/storage/model" +) + +var ErrDuplicate = errors.New("payment gateway storage: duplicate record") + +type Repository interface { + Payments() PaymentsStore + TelegramConfirmations() TelegramConfirmationsStore +} + +type PaymentsStore interface { + FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentExecution, error) + InsertExecution(ctx context.Context, exec *model.PaymentExecution) error +} + +type TelegramConfirmationsStore interface { + Upsert(ctx context.Context, record *model.TelegramConfirmation) error +} diff --git a/api/notification/interface/api/api.go b/api/notification/interface/api/api.go index 8410088..6941441 100644 --- a/api/notification/interface/api/api.go +++ b/api/notification/interface/api/api.go @@ -1,6 +1,7 @@ package api import ( + "github.com/go-chi/chi/v5" "github.com/tech/sendico/notification/interface/api/localizer" "github.com/tech/sendico/pkg/db" "github.com/tech/sendico/pkg/domainprovider" @@ -16,6 +17,7 @@ type API interface { Register() messaging.Register Localizer() localizer.Localizer DomainProvider() domainprovider.DomainProvider + Router() *chi.Mux } type MicroServiceFactoryT = func(API) (mservice.MicroService, error) diff --git a/api/notification/internal/api/api.go b/api/notification/internal/api/api.go index 4beea9c..6999eda 100644 --- a/api/notification/internal/api/api.go +++ b/api/notification/internal/api/api.go @@ -27,6 +27,7 @@ type APIImp struct { services Microservices debug bool mw *Middleware + router *chi.Mux } func (a *APIImp) installMicroservice(srv mservice.MicroService) { @@ -69,6 +70,10 @@ func (a *APIImp) Register() messaging.Register { return a.mw } +func (a *APIImp) Router() *chi.Mux { + return a.router +} + func (a *APIImp) installServices() error { srvf := make([]api.MicroServiceFactoryT, 0) @@ -117,6 +122,7 @@ func CreateAPI(logger mlogger.Logger, config *api.Config, l localizer.Localizer, p.config = config p.db = db p.localizer = l + p.router = router var err error if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil { diff --git a/api/notification/internal/server/notificationimp/confirmation.go b/api/notification/internal/server/notificationimp/confirmation.go new file mode 100644 index 0000000..f3e8782 --- /dev/null +++ b/api/notification/internal/server/notificationimp/confirmation.go @@ -0,0 +1,404 @@ +package notificationimp + +import ( + "context" + "errors" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/tech/sendico/notification/internal/server/notificationimp/telegram" + msg "github.com/tech/sendico/pkg/messaging" + confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.uber.org/zap" +) + +const ( + defaultConfirmationTimeout = 120 * time.Second +) + +type confirmationManager struct { + logger mlogger.Logger + tg telegram.Client + sender string + outbox msg.Producer + + mu sync.Mutex + pendingByMessage map[string]*confirmationState + pendingByRequest map[string]*confirmationState +} + +type confirmationState struct { + request model.ConfirmationRequest + requestMessageID string + targetChatID string + callbackSubject string + clarified bool + timer *time.Timer +} + +func newConfirmationManager(logger mlogger.Logger, tg telegram.Client, outbox msg.Producer) *confirmationManager { + if logger != nil { + logger = logger.Named("confirmations") + } + return &confirmationManager{ + logger: logger, + tg: tg, + outbox: outbox, + sender: string(mservice.Notifications), + pendingByMessage: map[string]*confirmationState{}, + pendingByRequest: map[string]*confirmationState{}, + } +} + +func (m *confirmationManager) Stop() { + if m == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + for _, state := range m.pendingByMessage { + if state.timer != nil { + state.timer.Stop() + } + } + m.pendingByMessage = map[string]*confirmationState{} + m.pendingByRequest = map[string]*confirmationState{} +} + +func (m *confirmationManager) HandleRequest(ctx context.Context, request *model.ConfirmationRequest) error { + if m == nil { + return errors.New("confirmation manager is nil") + } + if request == nil { + return merrors.InvalidArgument("confirmation request is nil", "request") + } + if m.tg == nil { + return merrors.InvalidArgument("telegram client is not configured", "telegram") + } + + req := normalizeConfirmationRequest(*request) + if req.RequestID == "" { + return merrors.InvalidArgument("confirmation request_id is required", "request_id") + } + if req.TargetChatID == "" { + return merrors.InvalidArgument("confirmation target_chat_id is required", "target_chat_id") + } + if req.RequestedMoney == nil || strings.TrimSpace(req.RequestedMoney.Amount) == "" || strings.TrimSpace(req.RequestedMoney.Currency) == "" { + return merrors.InvalidArgument("confirmation requested_money is required", "requested_money") + } + if req.SourceService == "" { + return merrors.InvalidArgument("confirmation source_service is required", "source_service") + } + + m.mu.Lock() + if _, ok := m.pendingByRequest[req.RequestID]; ok { + m.mu.Unlock() + m.logger.Info("Confirmation request already pending", zap.String("request_id", req.RequestID)) + return nil + } + m.mu.Unlock() + + message := confirmationPrompt(&req) + sent, err := m.tg.SendText(ctx, req.TargetChatID, message, "") + if err != nil { + m.logger.Warn("Failed to send confirmation request to Telegram", zap.Error(err), zap.String("request_id", req.RequestID)) + return err + } + if sent == nil || strings.TrimSpace(sent.MessageID) == "" { + return merrors.Internal("telegram confirmation message id is missing") + } + + state := &confirmationState{ + request: req, + requestMessageID: strings.TrimSpace(sent.MessageID), + targetChatID: strings.TrimSpace(req.TargetChatID), + callbackSubject: confirmationCallbackSubject(req.SourceService, req.Rail), + } + timeout := time.Duration(req.TimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = defaultConfirmationTimeout + } + state.timer = time.AfterFunc(timeout, func() { + m.handleTimeout(state.requestMessageID) + }) + + m.mu.Lock() + m.pendingByMessage[state.requestMessageID] = state + m.pendingByRequest[req.RequestID] = state + m.mu.Unlock() + + m.logger.Info("Confirmation request sent", zap.String("request_id", req.RequestID), zap.String("message_id", state.requestMessageID), zap.String("callback_subject", state.callbackSubject)) + return nil +} + +func (m *confirmationManager) HandleUpdate(ctx context.Context, update *telegram.Update) { + if m == nil || update == nil || update.Message == nil { + return + } + message := update.Message + if message.ReplyToMessage == nil { + return + } + + replyToID := strconv.FormatInt(message.ReplyToMessage.MessageID, 10) + state := m.lookupByMessageID(replyToID) + if state == nil { + return + } + + chatID := strconv.FormatInt(message.Chat.ID, 10) + if chatID != state.targetChatID { + m.logger.Debug("Telegram reply ignored: chat mismatch", zap.String("expected_chat_id", state.targetChatID), zap.String("chat_id", chatID)) + return + } + + rawReply := message.ToModel() + if !state.isUserAllowed(message.From) { + m.publishResult(state, &model.ConfirmationResult{ + RequestID: state.request.RequestID, + Status: model.ConfirmationStatusRejected, + ParseError: "unauthorized_user", + RawReply: rawReply, + }) + m.sendNotice(ctx, state, rawReply, "Only approved users can confirm this payment.") + m.removeState(state.requestMessageID) + return + } + + money, reason, err := parseConfirmationReply(message.Text) + if err != nil { + m.mu.Lock() + state.clarified = true + m.mu.Unlock() + m.sendNotice(ctx, state, rawReply, clarificationMessage(reason)) + return + } + + m.mu.Lock() + clarified := state.clarified + m.mu.Unlock() + status := model.ConfirmationStatusConfirmed + if clarified { + status = model.ConfirmationStatusClarified + } + m.publishResult(state, &model.ConfirmationResult{ + RequestID: state.request.RequestID, + Money: money, + RawReply: rawReply, + Status: status, + }) + m.removeState(state.requestMessageID) +} + +func (m *confirmationManager) lookupByMessageID(messageID string) *confirmationState { + m.mu.Lock() + defer m.mu.Unlock() + return m.pendingByMessage[strings.TrimSpace(messageID)] +} + +func (m *confirmationManager) handleTimeout(messageID string) { + state := m.lookupByMessageID(messageID) + if state == nil { + return + } + m.publishResult(state, &model.ConfirmationResult{ + RequestID: state.request.RequestID, + Status: model.ConfirmationStatusTimeout, + }) + m.removeState(messageID) +} + +func (m *confirmationManager) removeState(messageID string) { + messageID = strings.TrimSpace(messageID) + if messageID == "" { + return + } + m.mu.Lock() + state := m.pendingByMessage[messageID] + if state != nil && state.timer != nil { + state.timer.Stop() + } + delete(m.pendingByMessage, messageID) + if state != nil { + delete(m.pendingByRequest, state.request.RequestID) + } + m.mu.Unlock() +} + +func (m *confirmationManager) publishResult(state *confirmationState, result *model.ConfirmationResult) { + if m == nil || state == nil || result == nil { + return + } + if m.outbox == nil { + m.logger.Warn("Confirmation result skipped: producer not configured", zap.String("request_id", state.request.RequestID)) + return + } + env := confirmations.ConfirmationResult(m.sender, result, state.request.SourceService, state.request.Rail) + if err := m.outbox.SendMessage(env); err != nil { + m.logger.Warn("Failed to publish confirmation result", zap.Error(err), zap.String("request_id", state.request.RequestID)) + return + } + m.logger.Info("Confirmation result published", zap.String("request_id", state.request.RequestID), zap.String("status", string(result.Status))) +} + +func (m *confirmationManager) sendNotice(ctx context.Context, state *confirmationState, reply *model.TelegramMessage, text string) { + if m == nil || m.tg == nil || state == nil { + return + } + replyID := "" + if reply != nil { + replyID = reply.MessageID + } + if _, err := m.tg.SendText(ctx, state.targetChatID, text, replyID); err != nil { + m.logger.Warn("Failed to send clarification notice", zap.Error(err), zap.String("request_id", state.request.RequestID)) + } +} + +func (s *confirmationState) isUserAllowed(user *telegram.User) bool { + if s == nil { + return false + } + allowed := s.request.AcceptedUserIDs + if len(allowed) == 0 { + return true + } + if user == nil { + return false + } + userID := strconv.FormatInt(user.ID, 10) + for _, id := range allowed { + if id == userID { + return true + } + } + return false +} + +func confirmationCallbackSubject(sourceService, rail string) string { + sourceService = strings.ToLower(strings.TrimSpace(sourceService)) + if sourceService == "" { + sourceService = "unknown" + } + rail = strings.ToLower(strings.TrimSpace(rail)) + if rail == "" { + rail = "default" + } + return "confirmations." + sourceService + "." + rail +} + +func normalizeConfirmationRequest(request model.ConfirmationRequest) model.ConfirmationRequest { + request.RequestID = strings.TrimSpace(request.RequestID) + request.TargetChatID = strings.TrimSpace(request.TargetChatID) + request.PaymentIntentID = strings.TrimSpace(request.PaymentIntentID) + request.QuoteRef = strings.TrimSpace(request.QuoteRef) + request.SourceService = strings.TrimSpace(request.SourceService) + request.Rail = strings.TrimSpace(request.Rail) + request.AcceptedUserIDs = normalizeStringList(request.AcceptedUserIDs) + if request.RequestedMoney != nil { + request.RequestedMoney.Amount = strings.TrimSpace(request.RequestedMoney.Amount) + request.RequestedMoney.Currency = strings.TrimSpace(request.RequestedMoney.Currency) + } + return request +} + +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 +} + +var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`) +var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`) + +func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, "empty", errors.New("empty reply") + } + parts := strings.Fields(text) + if len(parts) < 2 { + if len(parts) == 1 && amountPattern.MatchString(parts[0]) { + return nil, "missing_currency", errors.New("currency is required") + } + return nil, "missing_amount", errors.New("amount is required") + } + if len(parts) > 2 { + return nil, "format", errors.New("reply format is invalid") + } + amount := parts[0] + currency := parts[1] + if !amountPattern.MatchString(amount) { + return nil, "invalid_amount", errors.New("amount format is invalid") + } + if !currencyPattern.MatchString(currency) { + return nil, "invalid_currency", errors.New("currency format is invalid") + } + return &paymenttypes.Money{ + Amount: amount, + Currency: strings.ToUpper(currency), + }, "", nil +} + +func confirmationPrompt(req *model.ConfirmationRequest) string { + var builder strings.Builder + builder.WriteString("Payment confirmation required\n") + if req.PaymentIntentID != "" { + builder.WriteString("Payment intent: ") + builder.WriteString(req.PaymentIntentID) + builder.WriteString("\n") + } + if req.QuoteRef != "" { + builder.WriteString("Quote ref: ") + builder.WriteString(req.QuoteRef) + builder.WriteString("\n") + } + if req.RequestedMoney != nil { + builder.WriteString("Requested: ") + builder.WriteString(req.RequestedMoney.Amount) + builder.WriteString(" ") + builder.WriteString(req.RequestedMoney.Currency) + builder.WriteString("\n") + } + builder.WriteString("Reply with \" \" (e.g., 12.34 USD).") + return builder.String() +} + +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)." + } +} diff --git a/api/notification/internal/server/notificationimp/notification.go b/api/notification/internal/server/notificationimp/notification.go index 5c84c45..03b09d7 100644 --- a/api/notification/internal/server/notificationimp/notification.go +++ b/api/notification/internal/server/notificationimp/notification.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/domainprovider" "github.com/tech/sendico/pkg/merrors" na "github.com/tech/sendico/pkg/messaging/notifications/account" + confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation" ni "github.com/tech/sendico/pkg/messaging/notifications/invitation" snotifications "github.com/tech/sendico/pkg/messaging/notifications/site" @@ -26,6 +27,7 @@ type NotificationAPI struct { dp domainprovider.DomainProvider tg telegram.Client announcer *discovery.Announcer + confirm *confirmationManager } func (a *NotificationAPI) Name() mservice.Type { @@ -36,6 +38,9 @@ func (a *NotificationAPI) Finish(_ context.Context) error { if a.announcer != nil { a.announcer.Stop() } + if a.confirm != nil { + a.confirm.Stop() + } return nil } @@ -61,6 +66,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { p.logger.Error("Failed to create telegram client", zap.Error(err)) return nil, err } + p.confirm = newConfirmationManager(p.logger, p.tg, a.Register().Producer()) db, err := a.DBFactory().NewAccountDB() if err != nil { @@ -81,6 +87,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { p.logger.Error("Failed to create confirmation code handler", zap.Error(err)) return nil, err } + if err := a.Register().Consumer(confirmations.NewConfirmationRequestProcessor(p.logger, p.onConfirmationRequest)); err != nil { + p.logger.Error("Failed to register confirmation request handler", zap.Error(err)) + return nil, err + } idb, err := a.DBFactory().NewInvitationsDB() if err != nil { @@ -97,6 +107,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { return nil, err } + if router := a.Router(); router != nil { + router.Post("/telegram/webhook", p.handleTelegramWebhook) + } + announce := discovery.Announcement{ Service: "NOTIFICATIONS", Operations: []string{"notify.send"}, @@ -143,3 +157,10 @@ func (a *NotificationAPI) onCallRequest(ctx context.Context, request *model.Call a.logger.Info("Call request sent via Telegram", zap.String("phone", request.Phone)) return nil } + +func (a *NotificationAPI) onConfirmationRequest(ctx context.Context, request *model.ConfirmationRequest) error { + if a.confirm == nil { + return merrors.Internal("confirmation manager is not configured") + } + return a.confirm.HandleRequest(ctx, request) +} diff --git a/api/notification/internal/server/notificationimp/telegram/client.go b/api/notification/internal/server/notificationimp/telegram/client.go index aabc0eb..43a2ef0 100644 --- a/api/notification/internal/server/notificationimp/telegram/client.go +++ b/api/notification/internal/server/notificationimp/telegram/client.go @@ -25,6 +25,7 @@ type Client interface { SendDemoRequest(ctx context.Context, request *model.DemoRequest) error SendContactRequest(ctx context.Context, request *model.ContactRequest) error SendCallRequest(ctx context.Context, request *model.CallRequest) error + SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error) } type client struct { @@ -38,13 +39,14 @@ type client struct { } type sendMessagePayload struct { - ChatID string `json:"chat_id"` - Text string `json:"text"` - ParseMode string `json:"parse_mode,omitempty"` - ThreadID *int64 `json:"message_thread_id,omitempty"` - DisablePreview bool `json:"disable_web_page_preview,omitempty"` - DisableNotify bool `json:"disable_notification,omitempty"` - ProtectContent bool `json:"protect_content,omitempty"` + ChatID string `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode,omitempty"` + ThreadID *int64 `json:"message_thread_id,omitempty"` + ReplyToMessageID *int64 `json:"reply_to_message_id,omitempty"` + DisablePreview bool `json:"disable_web_page_preview,omitempty"` + DisableNotify bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` } func NewClient(logger mlogger.Logger, cfg *notconfig.TelegramConfig) (Client, error) { @@ -106,16 +108,40 @@ func (c *client) SendDemoRequest(ctx context.Context, request *model.DemoRequest return c.sendForm(ctx, newDemoRequestTemplate(request)) } -func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) error { +type sendMessageResponse struct { + OK bool `json:"ok"` + Result *messageResponse `json:"result,omitempty"` + Description string `json:"description,omitempty"` +} + +type messageResponse struct { + MessageID int64 `json:"message_id"` + Date int64 `json:"date"` + Chat messageChat `json:"chat"` + From *messageUser `json:"from,omitempty"` + Text string `json:"text"` + ReplyToMessage *messageResponse `json:"reply_to_message,omitempty"` +} + +type messageChat struct { + ID int64 `json:"id"` +} + +type messageUser struct { + ID int64 `json:"id"` + Username string `json:"username,omitempty"` +} + +func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) (*model.TelegramMessage, error) { body, err := json.Marshal(&payload) if err != nil { c.logger.Warn("Failed to marshal telegram payload", zap.Error(err)) - return err + return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body)) if err != nil { c.logger.Warn("Failed to create telegram request", zap.Error(err)) - return err + return nil, err } req.Header.Set("Content-Type", "application/json") @@ -129,26 +155,41 @@ func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) er if payload.ThreadID != nil { fields = append(fields, zap.Int64("thread_id", *payload.ThreadID)) } + if payload.ReplyToMessageID != nil { + fields = append(fields, zap.Int64("reply_to_message_id", *payload.ReplyToMessageID)) + } c.logger.Debug("Sending Telegram message", fields...) start := time.Now() resp, err := c.httpClient.Do(req) if err != nil { c.logger.Warn("Telegram request failed", zap.Error(err)) - return err + return nil, err } defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10)) if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + var parsed sendMessageResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + c.logger.Warn("Failed to decode telegram response", zap.Error(err)) + return nil, err + } + if !parsed.OK || parsed.Result == nil { + msg := "telegram sendMessage response missing result" + if parsed.Description != "" { + msg = parsed.Description + } + return nil, merrors.Internal(msg) + } c.logger.Debug("Telegram message sent", zap.Int("status_code", resp.StatusCode), zap.Duration("latency", time.Since(start))) - return nil + return toTelegramMessage(parsed.Result), nil } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) c.logger.Warn("Telegram API returned non-success status", zap.Int("status_code", resp.StatusCode), zap.ByteString("response_body", respBody), zap.String("chat_id", c.chatID)) - return merrors.Internal(fmt.Sprintf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody))) + return nil, merrors.Internal(fmt.Sprintf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody))) } func (c *client) endpoint() string { @@ -178,5 +219,51 @@ func (c *client) sendForm(ctx context.Context, template messageTemplate) error { ThreadID: c.threadID, DisablePreview: true, } + _, err := c.sendMessage(ctx, payload) + return err +} + +func (c *client) SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error) { + chatID = strings.TrimSpace(chatID) + if chatID == "" { + chatID = c.chatID + } + if chatID == "" { + return nil, merrors.InvalidArgument("telegram chat id is empty", "chat_id") + } + payload := sendMessagePayload{ + ChatID: chatID, + Text: text, + ParseMode: c.parseMode.String(), + ThreadID: c.threadID, + DisablePreview: true, + } + if replyToMessageID != "" { + val, err := strconv.ParseInt(replyToMessageID, 10, 64) + if err != nil { + return nil, merrors.InvalidArgumentWrap(err, "invalid reply_to_message_id", "reply_to_message_id") + } + payload.ReplyToMessageID = &val + } return c.sendMessage(ctx, payload) } + +func toTelegramMessage(msg *messageResponse) *model.TelegramMessage { + if msg == nil { + return nil + } + result := &model.TelegramMessage{ + ChatID: strconv.FormatInt(msg.Chat.ID, 10), + MessageID: strconv.FormatInt(msg.MessageID, 10), + Text: msg.Text, + SentAt: msg.Date, + } + if msg.From != nil { + result.FromUserID = strconv.FormatInt(msg.From.ID, 10) + result.FromUsername = msg.From.Username + } + if msg.ReplyToMessage != nil { + result.ReplyToMessageID = strconv.FormatInt(msg.ReplyToMessage.MessageID, 10) + } + return result +} diff --git a/api/notification/internal/server/notificationimp/telegram/update.go b/api/notification/internal/server/notificationimp/telegram/update.go new file mode 100644 index 0000000..e187f1f --- /dev/null +++ b/api/notification/internal/server/notificationimp/telegram/update.go @@ -0,0 +1,50 @@ +package telegram + +import ( + "strconv" + + "github.com/tech/sendico/pkg/model" +) + +type Update struct { + UpdateID int64 `json:"update_id"` + Message *Message `json:"message,omitempty"` +} + +type Message struct { + MessageID int64 `json:"message_id"` + Date int64 `json:"date,omitempty"` + Chat Chat `json:"chat"` + From *User `json:"from,omitempty"` + Text string `json:"text,omitempty"` + ReplyToMessage *Message `json:"reply_to_message,omitempty"` +} + +type Chat struct { + ID int64 `json:"id"` +} + +type User struct { + ID int64 `json:"id"` + Username string `json:"username,omitempty"` +} + +func (m *Message) ToModel() *model.TelegramMessage { + if m == nil { + return nil + } + result := &model.TelegramMessage{ + ChatID: strconv.FormatInt(m.Chat.ID, 10), + MessageID: strconv.FormatInt(m.MessageID, 10), + Text: m.Text, + SentAt: m.Date, + } + if m.From != nil { + result.FromUserID = strconv.FormatInt(m.From.ID, 10) + result.FromUsername = m.From.Username + } + if m.ReplyToMessage != nil { + result.ReplyToMessageID = strconv.FormatInt(m.ReplyToMessage.MessageID, 10) + } + return result +} diff --git a/api/notification/internal/server/notificationimp/webhook.go b/api/notification/internal/server/notificationimp/webhook.go new file mode 100644 index 0000000..1c26575 --- /dev/null +++ b/api/notification/internal/server/notificationimp/webhook.go @@ -0,0 +1,30 @@ +package notificationimp + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/tech/sendico/notification/internal/server/notificationimp/telegram" + "go.uber.org/zap" +) + +const telegramWebhookMaxBody = 1 << 20 + +func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.Request) { + if a == nil || a.confirm == nil { + w.WriteHeader(http.StatusNoContent) + return + } + var update telegram.Update + dec := json.NewDecoder(io.LimitReader(r.Body, telegramWebhookMaxBody)) + if err := dec.Decode(&update); err != nil { + if a.logger != nil { + a.logger.Warn("Failed to decode telegram webhook update", zap.Error(err)) + } + w.WriteHeader(http.StatusBadRequest) + return + } + a.confirm.HandleUpdate(r.Context(), &update) + w.WriteHeader(http.StatusOK) +} diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 81f3f3b..1bf7888 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -6,8 +6,10 @@ import ( mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo" "github.com/tech/sendico/pkg/db" 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" ) func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { @@ -20,6 +22,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { func (i *Imp) Shutdown() { i.stopDiscovery() + if i.service != nil { + i.service.Shutdown() + } i.shutdownApp() i.closeClients() } @@ -37,11 +42,24 @@ func (i *Imp) Start() error { return mongostorage.New(logger, conn) } + 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)) + } + } + deps := i.initDependencies(cfg) serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { opts := i.buildServiceOptions(cfg, deps) - return orchestrator.NewService(logger, repo, opts...), nil + if broker != nil { + opts = append(opts, orchestrator.WithPaymentGatewayBroker(broker)) + } + svc := orchestrator.NewService(logger, repo, opts...) + i.service = svc + return svc, nil } app, err := grpcapp.NewApp(i.logger, "payments_orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory) diff --git a/api/payments/orchestrator/internal/server/internal/types.go b/api/payments/orchestrator/internal/server/internal/types.go index 10b3a38..612a59b 100644 --- a/api/payments/orchestrator/internal/server/internal/types.go +++ b/api/payments/orchestrator/internal/server/internal/types.go @@ -5,6 +5,7 @@ import ( chainclient "github.com/tech/sendico/gateway/chain/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/mlogger" @@ -22,6 +23,7 @@ type Imp struct { discoveryWatcher *discovery.RegistryWatcher discoveryReg *discovery.Registry discoveryAnnouncer *discovery.Announcer + service *orchestrator.Service feesConn *grpc.ClientConn ledgerClient ledgerclient.Client gatewayClient chainclient.Client diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index 66bc036..a3ed506 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -22,15 +22,16 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent { return model.PaymentIntent{} } intent := model.PaymentIntent{ - Kind: modelKindFromProto(src.GetKind()), - Source: endpointFromProto(src.GetSource()), - Destination: endpointFromProto(src.GetDestination()), - Amount: moneyFromProto(src.GetAmount()), - RequiresFX: src.GetRequiresFx(), - FeePolicy: feePolicyFromProto(src.GetFeePolicy()), - SettlementMode: settlementModeFromProto(src.GetSettlementMode()), - Attributes: cloneMetadata(src.GetAttributes()), - Customer: customerFromProto(src.GetCustomer()), + Kind: modelKindFromProto(src.GetKind()), + Source: endpointFromProto(src.GetSource()), + Destination: endpointFromProto(src.GetDestination()), + Amount: moneyFromProto(src.GetAmount()), + RequiresFX: src.GetRequiresFx(), + FeePolicy: feePolicyFromProto(src.GetFeePolicy()), + SettlementMode: settlementModeFromProto(src.GetSettlementMode()), + SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()), + Attributes: cloneMetadata(src.GetAttributes()), + Customer: customerFromProto(src.GetCustomer()), } if src.GetFx() != nil { intent.FX = fxIntentFromProto(src.GetFx()) @@ -43,8 +44,9 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin return model.PaymentEndpoint{Type: model.EndpointTypeUnspecified} } result := model.PaymentEndpoint{ - Type: model.EndpointTypeUnspecified, - Metadata: cloneMetadata(src.GetMetadata()), + Type: model.EndpointTypeUnspecified, + InstanceID: strings.TrimSpace(src.GetInstanceId()), + Metadata: cloneMetadata(src.GetMetadata()), } if ledger := src.GetLedger(); ledger != nil { result.Type = model.EndpointTypeLedger @@ -160,15 +162,16 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment { func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent { intent := &orchestratorv1.PaymentIntent{ - Kind: protoKindFromModel(src.Kind), - Source: protoEndpointFromModel(src.Source), - Destination: protoEndpointFromModel(src.Destination), - Amount: protoMoney(src.Amount), - RequiresFx: src.RequiresFX, - FeePolicy: feePolicyToProto(src.FeePolicy), - SettlementMode: settlementModeToProto(src.SettlementMode), - Attributes: cloneMetadata(src.Attributes), - Customer: protoCustomerFromModel(src.Customer), + Kind: protoKindFromModel(src.Kind), + Source: protoEndpointFromModel(src.Source), + Destination: protoEndpointFromModel(src.Destination), + Amount: protoMoney(src.Amount), + RequiresFx: src.RequiresFX, + FeePolicy: feePolicyToProto(src.FeePolicy), + SettlementMode: settlementModeToProto(src.SettlementMode), + SettlementCurrency: strings.TrimSpace(src.SettlementCurrency), + Attributes: cloneMetadata(src.Attributes), + Customer: protoCustomerFromModel(src.Customer), } if src.FX != nil { intent.Fx = protoFXIntentFromModel(src.FX) @@ -214,7 +217,8 @@ func protoCustomerFromModel(src *model.Customer) *orchestratorv1.Customer { func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint { endpoint := &orchestratorv1.PaymentEndpoint{ - Metadata: cloneMetadata(src.Metadata), + Metadata: cloneMetadata(src.Metadata), + InstanceId: strings.TrimSpace(src.InstanceID), } switch src.Type { case model.EndpointTypeLedger: @@ -337,11 +341,16 @@ func protoPaymentStepFromModel(src *model.PaymentStep) *orchestratorv1.PaymentSt return nil } return &orchestratorv1.PaymentStep{ - Rail: protoRailFromModel(src.Rail), - GatewayId: strings.TrimSpace(src.GatewayID), - Action: protoRailOperationFromModel(src.Action), - Amount: protoMoney(src.Amount), - Ref: strings.TrimSpace(src.Ref), + Rail: protoRailFromModel(src.Rail), + GatewayId: strings.TrimSpace(src.GatewayID), + Action: protoRailOperationFromModel(src.Action), + Amount: protoMoney(src.Amount), + Ref: strings.TrimSpace(src.Ref), + StepId: strings.TrimSpace(src.StepID), + InstanceId: strings.TrimSpace(src.InstanceID), + DependsOn: cloneStringList(src.DependsOn), + CommitPolicy: strings.TrimSpace(string(src.CommitPolicy)), + CommitAfter: cloneStringList(src.CommitAfter), } } @@ -362,6 +371,8 @@ func protoPaymentPlanFromModel(src *model.PaymentPlan) *orchestratorv1.PaymentPl Id: strings.TrimSpace(src.ID), Steps: steps, IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), + FxQuote: fxQuoteToProto(src.FXQuote), + Fees: feeLinesToProto(src.Fees), } if !src.CreatedAt.IsZero() { plan.CreatedAt = timestamppb.New(src.CreatedAt.UTC()) diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go new file mode 100644 index 0000000..95707b0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go @@ -0,0 +1,101 @@ +package orchestrator + +import ( + "context" + "strings" + + paymodel "github.com/tech/sendico/payments/orchestrator/storage/model" + cons "github.com/tech/sendico/pkg/messaging/consumer" + paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "go.uber.org/zap" +) + +func (s *Service) startGatewayConsumers() { + if s == nil || s.gatewayBroker == nil { + return + } + processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution) + s.consumeGatewayProcessor(processor) +} + +func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) { + consumer, err := cons.NewConsumer(s.logger, s.gatewayBroker, processor.GetSubject()) + if err != nil { + s.logger.Warn("Failed to create payment gateway consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + return + } + s.gatewayConsumers = append(s.gatewayConsumers, consumer) + go func() { + if err := consumer.ConsumeMessages(processor.Process); err != nil { + s.logger.Warn("Payment gateway consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) + } + }() +} + +func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error { + if exec == nil { + return merrors.InvalidArgument("payment gateway execution is nil", "execution") + } + paymentRef := strings.TrimSpace(exec.PaymentIntentID) + if paymentRef == "" { + return merrors.InvalidArgument("payment_intent_id is required", "payment_intent_id") + } + if s.storage == nil || s.storage.Payments() == nil { + return errStorageUnavailable + } + payment, err := s.storage.Payments().GetByPaymentRef(ctx, paymentRef) + if err != nil { + return err + } + if payment.Metadata == nil { + payment.Metadata = map[string]string{} + } + if exec.RequestID != "" { + payment.Metadata["gateway_request_id"] = exec.RequestID + } + if exec.QuoteRef != "" { + payment.Metadata["gateway_quote_ref"] = exec.QuoteRef + } + if exec.ExecutedMoney != nil { + payment.Metadata["gateway_executed_amount"] = exec.ExecutedMoney.Amount + payment.Metadata["gateway_executed_currency"] = exec.ExecutedMoney.Currency + } + payment.Metadata["gateway_confirmation_status"] = string(exec.Status) + + switch exec.Status { + case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified: + payment.State = paymodel.PaymentStateSettled + payment.FailureCode = paymodel.PaymentFailureCodeUnspecified + payment.FailureReason = "" + case model.ConfirmationStatusRejected: + payment.State = paymodel.PaymentStateFailed + payment.FailureCode = paymodel.PaymentFailureCodePolicy + payment.FailureReason = "gateway_rejected" + case model.ConfirmationStatusTimeout: + payment.State = paymodel.PaymentStateFailed + payment.FailureCode = paymodel.PaymentFailureCodePolicy + payment.FailureReason = "confirmation_timeout" + default: + s.logger.Warn("Unhandled gateway confirmation status", zap.String("status", string(exec.Status)), zap.String("payment_ref", paymentRef)) + } + if err := s.storage.Payments().Update(ctx, payment); err != nil { + return err + } + s.logger.Info("Payment gateway execution applied", zap.String("payment_ref", paymentRef), zap.String("status", string(exec.Status)), zap.String("service", string(mservice.PaymentGateway))) + return nil +} + +func (s *Service) Shutdown() { + if s == nil { + return + } + for _, consumer := range s.gatewayConsumers { + if consumer != nil { + consumer.Close() + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go new file mode 100644 index 0000000..f1eebe5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go @@ -0,0 +1,69 @@ +package orchestrator + +import ( + "context" + "testing" + + paymodel "github.com/tech/sendico/payments/orchestrator/storage/model" + mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" + "github.com/tech/sendico/pkg/model" +) + +func TestGatewayExecutionConfirmedUpdatesPayment(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + store := newHelperPaymentStore() + payment := &paymodel.Payment{PaymentRef: "pi-1", State: paymodel.PaymentStateSubmitted} + if err := store.Create(context.Background(), payment); err != nil { + t.Fatalf("failed to seed payment: %v", err) + } + svc := &Service{ + logger: logger, + storage: stubRepo{payments: store}, + } + exec := &model.PaymentGatewayExecution{ + PaymentIntentID: "pi-1", + Status: model.ConfirmationStatusConfirmed, + RequestID: "req-1", + QuoteRef: "quote-1", + } + if err := svc.onGatewayExecution(context.Background(), exec); err != nil { + t.Fatalf("onGatewayExecution error: %v", err) + } + updated, _ := store.GetByPaymentRef(context.Background(), "pi-1") + if updated.State != paymodel.PaymentStateSettled { + t.Fatalf("expected payment settled, got %s", updated.State) + } + if updated.Metadata["gateway_request_id"] != "req-1" { + t.Fatalf("expected gateway_request_id metadata") + } + if updated.Metadata["gateway_confirmation_status"] != string(model.ConfirmationStatusConfirmed) { + t.Fatalf("expected gateway_confirmation_status metadata") + } +} + +func TestGatewayExecutionRejectedFailsPayment(t *testing.T) { + logger := mloggerfactory.NewLogger(false) + store := newHelperPaymentStore() + payment := &paymodel.Payment{PaymentRef: "pi-2", State: paymodel.PaymentStateSubmitted} + if err := store.Create(context.Background(), payment); err != nil { + t.Fatalf("failed to seed payment: %v", err) + } + svc := &Service{ + logger: logger, + storage: stubRepo{payments: store}, + } + exec := &model.PaymentGatewayExecution{ + PaymentIntentID: "pi-2", + Status: model.ConfirmationStatusRejected, + } + if err := svc.onGatewayExecution(context.Background(), exec); err != nil { + t.Fatalf("onGatewayExecution error: %v", err) + } + updated, _ := store.GetByPaymentRef(context.Background(), "pi-2") + if updated.State != paymodel.PaymentStateFailed { + t.Fatalf("expected payment failed, got %s", updated.State) + } + if updated.FailureReason != "gateway_rejected" { + t.Fatalf("expected failure reason gateway_rejected, got %q", updated.FailureReason) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go index a1941e9..80ab211 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -465,13 +465,14 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat } intentProto := &orchestratorv1.PaymentIntent{ - Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, - Source: req.GetSource(), - Destination: req.GetDestination(), - Amount: amount, - RequiresFx: true, - Fx: fxIntent, - FeePolicy: req.GetFeePolicy(), + Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, + Source: req.GetSource(), + Destination: req.GetDestination(), + Amount: amount, + RequiresFx: true, + Fx: fxIntent, + FeePolicy: req.GetFeePolicy(), + SettlementCurrency: strings.TrimSpace(amount.GetCurrency()), } quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{ diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go index 1b51a02..acc23da 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go @@ -55,14 +55,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or } if payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != len(payment.PaymentPlan.Steps) { - intent := payment.Intent - splitIdx := len(payment.PaymentPlan.Steps) - sourceRail, _, srcErr := railFromEndpoint(intent.Source, intent.Attributes, true) - destRail, _, dstErr := railFromEndpoint(intent.Destination, intent.Attributes, false) - if srcErr == nil && dstErr == nil { - splitIdx = planSplitIndex(payment.PaymentPlan, sourceRail, destRail) - } - ensureExecutionPlanForPlan(payment, payment.PaymentPlan, splitIdx) + ensureExecutionPlanForPlan(payment, payment.PaymentPlan) } updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent()) if payment.Execution == nil { @@ -139,7 +132,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or payment.FailureReason = reason case chainv1.TransferStatus_TRANSFER_CONFIRMED: if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { - if sourceStepsConfirmed(payment.ExecutionPlan) { + if cardPayoutDependenciesConfirmed(payment.PaymentPlan, payment.ExecutionPlan) { if payment.Execution.CardPayoutRef != "" { payment.State = model.PaymentStateSubmitted } else { diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go index df242d6..8f94acc 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/helpers.go @@ -46,6 +46,24 @@ func cloneMetadata(input map[string]string) map[string]string { return clone } +func cloneStringList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.TrimSpace(value) + if clean == "" { + continue + } + result = append(result, clean) + } + if len(result) == 0 { + return nil + } + return result +} + func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine { if len(lines) == 0 { return nil diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go index 26a819a..519b2fd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go @@ -2,12 +2,14 @@ package orchestrator import ( "context" + "strings" "time" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/mservice" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -73,10 +75,37 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool { if intent == nil { return false } - if intent.GetRequiresFx() { + if fxIntentForQuote(intent) != nil { return true } - return intent.GetFx() != nil && intent.GetFx().GetPair() != nil + return intent.GetRequiresFx() +} + +func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIntent { + if intent == nil { + return nil + } + if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil { + return fx + } + amount := intent.GetAmount() + if amount == nil { + return nil + } + settlementCurrency := strings.TrimSpace(intent.GetSettlementCurrency()) + if settlementCurrency == "" { + return nil + } + if strings.EqualFold(amount.GetCurrency(), settlementCurrency) { + return nil + } + return &orchestratorv1.FXIntent{ + Pair: &fxv1.CurrencyPair{ + Base: strings.TrimSpace(amount.GetCurrency()), + Quote: settlementCurrency, + }, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + } } func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState { diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index fb46a1b..ebebb32 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -13,6 +13,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" "github.com/tech/sendico/pkg/merrors" + mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" ) @@ -153,6 +154,14 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option } } +func WithPaymentGatewayBroker(broker mb.Broker) Option { + return func(s *Service) { + if broker != nil { + s.gatewayBroker = broker + } + } +} + // WithLedgerClient wires the ledger client. func WithLedgerClient(client ledgerclient.Client) Option { return func(s *Service) { diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go index 104e96b..ff82156 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go @@ -39,11 +39,15 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym if routeStore == nil { return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable) } + planTemplates := p.svc.storage.PlanTemplates() + if planTemplates == nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "plan_templates_store_unavailable", errStorageUnavailable) + } builder := p.svc.deps.planBuilder if builder == nil { builder = &defaultPlanBuilder{} } - plan, err := builder.Build(ctx, payment, quote, routeStore, p.svc.deps.gatewayRegistry) + plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry) if err != nil { return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go index 8fe66d6..f363996 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go @@ -22,28 +22,30 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage. return merrors.InvalidArgument("payment plan: steps are required") } - intent := payment.Intent - sourceRail, _, err := railFromEndpoint(intent.Source, intent.Attributes, true) - if err != nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) - } - destRail, _, err := railFromEndpoint(intent.Destination, intent.Attributes, false) - if err != nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) - } - execQuote := executionQuote(payment, quote) charges := ledgerChargesFromFeeLines(execQuote.GetFeeLines()) - splitIdx := planSplitIndex(plan, sourceRail, destRail) - execPlan := ensureExecutionPlanForPlan(payment, plan, splitIdx) + order, _, err := planExecutionOrder(plan) + if err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) + } + + execPlan := ensureExecutionPlanForPlan(payment, plan) + execSteps := executionStepsByCode(execPlan) + planSteps := planStepsByID(plan) asyncSubmitted := false - for idx, step := range plan.Steps { + for _, idx := range order { + step := plan.Steps[idx] if step == nil { return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment plan: step is required", merrors.InvalidArgument("payment plan: step is required")) } - execStep := execPlan.Steps[idx] + stepID := planStepID(step, idx) + execStep := execSteps[stepID] + if execStep == nil { + execStep = &model.ExecutionStep{Code: stepID} + execSteps[stepID] = execStep + } status := executionStepStatus(execStep) switch status { case executionStepStatusConfirmed, executionStepStatusSkipped: @@ -58,17 +60,21 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage. return p.persistPayment(ctx, store, payment) case executionStepStatusSubmitted: asyncSubmitted = true - if isConsumerExecutionStep(execStep) || step.Action == model.RailOperationObserveConfirm { - payment.State = model.PaymentStateSubmitted - return p.persistPayment(ctx, store, payment) - } continue } - if isConsumerExecutionStep(execStep) && !sourceStepsConfirmed(execPlan) { - payment.State = model.PaymentStateSubmitted + ready, blocked, err := stepDependenciesReady(step, execSteps, planSteps, false) + if err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) + } + if blocked { + payment.State = model.PaymentStateFailed + payment.FailureCode = failureCodeForStep(step) return p.persistPayment(ctx, store, payment) } + if !ready { + continue + } async, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, charges, idx) if err != nil { @@ -76,10 +82,6 @@ func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage. } if async { asyncSubmitted = true - if isConsumerExecutionStep(execStep) || step.Action == model.RailOperationObserveConfirm { - payment.State = model.PaymentStateSubmitted - return p.persistPayment(ctx, store, payment) - } } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go index 1b732d8..c814edd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go @@ -121,12 +121,12 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { ID: "pay-plan-1", IdempotencyKey: "pay-plan-1", Steps: []*model.PaymentStep{ - {Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}}, - {Rail: model.RailCrypto, Action: model.RailOperationFee, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}}, - {Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm}, - {Rail: model.RailLedger, Action: model.RailOperationCredit, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, - {Rail: model.RailLedger, Action: model.RailOperationDebit, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, - {Rail: model.RailCardPayout, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, + {StepID: "crypto_send", Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}}, + {StepID: "crypto_fee", Rail: model.RailCrypto, Action: model.RailOperationFee, DependsOn: []string{"crypto_send"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}}, + {StepID: "crypto_observe", Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm, DependsOn: []string{"crypto_send"}}, + {StepID: "ledger_credit", Rail: model.RailLedger, Action: model.RailOperationCredit, DependsOn: []string{"crypto_observe"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, + {StepID: "card_payout", Rail: model.RailCardPayout, Action: model.RailOperationSend, DependsOn: []string{"ledger_credit"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, + {StepID: "ledger_debit", Rail: model.RailLedger, Action: model.RailOperationDebit, DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, }, }, } @@ -172,8 +172,8 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil { t.Fatalf("executePaymentPlan resume error: %v", err) } - if debitCalls != 1 || creditCalls != 1 { - t.Fatalf("expected ledger calls after source confirmation, debit=%d credit=%d", debitCalls, creditCalls) + if debitCalls != 0 || creditCalls != 1 { + t.Fatalf("expected ledger credit after source confirmation, debit=%d credit=%d", debitCalls, creditCalls) } if payoutCalls != 1 { t.Fatalf("expected card payout submitted, got %d", payoutCalls) @@ -181,4 +181,18 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { if payment.Execution == nil || payment.Execution.CardPayoutRef == "" { t.Fatalf("expected card payout ref set") } + + steps := executionStepsByCode(payment.ExecutionPlan) + cardStep := steps["card_payout"] + if cardStep == nil { + t.Fatalf("expected card payout step in execution plan") + } + setExecutionStepStatus(cardStep, executionStepStatusConfirmed) + + if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil { + t.Fatalf("executePaymentPlan finalize error: %v", err) + } + if debitCalls != 1 || creditCalls != 1 { + t.Fatalf("expected ledger debit after payout confirmation, debit=%d credit=%d", debitCalls, creditCalls) + } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go index 3e5c1d3..0565f4a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go @@ -25,41 +25,7 @@ func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) return &orchestratorv1.PaymentQuote{} } -func planSplitIndex(plan *model.PaymentPlan, sourceRail, destRail model.Rail) int { - if plan == nil { - return 0 - } - if sourceRail == model.RailLedger { - for idx, step := range plan.Steps { - if step == nil { - continue - } - if step.Rail != model.RailLedger { - return idx - } - } - return len(plan.Steps) - } - for idx, step := range plan.Steps { - if step == nil { - continue - } - if step.Rail == model.RailLedger && step.Action == model.RailOperationCredit { - return idx - } - } - for idx, step := range plan.Steps { - if step == nil { - continue - } - if step.Rail == destRail && step.Action == model.RailOperationSend { - return idx - } - } - return len(plan.Steps) -} - -func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan, splitIdx int) *model.ExecutionPlan { +func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan { if payment == nil || plan == nil { return nil } @@ -77,7 +43,7 @@ func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan, } steps := make([]*model.ExecutionStep, len(plan.Steps)) for idx, planStep := range plan.Steps { - code := planStepCode(idx) + code := planStepID(planStep, idx) step := existing[code] if step == nil { step = &model.ExecutionStep{Code: code} @@ -86,11 +52,6 @@ func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan, step.Description = describePlanStep(planStep) } step.Amount = cloneMoney(planStep.Amount) - if idx < splitIdx { - setExecutionStepRole(step, executionStepRoleSource) - } else { - setExecutionStepRole(step, executionStepRoleConsumer) - } if step.Metadata == nil || strings.TrimSpace(step.Metadata[executionStepMetadataStatus]) == "" { setExecutionStepStatus(step, executionStepStatusPlanned) } @@ -119,7 +80,12 @@ func executionPlanComplete(plan *model.ExecutionPlan) bool { return true } -func planStepCode(idx int) string { +func planStepID(step *model.PaymentStep, idx int) string { + if step != nil { + if val := strings.TrimSpace(step.StepID); val != "" { + return val + } + } return fmt.Sprintf("plan_step_%d", idx) } @@ -144,7 +110,11 @@ func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.Payment if step == nil { return fmt.Sprintf("%s:plan:%d", base, idx) } - return fmt.Sprintf("%s:plan:%d:%s:%s", base, idx, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action))) + stepID := strings.TrimSpace(step.StepID) + if stepID == "" { + stepID = fmt.Sprintf("%d", idx) + } + return fmt.Sprintf("%s:plan:%s:%s:%s", base, stepID, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action))) } func failureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode { diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go new file mode 100644 index 0000000..d092033 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go @@ -0,0 +1,193 @@ +package orchestrator + +import ( + "sort" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func planExecutionOrder(plan *model.PaymentPlan) ([]int, map[string]int, error) { + if plan == nil || len(plan.Steps) == 0 { + return nil, nil, merrors.InvalidArgument("payment plan: steps are required") + } + idToIndex := map[string]int{} + for idx, step := range plan.Steps { + if step == nil { + return nil, nil, merrors.InvalidArgument("payment plan: step is required") + } + id := planStepID(step, idx) + if _, exists := idToIndex[id]; exists { + return nil, nil, merrors.InvalidArgument("payment plan: duplicate step id") + } + idToIndex[id] = idx + } + + indegree := make([]int, len(plan.Steps)) + adj := make([][]int, len(plan.Steps)) + for idx, step := range plan.Steps { + for _, dep := range step.DependsOn { + key := strings.TrimSpace(dep) + if key == "" { + continue + } + depIdx, ok := idToIndex[key] + if !ok { + return nil, nil, merrors.InvalidArgument("payment plan: dependency missing") + } + adj[depIdx] = append(adj[depIdx], idx) + indegree[idx]++ + } + } + + queue := make([]int, 0, len(plan.Steps)) + for idx := range indegree { + if indegree[idx] == 0 { + queue = append(queue, idx) + } + } + sort.Ints(queue) + + order := make([]int, 0, len(plan.Steps)) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + order = append(order, current) + for _, next := range adj[current] { + indegree[next]-- + if indegree[next] == 0 { + queue = append(queue, next) + } + } + sort.Ints(queue) + } + + if len(order) != len(plan.Steps) { + return nil, nil, merrors.InvalidArgument("payment plan: dependency cycle detected") + } + return order, idToIndex, nil +} + +func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep { + result := map[string]*model.ExecutionStep{} + if plan == nil { + return result + } + for _, step := range plan.Steps { + if step == nil { + continue + } + if code := strings.TrimSpace(step.Code); code != "" { + result[code] = step + } + } + return result +} + +func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep { + result := map[string]*model.PaymentStep{} + if plan == nil { + return result + } + for idx, step := range plan.Steps { + if step == nil { + continue + } + id := planStepID(step, idx) + if id == "" { + continue + } + result[id] = step + } + return result +} + +func stepDependenciesReady(step *model.PaymentStep, execSteps map[string]*model.ExecutionStep, planSteps map[string]*model.PaymentStep, requireConfirmed bool) (bool, bool, error) { + if step == nil { + return false, false, merrors.InvalidArgument("payment plan: step is required") + } + for _, dep := range step.DependsOn { + key := strings.TrimSpace(dep) + if key == "" { + continue + } + execStep := execSteps[key] + if execStep == nil { + return false, false, merrors.InvalidArgument("payment plan: dependency missing") + } + depStep := planSteps[key] + needsConfirm := requireConfirmed + if depStep != nil && depStep.Action == model.RailOperationObserveConfirm { + needsConfirm = true + } + status := executionStepStatus(execStep) + switch status { + case executionStepStatusFailed, executionStepStatusCancelled: + return false, true, nil + case executionStepStatusConfirmed, executionStepStatusSkipped: + continue + case executionStepStatusSubmitted: + if needsConfirm { + return false, false, nil + } + continue + default: + return false, false, nil + } + } + + if step.CommitPolicy != model.CommitPolicyAfterSuccess { + return true, false, nil + } + + commitAfter := step.CommitAfter + if len(commitAfter) == 0 { + commitAfter = step.DependsOn + } + for _, dep := range commitAfter { + key := strings.TrimSpace(dep) + if key == "" { + continue + } + execStep := execSteps[key] + if execStep == nil { + return false, false, merrors.InvalidArgument("payment plan: commit dependency missing") + } + status := executionStepStatus(execStep) + switch status { + case executionStepStatusFailed, executionStepStatusCancelled: + return false, true, nil + case executionStepStatusConfirmed, executionStepStatusSkipped: + continue + default: + return false, false, nil + } + } + return true, false, nil +} + +func cardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { + if execPlan == nil { + return false + } + if plan == nil || len(plan.Steps) == 0 { + return sourceStepsConfirmed(execPlan) + } + execSteps := executionStepsByCode(execPlan) + planSteps := planStepsByID(plan) + for _, step := range plan.Steps { + if step == nil { + continue + } + if step.Rail != model.RailCardPayout || step.Action != model.RailOperationSend { + continue + } + ready, blocked, err := stepDependenciesReady(step, execSteps, planSteps, true) + if err != nil || blocked { + return false + } + return ready + } + return false +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go index d256389..e5a1ade 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go @@ -12,6 +12,11 @@ type RouteStore interface { List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) } +// PlanTemplateStore exposes orchestration plan templates for plan construction. +type PlanTemplateStore interface { + List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) +} + // GatewayRegistry exposes gateway instances for capability-based selection. type GatewayRegistry interface { List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) @@ -19,5 +24,5 @@ type GatewayRegistry interface { // PlanBuilder constructs ordered payment plans from intents, quotes, and routing policy. type PlanBuilder interface { - Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, gateways GatewayRegistry) (*model.PaymentPlan, error) + Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go index 2fe9a56..63c5a90 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go @@ -10,13 +10,16 @@ import ( type defaultPlanBuilder struct{} -func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { +func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { if payment == nil { return nil, merrors.InvalidArgument("plan builder: payment is required") } if routes == nil { return nil, merrors.InvalidArgument("plan builder: routes store is required") } + if templates == nil { + return nil, merrors.InvalidArgument("plan builder: plan templates store is required") + } intent := payment.Intent if intent.Kind == model.PaymentKindFXConversion { @@ -42,10 +45,19 @@ func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment") } - path, err := buildRoutePath(ctx, routes, sourceRail, destRail, sourceNetwork, destNetwork) + network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork) if err != nil { return nil, err } - return b.buildPlanFromRoutePath(ctx, payment, quote, path, sourceRail, destRail, sourceNetwork, destNetwork, gateways) + if _, err := selectRoute(ctx, routes, sourceRail, destRail, network); err != nil { + return nil, err + } + + template, err := selectPlanTemplate(ctx, templates, sourceRail, destRail, network) + if err != nil { + return nil, err + } + + return b.buildPlanFromTemplate(ctx, payment, quote, template, sourceRail, destRail, sourceNetwork, destNetwork, gateways) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go index 44f6bcb..1639889 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "strings" "testing" "github.com/tech/sendico/payments/orchestrator/storage/model" @@ -33,7 +34,8 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { Type: model.EndpointTypeCard, Card: &model.CardEndpoint{MaskedPan: "4111"}, }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}, + SettlementCurrency: "USDT", }, LastQuote: &model.PaymentQuoteSnapshot{ ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, @@ -48,9 +50,26 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { routes := &stubRouteStore{ routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON", RequiresObserve: true, IsEnabled: true}, - {FromRail: model.RailProviderSettlement, ToRail: model.RailLedger, IsEnabled: true}, - {FromRail: model.RailLedger, ToRail: model.RailCardPayout, IsEnabled: true}, + {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true}, + }, + } + + templates := &stubPlanTemplateStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailCrypto, + ToRail: model.RailCardPayout, + Network: "TRON", + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, + {StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}}, + {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"crypto_observe"}}, + {StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}}, + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}}, + }, + }, }, } @@ -58,18 +77,21 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { items: []*model.GatewayInstanceDescriptor{ { ID: "crypto-tron", + InstanceID: "crypto-tron-1", Rail: model.RailCrypto, Network: "TRON", Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ - CanPayOut: true, - CanSendFee: true, + CanPayOut: true, + CanSendFee: true, + RequiresObserveConfirm: true, }, Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, IsEnabled: true, }, { ID: "settlement", + InstanceID: "settlement-1", Rail: model.RailProviderSettlement, Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ @@ -80,6 +102,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { }, { ID: "card", + InstanceID: "card-1", Rail: model.RailCardPayout, Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ @@ -91,7 +114,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { }, } - plan, err := builder.Build(ctx, payment, quote, routes, registry) + plan, err := builder.Build(ctx, payment, quote, routes, templates, registry) if err != nil { t.Fatalf("expected plan, got error: %v", err) } @@ -102,12 +125,12 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { t.Fatalf("expected 6 steps, got %d", len(plan.Steps)) } - assertPlanStep(t, plan.Steps[0], model.RailCrypto, model.RailOperationSend, "crypto-tron", "USDT", "100") - assertPlanStep(t, plan.Steps[1], model.RailCrypto, model.RailOperationFee, "crypto-tron", "USDT", "5") - assertPlanStep(t, plan.Steps[2], model.RailProviderSettlement, model.RailOperationObserveConfirm, "settlement", "", "") - assertPlanStep(t, plan.Steps[3], model.RailLedger, model.RailOperationCredit, "", "USDT", "95") - assertPlanStep(t, plan.Steps[4], model.RailLedger, model.RailOperationDebit, "", "USDT", "95") - assertPlanStep(t, plan.Steps[5], model.RailCardPayout, model.RailOperationSend, "card", "USDT", "95") + assertPlanStep(t, plan.Steps[0], "crypto_send", model.RailCrypto, model.RailOperationSend, "crypto-tron", "crypto-tron-1", "USDT", "100") + assertPlanStep(t, plan.Steps[1], "crypto_fee", model.RailCrypto, model.RailOperationFee, "crypto-tron", "crypto-tron-1", "USDT", "5") + assertPlanStep(t, plan.Steps[2], "crypto_observe", model.RailCrypto, model.RailOperationObserveConfirm, "crypto-tron", "crypto-tron-1", "", "") + assertPlanStep(t, plan.Steps[3], "ledger_credit", model.RailLedger, model.RailOperationCredit, "", "", "USDT", "95") + assertPlanStep(t, plan.Steps[4], "card_payout", model.RailCardPayout, model.RailOperationSend, "card", "card-1", "USDT", "95") + assertPlanStep(t, plan.Steps[5], "ledger_debit", model.RailLedger, model.RailOperationDebit, "", "", "USDT", "95") } func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { @@ -135,9 +158,10 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { } routes := &stubRouteStore{} + templates := &stubPlanTemplateStore{} registry := &stubGatewayRegistry{} - plan, err := builder.Build(ctx, payment, &orchestratorv1.PaymentQuote{}, routes, registry) + plan, err := builder.Build(ctx, payment, &orchestratorv1.PaymentQuote{}, routes, templates, registry) if err == nil { t.Fatalf("expected error, got plan: %#v", plan) } @@ -155,6 +179,17 @@ func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilte if route == nil { continue } + if filter != nil { + if filter.FromRail != "" && route.FromRail != filter.FromRail { + continue + } + if filter.ToRail != "" && route.ToRail != filter.ToRail { + continue + } + if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) { + continue + } + } if filter != nil && filter.IsEnabled != nil { if route.IsEnabled != *filter.IsEnabled { continue @@ -165,6 +200,37 @@ func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilte return &model.PaymentRouteList{Items: items}, nil } +type stubPlanTemplateStore struct { + templates []*model.PaymentPlanTemplate +} + +func (s *stubPlanTemplateStore) List(_ context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { + items := make([]*model.PaymentPlanTemplate, 0, len(s.templates)) + for _, tpl := range s.templates { + if tpl == nil { + continue + } + if filter != nil { + if filter.FromRail != "" && tpl.FromRail != filter.FromRail { + continue + } + if filter.ToRail != "" && tpl.ToRail != filter.ToRail { + continue + } + if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) { + continue + } + } + if filter != nil && filter.IsEnabled != nil { + if tpl.IsEnabled != *filter.IsEnabled { + continue + } + } + items = append(items, tpl) + } + return &model.PaymentPlanTemplateList{Items: items}, nil +} + type stubGatewayRegistry struct { items []*model.GatewayInstanceDescriptor } @@ -173,11 +239,14 @@ func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceD return s.items, nil } -func assertPlanStep(t *testing.T, step *model.PaymentStep, rail model.Rail, action model.RailOperation, gatewayID, currency, amount string) { +func assertPlanStep(t *testing.T, step *model.PaymentStep, stepID string, rail model.Rail, action model.RailOperation, gatewayID, instanceID, currency, amount string) { t.Helper() if step == nil { t.Fatal("expected step") } + if step.StepID != stepID { + t.Fatalf("expected step id %q, got %q", stepID, step.StepID) + } if step.Rail != rail { t.Fatalf("expected rail %s, got %s", rail, step.Rail) } @@ -187,6 +256,9 @@ func assertPlanStep(t *testing.T, step *model.PaymentStep, rail model.Rail, acti if step.GatewayID != gatewayID { t.Fatalf("expected gateway %q, got %q", gatewayID, step.GatewayID) } + if step.InstanceID != instanceID { + t.Fatalf("expected instance %q, got %q", instanceID, step.InstanceID) + } if currency == "" && amount == "" { if step.Amount != nil && step.Amount.Amount != "" { t.Fatalf("expected empty amount, got %v", step.Amount) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go index 3954cb0..a126820 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go @@ -11,17 +11,19 @@ import ( paymenttypes "github.com/tech/sendico/pkg/payments/types" ) -func ensureGatewayForAction(ctx context.Context, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { +func ensureGatewayForAction(ctx context.Context, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { if registry == nil { return nil, merrors.InvalidArgument("plan builder: gateway registry is required") } if gw, ok := cache[rail]; ok && gw != nil { - if err := validateGatewayAction(gw, network, amount, action, dir); err != nil { - return nil, err + if instanceID == "" || strings.EqualFold(gw.InstanceID, instanceID) { + if err := validateGatewayAction(gw, network, amount, action, dir); err != nil { + return nil, err + } + return gw, nil } - return gw, nil } - gw, err := selectGateway(ctx, registry, rail, network, amount, action, dir) + gw, err := selectGateway(ctx, registry, rail, network, amount, action, instanceID, dir) if err != nil { return nil, err } @@ -66,7 +68,7 @@ func sendDirectionForRail(rail model.Rail) sendDirection { } } -func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { +func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { if registry == nil { return nil, merrors.InvalidArgument("plan builder: gateway registry is required") } @@ -91,6 +93,9 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai eligible := make([]*model.GatewayInstanceDescriptor, 0) for _, gw := range all { + if instanceID != "" && !strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) { + continue + } if !isGatewayEligible(gw, rail, network, currency, action, dir, amt) { continue } diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go index e23ad4e..96d88dd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go @@ -14,9 +14,11 @@ func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) { return nil, merrors.InvalidArgument("plan builder: payment is required") } step := &model.PaymentStep{ - Rail: model.RailLedger, - Action: model.RailOperationFXConvert, - Amount: cloneMoney(payment.Intent.Amount), + StepID: "fx_convert", + Rail: model.RailLedger, + Action: model.RailOperationFXConvert, + CommitPolicy: model.CommitPolicyImmediate, + Amount: cloneMoney(payment.Intent.Amount), } return &model.PaymentPlan{ ID: payment.PaymentRef, @@ -33,14 +35,20 @@ func buildLedgerTransferPlan(payment *model.Payment) (*model.PaymentPlan, error) amount := cloneMoney(payment.Intent.Amount) steps := []*model.PaymentStep{ { - Rail: model.RailLedger, - Action: model.RailOperationDebit, - Amount: cloneMoney(amount), + StepID: "ledger_debit", + Rail: model.RailLedger, + Action: model.RailOperationDebit, + CommitPolicy: model.CommitPolicyImmediate, + Amount: cloneMoney(amount), }, { - Rail: model.RailLedger, - Action: model.RailOperationCredit, - Amount: cloneMoney(amount), + StepID: "ledger_credit", + Rail: model.RailLedger, + Action: model.RailOperationCredit, + DependsOn: []string{"ledger_debit"}, + CommitPolicy: model.CommitPolicyAfterSuccess, + CommitAfter: []string{"ledger_debit"}, + Amount: cloneMoney(amount), }, } return &model.PaymentPlan{ diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go index 8c78890..51abc30 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go @@ -9,115 +9,85 @@ import ( "github.com/tech/sendico/pkg/merrors" ) -func buildRoutePath(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) ([]*model.PaymentRoute, error) { +func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { + src := strings.ToUpper(strings.TrimSpace(sourceNetwork)) + dst := strings.ToUpper(strings.TrimSpace(destNetwork)) + if src != "" && dst != "" && !strings.EqualFold(src, dst) { + return "", merrors.InvalidArgument("plan builder: source and destination networks mismatch") + } + + override := strings.ToUpper(strings.TrimSpace(attributeLookup(attrs, + "network", + "route_network", + "routeNetwork", + "source_network", + "sourceNetwork", + "destination_network", + "destinationNetwork", + ))) + if override != "" { + if src != "" && !strings.EqualFold(src, override) { + return "", merrors.InvalidArgument("plan builder: source network does not match override") + } + if dst != "" && !strings.EqualFold(dst, override) { + return "", merrors.InvalidArgument("plan builder: destination network does not match override") + } + return override, nil + } + if src != "" { + return src, nil + } + if dst != "" { + return dst, nil + } + return "", nil +} + +func selectRoute(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, network string) (*model.PaymentRoute, error) { if routes == nil { return nil, merrors.InvalidArgument("plan builder: routes store is required") } enabled := true - result, err := routes.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled}) + result, err := routes.List(ctx, &model.PaymentRouteFilter{ + FromRail: sourceRail, + ToRail: destRail, + Network: "", + IsEnabled: &enabled, + }) if err != nil { return nil, err } if result == nil || len(result.Items) == 0 { return nil, merrors.InvalidArgument("plan builder: route not allowed") } - network := routeNetworkForPath(sourceRail, destRail, sourceNetwork, destNetwork) - path, err := routePath(result.Items, sourceRail, destRail, network) - if err != nil { - return nil, err - } - return path, nil -} - -func routePath(routes []*model.PaymentRoute, sourceRail, destRail model.Rail, network string) ([]*model.PaymentRoute, error) { - if sourceRail == destRail { - return nil, nil - } - adjacency := map[model.Rail][]*model.PaymentRoute{} - for _, route := range routes { + candidates := make([]*model.PaymentRoute, 0, len(result.Items)) + for _, route := range result.Items { if route == nil || !route.IsEnabled { continue } - from := route.FromRail - to := route.ToRail - if from == "" || to == "" || from == model.RailUnspecified || to == model.RailUnspecified { + if route.FromRail != sourceRail || route.ToRail != destRail { continue } - adjacency[from] = append(adjacency[from], route) - } - - for rail, edges := range adjacency { - sort.Slice(edges, func(i, j int) bool { - pi := routePriority(edges[i], network) - pj := routePriority(edges[j], network) - if pi != pj { - return pi < pj - } - if edges[i].ToRail != edges[j].ToRail { - return edges[i].ToRail < edges[j].ToRail - } - if edges[i].Network != edges[j].Network { - return edges[i].Network < edges[j].Network - } - return edges[i].ID.Hex() < edges[j].ID.Hex() - }) - adjacency[rail] = edges - } - - queue := []model.Rail{sourceRail} - visited := map[model.Rail]bool{sourceRail: true} - parents := map[model.Rail]*model.PaymentRoute{} - found := false - - for len(queue) > 0 && !found { - current := queue[0] - queue = queue[1:] - for _, route := range adjacency[current] { - if !routeMatchesNetwork(route, network) { - continue - } - next := route.ToRail - if visited[next] { - continue - } - visited[next] = true - parents[next] = route - if next == destRail { - found = true - break - } - queue = append(queue, next) + if !routeMatchesNetwork(route, network) { + continue } + candidates = append(candidates, route) } - - if !found { + if len(candidates) == 0 { return nil, merrors.InvalidArgument("plan builder: route not allowed") } - - path := make([]*model.PaymentRoute, 0) - for current := destRail; current != sourceRail; { - edge := parents[current] - if edge == nil { - return nil, merrors.InvalidArgument("plan builder: route not allowed") + sort.Slice(candidates, func(i, j int) bool { + pi := routePriority(candidates[i], network) + pj := routePriority(candidates[j], network) + if pi != pj { + return pi < pj } - path = append(path, edge) - current = edge.FromRail - } - - for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { - path[i], path[j] = path[j], path[i] - } - return path, nil -} - -func routeNetworkForPath(sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string { - if sourceRail == model.RailCrypto || sourceRail == model.RailFiatOnRamp { - return strings.ToUpper(strings.TrimSpace(sourceNetwork)) - } - if destRail == model.RailCrypto || destRail == model.RailFiatOnRamp { - return strings.ToUpper(strings.TrimSpace(destNetwork)) - } - return "" + if candidates[i].Network != candidates[j].Network { + return candidates[i].Network < candidates[j].Network + } + return candidates[i].ID.Hex() < candidates[j].ID.Hex() + }) + return candidates[0], nil } func routeMatchesNetwork(route *model.PaymentRoute, network string) bool { @@ -125,13 +95,14 @@ func routeMatchesNetwork(route *model.PaymentRoute, network string) bool { return false } routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) - if strings.TrimSpace(network) == "" { - return routeNetwork == "" - } + net := strings.ToUpper(strings.TrimSpace(network)) if routeNetwork == "" { return true } - return strings.EqualFold(routeNetwork, network) + if net == "" { + return false + } + return strings.EqualFold(routeNetwork, net) } func routePriority(route *model.PaymentRoute, network string) int { @@ -139,7 +110,8 @@ func routePriority(route *model.PaymentRoute, network string) int { return 2 } routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) - if network != "" && strings.EqualFold(routeNetwork, network) { + net := strings.ToUpper(strings.TrimSpace(network)) + if net != "" && strings.EqualFold(routeNetwork, net) { return 0 } if routeNetwork == "" { diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go index 0caa863..62ebe57 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go @@ -10,9 +10,9 @@ import ( orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) -func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, path []*model.PaymentRoute, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) { - if len(path) == 0 { - return nil, merrors.InvalidArgument("plan builder: route path is required") +func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) { + if template == nil { + return nil, merrors.InvalidArgument("plan builder: plan template is required") } sourceAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount") @@ -26,7 +26,7 @@ func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment feeAmount := resolveFeeAmount(payment, quote) feeRequired := isPositiveMoney(feeAmount) - var payoutAmount *paymenttypes.Money + payoutAmount := settlementAmount if destRail == model.RailCardPayout { payoutAmount, err = cardPayoutAmount(payment) if err != nil { @@ -40,192 +40,158 @@ func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment ledgerDebitAmount = payoutAmount } - observeRequired := observeRailsFromPath(path) - intermediate := intermediateRailsFromPath(path, sourceRail, destRail) - - steps := make([]*model.PaymentStep, 0) + steps := make([]*model.PaymentStep, 0, len(template.Steps)) gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{} - observeAdded := map[model.Rail]bool{} - useSourceSend := isSendSourceRail(sourceRail) - useDestSend := isSendDestinationRail(destRail) + stepIDs := map[string]bool{} - for idx, edge := range path { - if edge == nil { + for _, tpl := range template.Steps { + stepID := strings.TrimSpace(tpl.StepID) + if stepID == "" { + return nil, merrors.InvalidArgument("plan builder: plan template step id is required") + } + if stepIDs[stepID] { + return nil, merrors.InvalidArgument("plan builder: plan template step id must be unique") + } + stepIDs[stepID] = true + + action, err := actionForOperation(tpl.Operation) + if err != nil { + return nil, err + } + + amount, err := stepAmountForAction(action, tpl.Rail, sourceRail, destRail, sourceAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount, feeRequired) + if err != nil { + return nil, err + } + if amount == nil && action != model.RailOperationObserveConfirm { continue } - from := edge.FromRail - to := edge.ToRail - if from == model.RailLedger { - if _, err := requireMoney(ledgerDebitAmount, "ledger debit amount"); err != nil { - return nil, err - } - steps = append(steps, &model.PaymentStep{ - Rail: model.RailLedger, - Action: model.RailOperationDebit, - Amount: cloneMoney(ledgerDebitAmount), - }) + policy := tpl.CommitPolicy + if strings.TrimSpace(string(policy)) == "" { + policy = model.CommitPolicyImmediate + } + step := &model.PaymentStep{ + StepID: stepID, + Rail: tpl.Rail, + Action: action, + DependsOn: cloneStringList(tpl.DependsOn), + CommitPolicy: policy, + CommitAfter: cloneStringList(tpl.CommitAfter), + Amount: cloneMoney(amount), } - if idx == 0 && useSourceSend && from == sourceRail { - network := gatewayNetworkForRail(from, sourceRail, destRail, sourceNetwork, destNetwork) - gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, from, network, sourceAmount, model.RailOperationSend, sendDirectionForRail(from)) + if action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm { + network := gatewayNetworkForRail(tpl.Rail, sourceRail, destRail, sourceNetwork, destNetwork) + instanceID := stepInstanceIDForRail(payment.Intent, tpl.Rail, sourceRail, destRail) + checkAmount := amount + if action == model.RailOperationObserveConfirm { + checkAmount = observeAmountForRail(tpl.Rail, sourceAmount, settlementAmount, payoutAmount) + } + gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail)) if err != nil { return nil, err } - steps = append(steps, &model.PaymentStep{ - Rail: from, - GatewayID: gw.ID, - Action: model.RailOperationSend, - Amount: cloneMoney(sourceAmount), - }) - if feeRequired { - if err := validateGatewayAction(gw, network, feeAmount, model.RailOperationFee, sendDirectionForRail(from)); err != nil { - return nil, err - } - steps = append(steps, &model.PaymentStep{ - Rail: from, - GatewayID: gw.ID, - Action: model.RailOperationFee, - Amount: cloneMoney(feeAmount), - }) - } - if shouldObserveRail(from, observeRequired, gw) && !observeAdded[from] { - observeAmount := observeAmountForRail(from, sourceAmount, settlementAmount, payoutAmount) - if err := validateGatewayAction(gw, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny); err != nil { - return nil, err - } - steps = append(steps, &model.PaymentStep{ - Rail: from, - GatewayID: gw.ID, - Action: model.RailOperationObserveConfirm, - }) - observeAdded[from] = true - } + step.GatewayID = strings.TrimSpace(gw.ID) + step.InstanceID = strings.TrimSpace(gw.InstanceID) } - if intermediate[to] && !observeAdded[to] { - observeAmount := observeAmountForRail(to, sourceAmount, settlementAmount, payoutAmount) - network := gatewayNetworkForRail(to, sourceRail, destRail, sourceNetwork, destNetwork) - gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, to, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny) - if err != nil { - return nil, err - } - steps = append(steps, &model.PaymentStep{ - Rail: to, - GatewayID: gw.ID, - Action: model.RailOperationObserveConfirm, - }) - observeAdded[to] = true - } - - if to == model.RailLedger { - if _, err := requireMoney(ledgerCreditAmount, "ledger credit amount"); err != nil { - return nil, err - } - steps = append(steps, &model.PaymentStep{ - Rail: model.RailLedger, - Action: model.RailOperationCredit, - Amount: cloneMoney(ledgerCreditAmount), - }) - } - - if idx == len(path)-1 && useDestSend && to == destRail { - if payoutAmount == nil { - payoutAmount = settlementAmount - } - network := gatewayNetworkForRail(to, sourceRail, destRail, sourceNetwork, destNetwork) - gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, to, network, payoutAmount, model.RailOperationSend, sendDirectionForRail(to)) - if err != nil { - return nil, err - } - steps = append(steps, &model.PaymentStep{ - Rail: to, - GatewayID: gw.ID, - Action: model.RailOperationSend, - Amount: cloneMoney(payoutAmount), - }) - if shouldObserveRail(to, observeRequired, gw) && !observeAdded[to] { - observeAmount := observeAmountForRail(to, sourceAmount, settlementAmount, payoutAmount) - if err := validateGatewayAction(gw, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny); err != nil { - return nil, err - } - steps = append(steps, &model.PaymentStep{ - Rail: to, - GatewayID: gw.ID, - Action: model.RailOperationObserveConfirm, - }) - observeAdded[to] = true - } - } + steps = append(steps, step) } if len(steps) == 0 { return nil, merrors.InvalidArgument("plan builder: empty payment plan") } + execQuote := executionQuote(payment, quote) return &model.PaymentPlan{ ID: payment.PaymentRef, + FXQuote: fxQuoteFromProto(execQuote.GetFxQuote()), + Fees: feeLinesFromProto(execQuote.GetFeeLines()), Steps: steps, IdempotencyKey: payment.IdempotencyKey, CreatedAt: planTimestamp(payment), }, nil } -func observeRailsFromPath(path []*model.PaymentRoute) map[model.Rail]bool { - observe := map[model.Rail]bool{} - for _, edge := range path { - if edge == nil || !edge.RequiresObserve { - continue - } - rail := edge.ToRail - if rail == model.RailLedger || rail == model.RailUnspecified { - rail = edge.FromRail - } - if rail == model.RailLedger || rail == model.RailUnspecified { - continue - } - observe[rail] = true +func actionForOperation(operation string) (model.RailOperation, error) { + op := strings.ToLower(strings.TrimSpace(operation)) + switch op { + case "debit", "ledger.debit", "wallet.debit": + return model.RailOperationDebit, nil + case "credit", "ledger.credit", "wallet.credit": + return model.RailOperationCredit, nil + case "fx.convert", "fx_conversion", "fx.converted": + return model.RailOperationFXConvert, nil + case "observe", "observe.confirm", "observe.confirmation", "observe.crypto", "observe.card": + return model.RailOperationObserveConfirm, nil + case "fee", "fee.send": + return model.RailOperationFee, nil + case "send", "payout.card", "payout.crypto", "payout.fiat", "payin.crypto", "payin.fiat", "fund.crypto", "fund.card": + return model.RailOperationSend, nil } - return observe + + switch strings.ToUpper(strings.TrimSpace(operation)) { + case string(model.RailOperationDebit): + return model.RailOperationDebit, nil + case string(model.RailOperationCredit): + return model.RailOperationCredit, nil + case string(model.RailOperationSend): + return model.RailOperationSend, nil + case string(model.RailOperationFee): + return model.RailOperationFee, nil + case string(model.RailOperationObserveConfirm): + return model.RailOperationObserveConfirm, nil + case string(model.RailOperationFXConvert): + return model.RailOperationFXConvert, nil + } + + return model.RailOperationUnspecified, merrors.InvalidArgument("plan builder: unsupported operation") } -func intermediateRailsFromPath(path []*model.PaymentRoute, sourceRail, destRail model.Rail) map[model.Rail]bool { - intermediate := map[model.Rail]bool{} - for _, edge := range path { - if edge == nil { - continue +func stepAmountForAction(action model.RailOperation, rail, sourceRail, destRail model.Rail, sourceAmount, settlementAmount, payoutAmount, feeAmount, ledgerDebitAmount, ledgerCreditAmount *paymenttypes.Money, feeRequired bool) (*paymenttypes.Money, error) { + switch action { + case model.RailOperationDebit: + if rail == model.RailLedger { + return cloneMoney(ledgerDebitAmount), nil } - rail := edge.ToRail - if rail == model.RailLedger || rail == sourceRail || rail == destRail || rail == model.RailUnspecified { - continue + return cloneMoney(settlementAmount), nil + case model.RailOperationCredit: + if rail == model.RailLedger { + return cloneMoney(ledgerCreditAmount), nil } - intermediate[rail] = true - } - return intermediate -} - -func isSendSourceRail(rail model.Rail) bool { - switch rail { - case model.RailCrypto, model.RailFiatOnRamp: - return true + return cloneMoney(settlementAmount), nil + case model.RailOperationSend: + switch rail { + case sourceRail: + return cloneMoney(sourceAmount), nil + case destRail: + return cloneMoney(payoutAmount), nil + default: + return cloneMoney(settlementAmount), nil + } + case model.RailOperationFee: + if !feeRequired { + return nil, nil + } + return cloneMoney(feeAmount), nil + case model.RailOperationObserveConfirm: + return nil, nil + case model.RailOperationFXConvert: + return cloneMoney(settlementAmount), nil default: - return false + return nil, merrors.InvalidArgument("plan builder: unsupported action") } } -func isSendDestinationRail(rail model.Rail) bool { - return rail == model.RailCardPayout -} - -func shouldObserveRail(rail model.Rail, observeRequired map[model.Rail]bool, gw *model.GatewayInstanceDescriptor) bool { - if observeRequired[rail] { - return true +func stepInstanceIDForRail(intent model.PaymentIntent, rail, sourceRail, destRail model.Rail) string { + if rail == sourceRail { + return strings.TrimSpace(intent.Source.InstanceID) } - if gw != nil && gw.Capabilities.RequiresObserveConfirm { - return true + if rail == destRail { + return strings.TrimSpace(intent.Destination.InstanceID) } - return false + return "" } func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money { diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go new file mode 100644 index 0000000..799cc0f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_templates.go @@ -0,0 +1,128 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func selectPlanTemplate(ctx context.Context, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { + if templates == nil { + return nil, merrors.InvalidArgument("plan builder: plan templates store is required") + } + enabled := true + result, err := templates.List(ctx, &model.PaymentPlanTemplateFilter{ + FromRail: sourceRail, + ToRail: destRail, + IsEnabled: &enabled, + }) + if err != nil { + return nil, err + } + if result == nil || len(result.Items) == 0 { + return nil, merrors.InvalidArgument("plan builder: plan template missing") + } + + candidates := make([]*model.PaymentPlanTemplate, 0, len(result.Items)) + for _, tpl := range result.Items { + if tpl == nil || !tpl.IsEnabled { + continue + } + if tpl.FromRail != sourceRail || tpl.ToRail != destRail { + continue + } + if !templateMatchesNetwork(tpl, network) { + continue + } + if err := validatePlanTemplate(tpl); err != nil { + return nil, err + } + candidates = append(candidates, tpl) + } + if len(candidates) == 0 { + return nil, merrors.InvalidArgument("plan builder: plan template missing") + } + + sort.Slice(candidates, func(i, j int) bool { + pi := templatePriority(candidates[i], network) + pj := templatePriority(candidates[j], network) + if pi != pj { + return pi < pj + } + if candidates[i].Network != candidates[j].Network { + return candidates[i].Network < candidates[j].Network + } + return candidates[i].ID.Hex() < candidates[j].ID.Hex() + }) + + return candidates[0], nil +} + +func templateMatchesNetwork(template *model.PaymentPlanTemplate, network string) bool { + if template == nil { + return false + } + templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) + net := strings.ToUpper(strings.TrimSpace(network)) + if templateNetwork == "" { + return true + } + if net == "" { + return false + } + return strings.EqualFold(templateNetwork, net) +} + +func templatePriority(template *model.PaymentPlanTemplate, network string) int { + if template == nil { + return 2 + } + templateNetwork := strings.ToUpper(strings.TrimSpace(template.Network)) + net := strings.ToUpper(strings.TrimSpace(network)) + if net != "" && strings.EqualFold(templateNetwork, net) { + return 0 + } + if templateNetwork == "" { + return 1 + } + return 2 +} + +func validatePlanTemplate(template *model.PaymentPlanTemplate) error { + if template == nil { + return merrors.InvalidArgument("plan builder: plan template is required") + } + if len(template.Steps) == 0 { + return merrors.InvalidArgument("plan builder: plan template steps are required") + } + seen := map[string]struct{}{} + for _, step := range template.Steps { + id := strings.TrimSpace(step.StepID) + if id == "" { + return merrors.InvalidArgument("plan builder: plan template step id is required") + } + if _, exists := seen[id]; exists { + return merrors.InvalidArgument("plan builder: plan template step id must be unique") + } + seen[id] = struct{}{} + if strings.TrimSpace(step.Operation) == "" { + return merrors.InvalidArgument("plan builder: plan template operation is required") + } + } + for _, step := range template.Steps { + for _, dep := range step.DependsOn { + if _, ok := seen[strings.TrimSpace(dep)]; !ok { + return merrors.InvalidArgument("plan builder: plan template dependency missing") + } + } + for _, dep := range step.CommitAfter { + if _, ok := seen[strings.TrimSpace(dep)]; !ok { + return merrors.InvalidArgument("plan builder: plan template commit dependency missing") + } + } + } + return nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go index 5ca73f7..bdaecb2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go @@ -6,6 +6,7 @@ import ( "time" oracleclient "github.com/tech/sendico/fx/oracle/client" + "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" @@ -21,8 +22,8 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc intent := req.GetIntent() amount := intent.GetAmount() fxSide := fxv1.Side_SIDE_UNSPECIFIED - if intent.GetFx() != nil { - fxSide = intent.GetFx().GetSide() + if fxIntent := fxIntentForQuote(intent); fxIntent != nil { + fxSide = fxIntent.GetSide() } var fxQuote *oraclev1.Quote @@ -42,10 +43,23 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc feeBaseAmount = cloneProtoMoney(amount) } - feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount) + intentModel := intentFromProto(intent) + sourceRail, _, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true) if err != nil { return nil, time.Time{}, err } + destRail, _, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false) + if err != nil { + return nil, time.Time{}, err + } + feeRequired := feesRequiredForRails(sourceRail, destRail) + feeQuote := &feesv1.PrecomputeFeesResponse{} + if feeRequired { + feeQuote, err = s.quoteFees(ctx, orgRef, req, feeBaseAmount) + if err != nil { + return nil, time.Time{}, err + } + } feeCurrency := "" if feeBaseAmount != nil { feeCurrency = feeBaseAmount.GetCurrency() @@ -160,8 +174,11 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches } intent := req.GetIntent() meta := req.GetMeta() - fxIntent := intent.GetFx() + fxIntent := fxIntentForQuote(intent) if fxIntent == nil { + if intent.GetRequiresFx() { + return nil, merrors.InvalidArgument("fx intent missing") + } return nil, nil } @@ -210,6 +227,13 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches return quoteToProto(quote), nil } +func feesRequiredForRails(sourceRail, destRail model.Rail) bool { + if sourceRail == model.RailLedger && destRail == model.RailLedger { + return false + } + return true +} + func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string { if intent == nil || len(s.deps.feeLedgerAccounts) == 0 { return "" diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go new file mode 100644 index 0000000..a8441f1 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go @@ -0,0 +1,148 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + oracleclient "github.com/tech/sendico/fx/oracle/client" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" + "google.golang.org/grpc" +) + +type feeEngineFake struct { + precomputeCalls int +} + +func (f *feeEngineFake) QuoteFees(ctx context.Context, in *feesv1.QuoteFeesRequest, opts ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) { + return &feesv1.QuoteFeesResponse{}, nil +} + +func (f *feeEngineFake) PrecomputeFees(ctx context.Context, in *feesv1.PrecomputeFeesRequest, opts ...grpc.CallOption) (*feesv1.PrecomputeFeesResponse, error) { + f.precomputeCalls++ + return &feesv1.PrecomputeFeesResponse{}, nil +} + +func (f *feeEngineFake) ValidateFeeToken(ctx context.Context, in *feesv1.ValidateFeeTokenRequest, opts ...grpc.CallOption) (*feesv1.ValidateFeeTokenResponse, error) { + return &feesv1.ValidateFeeTokenResponse{}, nil +} + +func TestBuildPaymentQuote_RequestsFXWhenSettlementDiffers(t *testing.T) { + ctx := context.Background() + calls := 0 + + svc := &Service{ + logger: zap.NewNop(), + clock: testClock{now: time.Now()}, + deps: serviceDependencies{ + oracle: oracleDependency{ + client: &oracleclient.Fake{ + GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { + calls++ + return &oracleclient.Quote{ + QuoteRef: "q1", + Pair: params.Pair, + Side: params.Side, + Price: "1.1", + BaseAmount: params.BaseAmount, + QuoteAmount: params.QuoteAmount, + ExpiresAt: time.Now().Add(time.Minute), + }, nil + }, + }, + }, + }, + } + + req := &orchestratorv1.QuotePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, + Intent: &orchestratorv1.PaymentIntent{ + Source: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, + }, + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, + }, + Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + SettlementCurrency: "EUR", + }, + } + + if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 fx quote call, got %d", calls) + } +} + +func TestBuildPaymentQuote_FeesRequestedForExternalRails(t *testing.T) { + ctx := context.Background() + feeFake := &feeEngineFake{} + + svc := &Service{ + logger: zap.NewNop(), + clock: testClock{now: time.Now()}, + deps: serviceDependencies{ + fees: feesDependency{client: feeFake}, + }, + } + + req := &orchestratorv1.QuotePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, + Intent: &orchestratorv1.PaymentIntent{ + Source: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, + }, + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Card{Card: &orchestratorv1.CardEndpoint{}}, + }, + Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + SettlementCurrency: "USD", + }, + } + + if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if feeFake.precomputeCalls != 1 { + t.Fatalf("expected 1 fee precompute call, got %d", feeFake.precomputeCalls) + } +} + +func TestBuildPaymentQuote_FeesSkippedForLedgerTransfer(t *testing.T) { + ctx := context.Background() + feeFake := &feeEngineFake{} + + svc := &Service{ + logger: zap.NewNop(), + clock: testClock{now: time.Now()}, + deps: serviceDependencies{ + fees: feesDependency{client: feeFake}, + }, + } + + req := &orchestratorv1.QuotePaymentRequest{ + Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, + Intent: &orchestratorv1.PaymentIntent{ + Source: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, + }, + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, + }, + Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + SettlementCurrency: "USD", + }, + } + + if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil { + t.Fatalf("buildPaymentQuote returned error: %v", err) + } + if feeFake.precomputeCalls != 0 { + t.Fatalf("expected fee precompute to be skipped, got %d", feeFake.precomputeCalls) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go index 397e463..553dfe8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go @@ -42,7 +42,8 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, Intent: &orchestratorv1.PaymentIntent{ - Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + SettlementCurrency: "USD", Fx: &orchestratorv1.FXIntent{ Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"}, Side: fxv1.Side_BUY_BASE_SELL_QUOTE, diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 2cffdd6..f278ab4 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -7,6 +7,8 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers" clockpkg "github.com/tech/sendico/pkg/clock" + msg "github.com/tech/sendico/pkg/messaging" + mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "google.golang.org/grpc" @@ -37,6 +39,9 @@ type Service struct { h handlerSet comp componentSet + gatewayBroker mb.Broker + gatewayConsumers []msg.Consumer + orchestratorv1.UnimplementedPaymentOrchestratorServer } @@ -88,6 +93,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries")) svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"), svc.submitCardPayout, svc.resumePaymentPlan) svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc) + svc.startGatewayConsumers() return svc } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go index b0ead12..994a8d0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go @@ -54,6 +54,9 @@ func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error { if intent.GetAmount() == nil { return merrors.InvalidArgument("intent.amount is required") } + if strings.TrimSpace(intent.GetSettlementCurrency()) == "" { + return merrors.InvalidArgument("intent.settlement_currency is required") + } return nil } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go index 0d8f58d..aff6017 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + oracleclient "github.com/tech/sendico/fx/oracle/client" ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" @@ -50,8 +51,9 @@ func TestRequireIdempotencyKey(t *testing.T) { func TestNewPayment(t *testing.T) { org := primitive.NewObjectID() intent := &orchestratorv1.PaymentIntent{ - Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, - SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, + SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, + SettlementCurrency: "USD", } quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"} p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote) @@ -79,7 +81,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) { OrgRef: org.Hex(), OrgID: org, Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}, + Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"}, QuoteRef: "missing", }) if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" { @@ -89,7 +91,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) { func TestResolvePaymentQuote_Expired(t *testing.T) { org := primitive.NewObjectID() - intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}} + intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} record := &model.PaymentQuoteRecord{ QuoteRef: "q1", Intent: intentFromProto(intent), @@ -114,7 +116,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) { func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { org := primitive.NewObjectID() - intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}} + intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} record := &model.PaymentQuoteRecord{ QuoteRef: "q1", Intent: intentFromProto(intent), @@ -141,6 +143,51 @@ func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { } } +func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) { + org := primitive.NewObjectID() + intent := &orchestratorv1.PaymentIntent{ + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + SettlementCurrency: "USD", + } + record := &model.PaymentQuoteRecord{ + QuoteRef: "q1", + Intent: intentFromProto(intent), + Quote: &model.PaymentQuoteSnapshot{}, + } + + feeFake := &feeEngineFake{} + oracleCalls := 0 + svc := &Service{ + storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, + clock: clockpkg.NewSystem(), + deps: serviceDependencies{ + fees: feesDependency{client: feeFake}, + oracle: oracleDependency{client: &oracleclient.Fake{ + GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { + oracleCalls++ + return &oracleclient.Quote{QuoteRef: "q1", ExpiresAt: time.Now()}, nil + }, + }}, + }, + } + + _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ + OrgRef: org.Hex(), + OrgID: org, + Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, + QuoteRef: "q1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if feeFake.precomputeCalls != 0 { + t.Fatalf("expected no fee recompute, got %d", feeFake.precomputeCalls) + } + if oracleCalls != 0 { + t.Fatalf("expected no fx recompute, got %d", oracleCalls) + } +} + func TestInitiatePaymentIdempotency(t *testing.T) { logger := mloggerfactory.NewLogger(false) org := primitive.NewObjectID() @@ -153,9 +200,28 @@ func TestInitiatePaymentIdempotency(t *testing.T) { return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil }, } + routes := &stubRoutesStore{ + routes: []*model.PaymentRoute{ + {FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true}, + }, + } + plans := &stubPlanTemplatesStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailLedger, + ToRail: model.RailLedger, + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit"}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"ledger_debit"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"ledger_debit"}}, + }, + }, + }, + } svc := NewService(logger, stubRepo{ payments: store, - routes: &stubRoutesStore{}, + routes: routes, + plans: plans, }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) svc.ensureHandlers() @@ -166,7 +232,8 @@ func TestInitiatePaymentIdempotency(t *testing.T) { Destination: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + SettlementCurrency: "USD", } req := &orchestratorv1.InitiatePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, @@ -197,7 +264,8 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { Destination: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + SettlementCurrency: "USD", } record := &model.PaymentQuoteRecord{ QuoteRef: "q1", @@ -212,10 +280,29 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil }, } + routes := &stubRoutesStore{ + routes: []*model.PaymentRoute{ + {FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true}, + }, + } + plans := &stubPlanTemplatesStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailLedger, + ToRail: model.RailLedger, + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.debit"}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"ledger_debit"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"ledger_debit"}}, + }, + }, + }, + } svc := NewService(logger, stubRepo{ payments: store, quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, - routes: &stubRoutesStore{}, + routes: routes, + plans: plans, }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) svc.ensureHandlers() @@ -245,6 +332,7 @@ type stubRepo struct { payments storage.PaymentsStore quotes storage.QuotesStore routes storage.RoutesStore + plans storage.PlanTemplatesStore pingErr error } @@ -252,6 +340,12 @@ func (s stubRepo) Ping(context.Context) error { return s.pingErr } func (s stubRepo) Payments() storage.PaymentsStore { return s.payments } func (s stubRepo) Quotes() storage.QuotesStore { return s.quotes } func (s stubRepo) Routes() storage.RoutesStore { return s.routes } +func (s stubRepo) PlanTemplates() storage.PlanTemplatesStore { + if s.plans != nil { + return s.plans + } + return &stubPlanTemplatesStore{} +} type helperPaymentStore struct { byRef map[string]*model.Payment diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index dc7d0da..ead5954 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -94,11 +94,25 @@ func TestExecutePayment_ChainFailure(t *testing.T) { store := newStubPaymentsStore() routes := &stubRoutesStore{ routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON", RequiresObserve: true, IsEnabled: true}, - {FromRail: model.RailProviderSettlement, ToRail: model.RailLedger, IsEnabled: true}, + {FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON", IsEnabled: true}, }, } - repo := &stubRepository{store: store, routes: routes} + plans := &stubPlanTemplatesStore{ + templates: []*model.PaymentPlanTemplate{ + { + FromRail: model.RailCrypto, + ToRail: model.RailLedger, + Network: "TRON", + IsEnabled: true, + Steps: []model.OrchestrationStep{ + {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, + {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.credit", DependsOn: []string{"crypto_observe"}}, + }, + }, + }, + } + repo := &stubRepository{store: store, routes: routes, plans: plans} svc := &Service{ logger: zap.NewNop(), clock: testClock{now: time.Now()}, @@ -116,20 +130,12 @@ func TestExecutePayment_ChainFailure(t *testing.T) { items: []*model.GatewayInstanceDescriptor{ { ID: "crypto-tron", + InstanceID: "crypto-tron-1", Rail: model.RailCrypto, Network: "TRON", Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - { - ID: "settlement", - Rail: model.RailProviderSettlement, - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ + CanPayOut: true, RequiresObserveConfirm: true, }, Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, @@ -367,6 +373,7 @@ type stubRepository struct { store *stubPaymentsStore quotes storage.QuotesStore routes storage.RoutesStore + plans storage.PlanTemplatesStore } func (r *stubRepository) Ping(context.Context) error { return nil } @@ -384,6 +391,13 @@ func (r *stubRepository) Routes() storage.RoutesStore { return &stubRoutesStore{} } +func (r *stubRepository) PlanTemplates() storage.PlanTemplatesStore { + if r.plans != nil { + return r.plans + } + return &stubPlanTemplatesStore{} +} + type stubQuotesStore struct { quotes map[string]*model.PaymentQuoteRecord } @@ -431,6 +445,17 @@ func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFi if route == nil { continue } + if filter != nil { + if filter.FromRail != "" && route.FromRail != filter.FromRail { + continue + } + if filter.ToRail != "" && route.ToRail != filter.ToRail { + continue + } + if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) { + continue + } + } if filter != nil && filter.IsEnabled != nil { if route.IsEnabled != *filter.IsEnabled { continue @@ -441,6 +466,49 @@ func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFi return &model.PaymentRouteList{Items: items}, nil } +type stubPlanTemplatesStore struct { + templates []*model.PaymentPlanTemplate +} + +func (s *stubPlanTemplatesStore) Create(ctx context.Context, template *model.PaymentPlanTemplate) error { + return merrors.InvalidArgument("plan templates store not implemented") +} + +func (s *stubPlanTemplatesStore) Update(ctx context.Context, template *model.PaymentPlanTemplate) error { + return merrors.InvalidArgument("plan templates store not implemented") +} + +func (s *stubPlanTemplatesStore) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error) { + return nil, storage.ErrPlanTemplateNotFound +} + +func (s *stubPlanTemplatesStore) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { + items := make([]*model.PaymentPlanTemplate, 0, len(s.templates)) + for _, tpl := range s.templates { + if tpl == nil { + continue + } + if filter != nil { + if filter.FromRail != "" && tpl.FromRail != filter.FromRail { + continue + } + if filter.ToRail != "" && tpl.ToRail != filter.ToRail { + continue + } + if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) { + continue + } + } + if filter != nil && filter.IsEnabled != nil { + if tpl.IsEnabled != *filter.IsEnabled { + continue + } + } + items = append(items, tpl) + } + return &model.PaymentPlanTemplateList{Items: items}, nil +} + type stubPaymentsStore struct { payments map[string]*model.Payment byChain map[string]*model.Payment diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index 88c33e7..3560006 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -29,6 +29,15 @@ const ( SettlementModeFixReceived SettlementMode = "fix_received" ) +// CommitPolicy controls when a step is committed during orchestration. +type CommitPolicy string + +const ( + CommitPolicyUnspecified CommitPolicy = "UNSPECIFIED" + CommitPolicyImmediate CommitPolicy = "IMMEDIATE" + CommitPolicyAfterSuccess CommitPolicy = "AFTER_SUCCESS" +) + // PaymentState enumerates lifecycle phases. type PaymentState string @@ -180,6 +189,7 @@ type CardPayout struct { // PaymentEndpoint is a polymorphic payment destination/source. type PaymentEndpoint struct { Type PaymentEndpointType `bson:"type" json:"type"` + InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"` Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"` ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"` ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"` @@ -199,16 +209,17 @@ type FXIntent struct { // PaymentIntent models the requested payment operation. type PaymentIntent struct { - Kind PaymentKind `bson:"kind" json:"kind"` - Source PaymentEndpoint `bson:"source" json:"source"` - Destination PaymentEndpoint `bson:"destination" json:"destination"` - Amount *paymenttypes.Money `bson:"amount" json:"amount"` - RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"` - FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"` - FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` - SettlementMode SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` - Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` - Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"` + Kind PaymentKind `bson:"kind" json:"kind"` + Source PaymentEndpoint `bson:"source" json:"source"` + Destination PaymentEndpoint `bson:"destination" json:"destination"` + Amount *paymenttypes.Money `bson:"amount" json:"amount"` + RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"` + FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"` + FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` + SettlementMode SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` + SettlementCurrency string `bson:"settlementCurrency,omitempty" json:"settlementCurrency,omitempty"` + Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` + Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"` } // Customer captures payer/recipient identity details for downstream processing. @@ -249,19 +260,26 @@ type ExecutionRefs struct { // PaymentStep is an explicit action within a payment plan. type PaymentStep struct { - Rail Rail `bson:"rail" json:"rail"` - GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` - Action RailOperation `bson:"action" json:"action"` - Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` - Ref string `bson:"ref,omitempty" json:"ref,omitempty"` + StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"` + Rail Rail `bson:"rail" json:"rail"` + GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` + InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"` + Action RailOperation `bson:"action" json:"action"` + DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` + CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` + CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` + Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` + Ref string `bson:"ref,omitempty" json:"ref,omitempty"` } // PaymentPlan captures the ordered list of steps to execute a payment. type PaymentPlan struct { - ID string `bson:"id,omitempty" json:"id,omitempty"` - Steps []*PaymentStep `bson:"steps,omitempty" json:"steps,omitempty"` - IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"` - CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt,omitempty"` + ID string `bson:"id,omitempty" json:"id,omitempty"` + FXQuote *paymenttypes.FXQuote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` + Fees []*paymenttypes.FeeLine `bson:"fees,omitempty" json:"fees,omitempty"` + Steps []*PaymentStep `bson:"steps,omitempty" json:"steps,omitempty"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"` + CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt,omitempty"` } // ExecutionStep describes a planned or executed payment step for reporting. @@ -338,6 +356,7 @@ func (p *Payment) Normalize() { p.Intent.Attributes[k] = strings.TrimSpace(v) } } + p.Intent.SettlementCurrency = strings.TrimSpace(p.Intent.SettlementCurrency) if p.Intent.Customer != nil { p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID) p.Intent.Customer.FirstName = strings.TrimSpace(p.Intent.Customer.FirstName) @@ -380,9 +399,14 @@ func (p *Payment) Normalize() { if step == nil { continue } + step.StepID = strings.TrimSpace(step.StepID) step.Rail = Rail(strings.TrimSpace(string(step.Rail))) step.GatewayID = strings.TrimSpace(step.GatewayID) + step.InstanceID = strings.TrimSpace(step.InstanceID) step.Action = RailOperation(strings.TrimSpace(string(step.Action))) + step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) + step.DependsOn = normalizeStringList(step.DependsOn) + step.CommitAfter = normalizeStringList(step.CommitAfter) step.Ref = strings.TrimSpace(step.Ref) } } @@ -392,6 +416,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) { if ep == nil { return } + ep.InstanceID = strings.TrimSpace(ep.InstanceID) if ep.Metadata != nil { for k, v := range ep.Metadata { ep.Metadata[k] = strings.TrimSpace(v) @@ -433,3 +458,34 @@ func normalizeEndpoint(ep *PaymentEndpoint) { } } } + +func normalizeCommitPolicy(policy CommitPolicy) CommitPolicy { + val := strings.ToUpper(strings.TrimSpace(string(policy))) + switch CommitPolicy(val) { + case CommitPolicyImmediate, CommitPolicyAfterSuccess: + return CommitPolicy(val) + default: + if val == "" { + return CommitPolicyUnspecified + } + return CommitPolicy(val) + } +} + +func normalizeStringList(items []string) []string { + if len(items) == 0 { + return nil + } + result := make([]string, 0, len(items)) + for _, item := range items { + clean := strings.TrimSpace(item) + if clean == "" { + continue + } + result = append(result, clean) + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/api/payments/orchestrator/storage/model/plan_template.go b/api/payments/orchestrator/storage/model/plan_template.go new file mode 100644 index 0000000..86339f4 --- /dev/null +++ b/api/payments/orchestrator/storage/model/plan_template.go @@ -0,0 +1,69 @@ +package model + +import ( + "strings" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" +) + +// OrchestrationStep defines a template step for execution planning. +type OrchestrationStep struct { + StepID string `bson:"stepId" json:"stepId"` + Rail Rail `bson:"rail" json:"rail"` + Operation string `bson:"operation" json:"operation"` + DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` + CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` + CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` +} + +// PaymentPlanTemplate stores reusable orchestration templates. +type PaymentPlanTemplate struct { + storable.Base `bson:",inline" json:",inline"` + + FromRail Rail `bson:"fromRail" json:"fromRail"` + ToRail Rail `bson:"toRail" json:"toRail"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + Steps []OrchestrationStep `bson:"steps,omitempty" json:"steps,omitempty"` + IsEnabled bool `bson:"isEnabled" json:"isEnabled"` +} + +// Collection implements storable.Storable. +func (*PaymentPlanTemplate) Collection() string { + return mservice.PaymentPlanTemplates +} + +// Normalize standardizes template fields for matching and indexing. +func (t *PaymentPlanTemplate) Normalize() { + if t == nil { + return + } + t.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.FromRail)))) + t.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.ToRail)))) + t.Network = strings.ToUpper(strings.TrimSpace(t.Network)) + if len(t.Steps) == 0 { + return + } + for i := range t.Steps { + step := &t.Steps[i] + step.StepID = strings.TrimSpace(step.StepID) + step.Rail = Rail(strings.ToUpper(strings.TrimSpace(string(step.Rail)))) + step.Operation = strings.ToLower(strings.TrimSpace(step.Operation)) + step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) + step.DependsOn = normalizeStringList(step.DependsOn) + step.CommitAfter = normalizeStringList(step.CommitAfter) + } +} + +// PaymentPlanTemplateFilter selects templates for lookup. +type PaymentPlanTemplateFilter struct { + FromRail Rail + ToRail Rail + Network string + IsEnabled *bool +} + +// PaymentPlanTemplateList holds template results. +type PaymentPlanTemplateList struct { + Items []*PaymentPlanTemplate +} diff --git a/api/payments/orchestrator/storage/mongo/repository.go b/api/payments/orchestrator/storage/mongo/repository.go index d8ad407..59e22dc 100644 --- a/api/payments/orchestrator/storage/mongo/repository.go +++ b/api/payments/orchestrator/storage/mongo/repository.go @@ -20,6 +20,7 @@ type Store struct { payments storage.PaymentsStore quotes storage.QuotesStore routes storage.RoutesStore + plans storage.PlanTemplatesStore } // New constructs a Mongo-backed payments repository from a Mongo connection. @@ -30,11 +31,12 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection()) quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection()) routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection()) - return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo) + plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection()) + return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo, plansRepo) } // NewWithRepository constructs a payments repository using the provided primitives. -func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository) (*Store, error) { +func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, plansRepo repository.Repository) (*Store, error) { if ping == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil") } @@ -47,6 +49,9 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, if routesRepo == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil") } + if plansRepo == nil { + return nil, merrors.InvalidArgument("payments.storage.mongo: plan templates repository is nil") + } childLogger := logger.Named("storage").Named("mongo") paymentsStore, err := store.NewPayments(childLogger, paymentsRepo) @@ -61,12 +66,17 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, if err != nil { return nil, err } + plansStore, err := store.NewPlanTemplates(childLogger, plansRepo) + if err != nil { + return nil, err + } result := &Store{ logger: childLogger, ping: ping, payments: paymentsStore, quotes: quotesStore, routes: routesStore, + plans: plansStore, } return result, nil @@ -95,4 +105,9 @@ func (s *Store) Routes() storage.RoutesStore { return s.routes } +// PlanTemplates returns the plan templates store. +func (s *Store) PlanTemplates() storage.PlanTemplatesStore { + return s.plans +} + var _ storage.Repository = (*Store)(nil) diff --git a/api/payments/orchestrator/storage/mongo/store/plan_templates.go b/api/payments/orchestrator/storage/mongo/store/plan_templates.go new file mode 100644 index 0000000..84cc028 --- /dev/null +++ b/api/payments/orchestrator/storage/mongo/store/plan_templates.go @@ -0,0 +1,168 @@ +package store + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/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/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +type PlanTemplates struct { + logger mlogger.Logger + repo repository.Repository +} + +// NewPlanTemplates constructs a Mongo-backed plan template store. +func NewPlanTemplates(logger mlogger.Logger, repo repository.Repository) (*PlanTemplates, error) { + if repo == nil { + return nil, merrors.InvalidArgument("planTemplatesStore: repository is nil") + } + + indexes := []*ri.Definition{ + { + Keys: []ri.Key{ + {Field: "fromRail", Sort: ri.Asc}, + {Field: "toRail", Sort: ri.Asc}, + {Field: "network", Sort: ri.Asc}, + }, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "fromRail", Sort: ri.Asc}}, + }, + { + Keys: []ri.Key{{Field: "toRail", Sort: ri.Asc}}, + }, + { + Keys: []ri.Key{{Field: "isEnabled", Sort: ri.Asc}}, + }, + } + + for _, def := range indexes { + if err := repo.CreateIndex(def); err != nil { + logger.Error("failed to ensure plan templates index", zap.Error(err), zap.String("collection", repo.Collection())) + return nil, err + } + } + + return &PlanTemplates{ + logger: logger.Named("plan_templates"), + repo: repo, + }, nil +} + +func (p *PlanTemplates) Create(ctx context.Context, template *model.PaymentPlanTemplate) error { + if template == nil { + return merrors.InvalidArgument("planTemplatesStore: nil template") + } + template.Normalize() + if template.FromRail == "" || template.FromRail == model.RailUnspecified { + return merrors.InvalidArgument("planTemplatesStore: from_rail is required") + } + if template.ToRail == "" || template.ToRail == model.RailUnspecified { + return merrors.InvalidArgument("planTemplatesStore: to_rail is required") + } + if len(template.Steps) == 0 { + return merrors.InvalidArgument("planTemplatesStore: steps are required") + } + if template.ID.IsZero() { + template.SetID(primitive.NewObjectID()) + } else { + template.Update() + } + + filter := repository.Filter("fromRail", template.FromRail).And( + repository.Filter("toRail", template.ToRail), + repository.Filter("network", template.Network), + ) + + if err := p.repo.Insert(ctx, template, filter); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + return storage.ErrDuplicatePlanTemplate + } + return err + } + return nil +} + +func (p *PlanTemplates) Update(ctx context.Context, template *model.PaymentPlanTemplate) error { + if template == nil { + return merrors.InvalidArgument("planTemplatesStore: nil template") + } + if template.ID.IsZero() { + return merrors.InvalidArgument("planTemplatesStore: missing template id") + } + template.Normalize() + template.Update() + if err := p.repo.Update(ctx, template); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return storage.ErrPlanTemplateNotFound + } + return err + } + return nil +} + +func (p *PlanTemplates) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error) { + if id == primitive.NilObjectID { + return nil, merrors.InvalidArgument("planTemplatesStore: template id is required") + } + entity := &model.PaymentPlanTemplate{} + if err := p.repo.Get(ctx, id, entity); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return nil, storage.ErrPlanTemplateNotFound + } + return nil, err + } + return entity, nil +} + +func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { + if filter == nil { + filter = &model.PaymentPlanTemplateFilter{} + } + + query := repository.Query() + + if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" { + query = query.Filter(repository.Field("fromRail"), from) + } + if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" { + query = query.Filter(repository.Field("toRail"), to) + } + if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" { + query = query.Filter(repository.Field("network"), network) + } + if filter.IsEnabled != nil { + query = query.Filter(repository.Field("isEnabled"), *filter.IsEnabled) + } + + templates := make([]*model.PaymentPlanTemplate, 0) + decoder := func(cur *mongo.Cursor) error { + item := &model.PaymentPlanTemplate{} + if err := cur.Decode(item); err != nil { + return err + } + templates = append(templates, item) + return nil + } + + if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) { + return nil, err + } + + return &model.PaymentPlanTemplateList{ + Items: templates, + }, nil +} + +var _ storage.PlanTemplatesStore = (*PlanTemplates)(nil) diff --git a/api/payments/orchestrator/storage/storage.go b/api/payments/orchestrator/storage/storage.go index 2f4e524..f85c92f 100644 --- a/api/payments/orchestrator/storage/storage.go +++ b/api/payments/orchestrator/storage/storage.go @@ -26,6 +26,10 @@ var ( ErrRouteNotFound = storageError("payments.orchestrator.storage: route not found") // ErrDuplicateRoute signals that a route already exists for the same transition. ErrDuplicateRoute = storageError("payments.orchestrator.storage: duplicate route") + // ErrPlanTemplateNotFound signals that a plan template record does not exist. + ErrPlanTemplateNotFound = storageError("payments.orchestrator.storage: plan template not found") + // ErrDuplicatePlanTemplate signals that a plan template already exists for the same transition. + ErrDuplicatePlanTemplate = storageError("payments.orchestrator.storage: duplicate plan template") ) // Repository exposes persistence primitives for the orchestrator domain. @@ -34,6 +38,7 @@ type Repository interface { Payments() PaymentsStore Quotes() QuotesStore Routes() RoutesStore + PlanTemplates() PlanTemplatesStore } // PaymentsStore manages payment lifecycle state. @@ -59,3 +64,11 @@ type RoutesStore interface { GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) } + +// PlanTemplatesStore manages orchestration plan templates. +type PlanTemplatesStore interface { + Create(ctx context.Context, template *model.PaymentPlanTemplate) error + Update(ctx context.Context, template *model.PaymentPlanTemplate) error + GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentPlanTemplate, error) + List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) +} diff --git a/api/pkg/discovery/service.go b/api/pkg/discovery/service.go index ea28190..faf1796 100644 --- a/api/pkg/discovery/service.go +++ b/api/pkg/discovery/service.go @@ -93,7 +93,13 @@ func (s *RegistryService) Start() { return } s.startOnce.Do(func() { - s.logInfo("Discovery registry service starting", zap.Int("consumers", len(s.consumers)), zap.Bool("kv_enabled", s.kv != nil)) + fields := []zap.Field{zap.Int("consumers", len(s.consumers)), zap.Bool("kv_enabled", s.kv != nil)} + if s.kv != nil { + if bucket := s.kv.Bucket(); bucket != "" { + fields = append(fields, zap.String("kv_bucket", bucket)) + } + } + s.logInfo("Discovery registry service starting", fields...) for _, ch := range s.consumers { ch := ch go func() { @@ -130,6 +136,12 @@ func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) err s.logWarn("Failed to decode discovery announce payload", fields...) return err } + s.logDebug("Discovery announce received", append(envelopeFields(env), announcementFields(payload)...)...) + if strings.TrimSpace(payload.InstanceID) == "" && strings.TrimSpace(payload.ID) == "" { + fields := append(envelopeFields(env), announcementFields(payload)...) + s.logWarn("Discovery announce missing id and instance id", fields...) + return nil + } if strings.TrimSpace(payload.InstanceID) == "" { fields := append(envelopeFields(env), announcementFields(payload)...) s.logWarn("Discovery announce missing instance id", fields...) @@ -151,6 +163,7 @@ func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) er s.logWarn("Failed to decode discovery heartbeat payload", fields...) return err } + s.logDebug("Discovery heartbeat received", append(envelopeFields(env), zap.String("id", payload.ID), zap.String("instance_id", payload.InstanceID), zap.String("status", payload.Status))...) if strings.TrimSpace(payload.InstanceID) == "" && strings.TrimSpace(payload.ID) == "" { return nil } @@ -163,6 +176,10 @@ func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) er ts = time.Now() } results := s.registry.UpdateHeartbeat(payload.ID, payload.InstanceID, strings.TrimSpace(payload.Status), ts, time.Now()) + if len(results) == 0 { + s.logDebug("Discovery heartbeat ignored: entry not found", zap.String("id", payload.ID), zap.String("instance_id", payload.InstanceID)) + return nil + } for _, result := range results { if result.BecameHealthy { s.logInfo("Discovery registry entry became healthy", append(entryFields(result.Entry), zap.String("status", result.Entry.Status))...) @@ -186,6 +203,7 @@ func (s *RegistryService) handleLookup(_ context.Context, env me.Envelope) error } resp := s.registry.Lookup(time.Now()) resp.RequestID = strings.TrimSpace(payload.RequestID) + s.logDebug("Discovery lookup prepared", zap.String("request_id", resp.RequestID), zap.Int("services", len(resp.Services)), zap.Int("gateways", len(resp.Gateways))) if err := s.producer.SendMessage(NewLookupResponseEnvelope(s.sender, resp)); err != nil { fields := []zap.Field{zap.String("request_id", resp.RequestID), zap.Error(err)} s.logWarn("Failed to publish discovery lookup response", fields...) @@ -221,10 +239,12 @@ func (s *RegistryService) initKV(msgBroker mb.Broker) { } provider, ok := msgBroker.(jetStreamProvider) if !ok { + s.logInfo("Discovery KV disabled: broker does not support JetStream") return } js := provider.JetStream() if js == nil { + s.logWarn("Discovery KV disabled: JetStream not configured") return } store, err := NewKVStore(s.logger, js, "") @@ -255,10 +275,25 @@ func (s *RegistryService) consumeKVUpdates(watcher nats.KeyWatcher) { if s == nil || watcher == nil { return } + initial := true + initialCount := 0 for entry := range watcher.Updates() { if entry == nil { + if initial { + fields := []zap.Field{zap.Int("entries", initialCount)} + if s.kv != nil { + if bucket := s.kv.Bucket(); bucket != "" { + fields = append(fields, zap.String("bucket", bucket)) + } + } + s.logInfo("Discovery KV initial sync complete", fields...) + initial = false + } continue } + if initial && entry.Operation() == nats.KeyValuePut { + initialCount++ + } switch entry.Operation() { case nats.KeyValueDelete, nats.KeyValuePurge: key := registryKeyFromKVKey(entry.Key()) @@ -302,6 +337,13 @@ func (s *RegistryService) logWarn(message string, fields ...zap.Field) { s.logger.Warn(message, fields...) } +func (s *RegistryService) logDebug(message string, fields ...zap.Field) { + if s.logger == nil { + return + } + s.logger.Debug(message, fields...) +} + func (s *RegistryService) logInfo(message string, fields ...zap.Field) { if s.logger == nil { return diff --git a/api/pkg/discovery/watcher.go b/api/pkg/discovery/watcher.go index 559196b..9a80b54 100644 --- a/api/pkg/discovery/watcher.go +++ b/api/pkg/discovery/watcher.go @@ -92,10 +92,23 @@ func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) { if w == nil || watcher == nil { return } + initial := true + initialCount := 0 for entry := range watcher.Updates() { if entry == nil { + if initial && w.logger != nil { + fields := []zap.Field{zap.Int("entries", initialCount)} + if w.kv != nil { + fields = append(fields, zap.String("bucket", w.kv.Bucket())) + } + w.logger.Info("Discovery registry watcher initial sync complete", fields...) + initial = false + } continue } + if initial && entry.Operation() == nats.KeyValuePut { + initialCount++ + } switch entry.Operation() { case nats.KeyValueDelete, nats.KeyValuePurge: key := registryKeyFromKVKey(entry.Key()) diff --git a/api/pkg/messaging/internal/notifications/confirmations/notification.go b/api/pkg/messaging/internal/notifications/confirmations/notification.go new file mode 100644 index 0000000..1b381e8 --- /dev/null +++ b/api/pkg/messaging/internal/notifications/confirmations/notification.go @@ -0,0 +1,77 @@ +package notifications + +import ( + "encoding/json" + "strings" + + messaging "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/model" + nm "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" +) + +type ConfirmationRequestNotification struct { + messaging.Envelope + payload model.ConfirmationRequest +} + +func (crn *ConfirmationRequestNotification) Serialize() ([]byte, error) { + data, err := json.Marshal(crn.payload) + if err != nil { + return nil, err + } + return crn.Envelope.Wrap(data) +} + +type ConfirmationResultNotification struct { + messaging.Envelope + payload model.ConfirmationResult +} + +func (crn *ConfirmationResultNotification) Serialize() ([]byte, error) { + data, err := json.Marshal(crn.payload) + if err != nil { + return nil, err + } + return crn.Envelope.Wrap(data) +} + +func confirmationRequestEvent() model.NotificationEvent { + return model.NewNotification(mservice.Notifications, nm.NAConfirmationRequest) +} + +func confirmationResultEvent(sourceService, rail string) model.NotificationEvent { + action := strings.TrimSpace(sourceService) + if action == "" { + action = "unknown" + } + action = strings.ToLower(action) + rail = strings.TrimSpace(rail) + if rail == "" { + rail = "default" + } + rail = strings.ToLower(rail) + return model.NewNotification(mservice.Confirmations, nm.NotificationAction(action+"."+rail)) +} + +func NewConfirmationRequestEnvelope(sender string, request *model.ConfirmationRequest) messaging.Envelope { + var payload model.ConfirmationRequest + if request != nil { + payload = *request + } + return &ConfirmationRequestNotification{ + Envelope: messaging.CreateEnvelope(sender, confirmationRequestEvent()), + payload: payload, + } +} + +func NewConfirmationResultEnvelope(sender string, result *model.ConfirmationResult, sourceService, rail string) messaging.Envelope { + var payload model.ConfirmationResult + if result != nil { + payload = *result + } + return &ConfirmationResultNotification{ + Envelope: messaging.CreateEnvelope(sender, confirmationResultEvent(sourceService, rail)), + payload: payload, + } +} diff --git a/api/pkg/messaging/internal/notifications/confirmations/processor.go b/api/pkg/messaging/internal/notifications/confirmations/processor.go new file mode 100644 index 0000000..7110906 --- /dev/null +++ b/api/pkg/messaging/internal/notifications/confirmations/processor.go @@ -0,0 +1,81 @@ +package notifications + +import ( + "context" + "encoding/json" + + me "github.com/tech/sendico/pkg/messaging/envelope" + ch "github.com/tech/sendico/pkg/messaging/notifications/confirmations/handler" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "go.uber.org/zap" +) + +type ConfirmationRequestProcessor struct { + logger mlogger.Logger + handler ch.ConfirmationRequestHandler + event model.NotificationEvent +} + +func (crp *ConfirmationRequestProcessor) Process(ctx context.Context, envelope me.Envelope) error { + var msg model.ConfirmationRequest + if err := json.Unmarshal(envelope.GetData(), &msg); err != nil { + crp.logger.Warn("Failed to decode confirmation request envelope", zap.Error(err), zap.String("topic", crp.event.ToString())) + return err + } + if crp.handler == nil { + crp.logger.Warn("Confirmation request handler is not configured", zap.String("topic", crp.event.ToString())) + return nil + } + return crp.handler(ctx, &msg) +} + +func (crp *ConfirmationRequestProcessor) GetSubject() model.NotificationEvent { + return crp.event +} + +type ConfirmationResultProcessor struct { + logger mlogger.Logger + handler ch.ConfirmationResultHandler + event model.NotificationEvent +} + +func (crp *ConfirmationResultProcessor) Process(ctx context.Context, envelope me.Envelope) error { + var msg model.ConfirmationResult + if err := json.Unmarshal(envelope.GetData(), &msg); err != nil { + crp.logger.Warn("Failed to decode confirmation result envelope", zap.Error(err), zap.String("topic", crp.event.ToString())) + return err + } + if crp.handler == nil { + crp.logger.Warn("Confirmation result handler is not configured", zap.String("topic", crp.event.ToString())) + return nil + } + return crp.handler(ctx, &msg) +} + +func (crp *ConfirmationResultProcessor) GetSubject() model.NotificationEvent { + return crp.event +} + +func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor { + if logger != nil { + logger = logger.Named("confirmation_request_processor") + } + return &ConfirmationRequestProcessor{ + logger: logger, + handler: handler, + event: confirmationRequestEvent(), + } +} + +func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationResultHandler) np.EnvelopeProcessor { + if logger != nil { + logger = logger.Named("confirmation_result_processor") + } + return &ConfirmationResultProcessor{ + logger: logger, + handler: handler, + event: confirmationResultEvent(sourceService, rail), + } +} diff --git a/api/pkg/messaging/internal/notifications/paymentgateway/notification.go b/api/pkg/messaging/internal/notifications/paymentgateway/notification.go new file mode 100644 index 0000000..2b7ea8e --- /dev/null +++ b/api/pkg/messaging/internal/notifications/paymentgateway/notification.go @@ -0,0 +1,66 @@ +package notifications + +import ( + "encoding/json" + + messaging "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/model" + nm "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" +) + +type PaymentGatewayIntentNotification struct { + messaging.Envelope + payload model.PaymentGatewayIntent +} + +func (pgn *PaymentGatewayIntentNotification) Serialize() ([]byte, error) { + data, err := json.Marshal(pgn.payload) + if err != nil { + return nil, err + } + return pgn.Envelope.Wrap(data) +} + +type PaymentGatewayExecutionNotification struct { + messaging.Envelope + payload model.PaymentGatewayExecution +} + +func (pgn *PaymentGatewayExecutionNotification) Serialize() ([]byte, error) { + data, err := json.Marshal(pgn.payload) + if err != nil { + return nil, err + } + return pgn.Envelope.Wrap(data) +} + +func intentEvent() model.NotificationEvent { + return model.NewNotification(mservice.PaymentGateway, nm.NAPaymentGatewayIntent) +} + +func executionEvent() model.NotificationEvent { + return model.NewNotification(mservice.PaymentGateway, nm.NAPaymentGatewayExecution) +} + +func NewPaymentGatewayIntentEnvelope(sender string, intent *model.PaymentGatewayIntent) messaging.Envelope { + var payload model.PaymentGatewayIntent + if intent != nil { + payload = *intent + } + return &PaymentGatewayIntentNotification{ + Envelope: messaging.CreateEnvelope(sender, intentEvent()), + payload: payload, + } +} + +func NewPaymentGatewayExecutionEnvelope(sender string, exec *model.PaymentGatewayExecution) messaging.Envelope { + var payload model.PaymentGatewayExecution + if exec != nil { + payload = *exec + } + return &PaymentGatewayExecutionNotification{ + Envelope: messaging.CreateEnvelope(sender, executionEvent()), + payload: payload, + } +} diff --git a/api/pkg/messaging/internal/notifications/paymentgateway/processor.go b/api/pkg/messaging/internal/notifications/paymentgateway/processor.go new file mode 100644 index 0000000..f957534 --- /dev/null +++ b/api/pkg/messaging/internal/notifications/paymentgateway/processor.go @@ -0,0 +1,81 @@ +package notifications + +import ( + "context" + "encoding/json" + + me "github.com/tech/sendico/pkg/messaging/envelope" + ch "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway/handler" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "go.uber.org/zap" +) + +type PaymentGatewayIntentProcessor struct { + logger mlogger.Logger + handler ch.PaymentGatewayIntentHandler + event model.NotificationEvent +} + +func (pgp *PaymentGatewayIntentProcessor) Process(ctx context.Context, envelope me.Envelope) error { + var msg model.PaymentGatewayIntent + if err := json.Unmarshal(envelope.GetData(), &msg); err != nil { + pgp.logger.Warn("Failed to decode payment gateway intent envelope", zap.Error(err), zap.String("topic", pgp.event.ToString())) + return err + } + if pgp.handler == nil { + pgp.logger.Warn("Payment gateway intent handler is not configured", zap.String("topic", pgp.event.ToString())) + return nil + } + return pgp.handler(ctx, &msg) +} + +func (pgp *PaymentGatewayIntentProcessor) GetSubject() model.NotificationEvent { + return pgp.event +} + +type PaymentGatewayExecutionProcessor struct { + logger mlogger.Logger + handler ch.PaymentGatewayExecutionHandler + event model.NotificationEvent +} + +func (pgp *PaymentGatewayExecutionProcessor) Process(ctx context.Context, envelope me.Envelope) error { + var msg model.PaymentGatewayExecution + if err := json.Unmarshal(envelope.GetData(), &msg); err != nil { + pgp.logger.Warn("Failed to decode payment gateway execution envelope", zap.Error(err), zap.String("topic", pgp.event.ToString())) + return err + } + if pgp.handler == nil { + pgp.logger.Warn("Payment gateway execution handler is not configured", zap.String("topic", pgp.event.ToString())) + return nil + } + return pgp.handler(ctx, &msg) +} + +func (pgp *PaymentGatewayExecutionProcessor) GetSubject() model.NotificationEvent { + return pgp.event +} + +func NewPaymentGatewayIntentProcessor(logger mlogger.Logger, handler ch.PaymentGatewayIntentHandler) np.EnvelopeProcessor { + if logger != nil { + logger = logger.Named("payment_gateway_intent_processor") + } + return &PaymentGatewayIntentProcessor{ + logger: logger, + handler: handler, + event: intentEvent(), + } +} + +func NewPaymentGatewayExecutionProcessor(logger mlogger.Logger, handler ch.PaymentGatewayExecutionHandler) np.EnvelopeProcessor { + if logger != nil { + logger = logger.Named("payment_gateway_execution_processor") + } + return &PaymentGatewayExecutionProcessor{ + logger: logger, + handler: handler, + event: executionEvent(), + } +} diff --git a/api/pkg/messaging/notifications/confirmations/confirmations.go b/api/pkg/messaging/notifications/confirmations/confirmations.go new file mode 100644 index 0000000..09b9003 --- /dev/null +++ b/api/pkg/messaging/notifications/confirmations/confirmations.go @@ -0,0 +1,26 @@ +package notifications + +import ( + messaging "github.com/tech/sendico/pkg/messaging/envelope" + cinternal "github.com/tech/sendico/pkg/messaging/internal/notifications/confirmations" + ch "github.com/tech/sendico/pkg/messaging/notifications/confirmations/handler" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +func ConfirmationRequest(sender string, request *model.ConfirmationRequest) messaging.Envelope { + return cinternal.NewConfirmationRequestEnvelope(sender, request) +} + +func ConfirmationResult(sender string, result *model.ConfirmationResult, sourceService, rail string) messaging.Envelope { + return cinternal.NewConfirmationResultEnvelope(sender, result, sourceService, rail) +} + +func NewConfirmationRequestProcessor(logger mlogger.Logger, handler ch.ConfirmationRequestHandler) np.EnvelopeProcessor { + return cinternal.NewConfirmationRequestProcessor(logger, handler) +} + +func NewConfirmationResultProcessor(logger mlogger.Logger, sourceService, rail string, handler ch.ConfirmationResultHandler) np.EnvelopeProcessor { + return cinternal.NewConfirmationResultProcessor(logger, sourceService, rail, handler) +} diff --git a/api/pkg/messaging/notifications/confirmations/handler/interface.go b/api/pkg/messaging/notifications/confirmations/handler/interface.go new file mode 100644 index 0000000..7a15817 --- /dev/null +++ b/api/pkg/messaging/notifications/confirmations/handler/interface.go @@ -0,0 +1,11 @@ +package notifications + +import ( + "context" + + "github.com/tech/sendico/pkg/model" +) + +type ConfirmationRequestHandler = func(context.Context, *model.ConfirmationRequest) error + +type ConfirmationResultHandler = func(context.Context, *model.ConfirmationResult) error diff --git a/api/pkg/messaging/notifications/paymentgateway/handler/interface.go b/api/pkg/messaging/notifications/paymentgateway/handler/interface.go new file mode 100644 index 0000000..8e30a6a --- /dev/null +++ b/api/pkg/messaging/notifications/paymentgateway/handler/interface.go @@ -0,0 +1,11 @@ +package notifications + +import ( + "context" + + "github.com/tech/sendico/pkg/model" +) + +type PaymentGatewayIntentHandler = func(context.Context, *model.PaymentGatewayIntent) error + +type PaymentGatewayExecutionHandler = func(context.Context, *model.PaymentGatewayExecution) error diff --git a/api/pkg/messaging/notifications/paymentgateway/paymentgateway.go b/api/pkg/messaging/notifications/paymentgateway/paymentgateway.go new file mode 100644 index 0000000..c454809 --- /dev/null +++ b/api/pkg/messaging/notifications/paymentgateway/paymentgateway.go @@ -0,0 +1,26 @@ +package notifications + +import ( + messaging "github.com/tech/sendico/pkg/messaging/envelope" + pinternal "github.com/tech/sendico/pkg/messaging/internal/notifications/paymentgateway" + ch "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway/handler" + np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +func PaymentGatewayIntent(sender string, intent *model.PaymentGatewayIntent) messaging.Envelope { + return pinternal.NewPaymentGatewayIntentEnvelope(sender, intent) +} + +func PaymentGatewayExecution(sender string, exec *model.PaymentGatewayExecution) messaging.Envelope { + return pinternal.NewPaymentGatewayExecutionEnvelope(sender, exec) +} + +func NewPaymentGatewayIntentProcessor(logger mlogger.Logger, handler ch.PaymentGatewayIntentHandler) np.EnvelopeProcessor { + return pinternal.NewPaymentGatewayIntentProcessor(logger, handler) +} + +func NewPaymentGatewayExecutionProcessor(logger mlogger.Logger, handler ch.PaymentGatewayExecutionHandler) np.EnvelopeProcessor { + return pinternal.NewPaymentGatewayExecutionProcessor(logger, handler) +} diff --git a/api/pkg/model/confirmation.go b/api/pkg/model/confirmation.go index 6c369c4..6cdba30 100644 --- a/api/pkg/model/confirmation.go +++ b/api/pkg/model/confirmation.go @@ -1,38 +1,42 @@ package model -import ( - "time" +import paymenttypes "github.com/tech/sendico/pkg/payments/types" - "github.com/tech/sendico/pkg/db/storable" - "github.com/tech/sendico/pkg/mservice" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -type ConfirmationTarget string +type ConfirmationStatus string const ( - ConfirmationTargetLogin ConfirmationTarget = "login" - ConfirmationTargetPayout ConfirmationTarget = "payout" + ConfirmationStatusConfirmed ConfirmationStatus = "CONFIRMED" + ConfirmationStatusClarified ConfirmationStatus = "CLARIFIED" + ConfirmationStatusTimeout ConfirmationStatus = "TIMEOUT" + ConfirmationStatusRejected ConfirmationStatus = "REJECTED" ) -// ConfirmationCode stores verification codes for operations like login or payouts. -type ConfirmationCode struct { - storable.Base `bson:",inline" json:",inline"` - - AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"` - Destination string `bson:"destination" json:"destination"` - Target ConfirmationTarget `bson:"target" json:"target"` - CodeHash []byte `bson:"codeHash" json:"-"` - Salt []byte `bson:"salt" json:"-"` - ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` - Attempts int `bson:"attempts" json:"attempts"` - MaxAttempts int `bson:"maxAttempts" json:"maxAttempts"` - ResendCount int `bson:"resendCount" json:"resendCount"` - ResendLimit int `bson:"resendLimit" json:"resendLimit"` - CooldownUntil time.Time `bson:"cooldownUntil" json:"cooldownUntil"` - Used bool `bson:"used" json:"used"` +type ConfirmationRequest struct { + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"` + RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"` + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + AcceptedUserIDs []string `bson:"acceptedUserIds,omitempty" json:"accepted_user_ids,omitempty"` + TimeoutSeconds int32 `bson:"timeoutSeconds,omitempty" json:"timeout_seconds,omitempty"` + SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"` + Rail string `bson:"rail,omitempty" json:"rail,omitempty"` } -func (c *ConfirmationCode) Collection() string { - return mservice.Confirmations +type ConfirmationResult struct { + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + Money *paymenttypes.Money `bson:"money,omitempty" json:"money,omitempty"` + RawReply *TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"` + Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"` + ParseError string `bson:"parseError,omitempty" json:"parse_error,omitempty"` +} + +type TelegramMessage struct { + ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"` + MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"` + ReplyToMessageID string `bson:"replyToMessageId,omitempty" json:"reply_to_message_id,omitempty"` + FromUserID string `bson:"fromUserId,omitempty" json:"from_user_id,omitempty"` + FromUsername string `bson:"fromUsername,omitempty" json:"from_username,omitempty"` + Text string `bson:"text,omitempty" json:"text,omitempty"` + SentAt int64 `bson:"sentAt,omitempty" json:"sent_at,omitempty"` } diff --git a/api/pkg/model/confirmation_code.go b/api/pkg/model/confirmation_code.go new file mode 100644 index 0000000..326a080 --- /dev/null +++ b/api/pkg/model/confirmation_code.go @@ -0,0 +1,36 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ConfirmationTarget string + +const ( + ConfirmationTargetLogin ConfirmationTarget = "login" + ConfirmationTargetPayout ConfirmationTarget = "payout" +) + +type ConfirmationCode struct { + storable.Base `bson:",inline" json:",inline"` + AccountRef primitive.ObjectID `bson:"accountRef" json:"accountRef"` + Destination string `bson:"destination" json:"destination"` + Target ConfirmationTarget `bson:"target" json:"target"` + CodeHash []byte `bson:"codeHash" json:"codeHash,omitempty"` + Salt []byte `bson:"salt" json:"salt,omitempty"` + ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` + MaxAttempts int `bson:"maxAttempts" json:"maxAttempts"` + ResendLimit int `bson:"resendLimit" json:"resendLimit"` + CooldownUntil time.Time `bson:"cooldownUntil" json:"cooldownUntil"` + Used bool `bson:"used" json:"used"` + Attempts int `bson:"attempts" json:"attempts"` + ResendCount int `bson:"resendCount" json:"resendCount"` +} + +func (*ConfirmationCode) Collection() string { + return mservice.Confirmations +} diff --git a/api/pkg/model/notification/notification.go b/api/pkg/model/notification/notification.go index 0d08515..a3f4da0 100644 --- a/api/pkg/model/notification/notification.go +++ b/api/pkg/model/notification/notification.go @@ -12,6 +12,10 @@ const ( NASent NotificationAction = "sent" NAPasswordReset NotificationAction = "password_reset" + NAConfirmationRequest NotificationAction = "confirmation.request" + NAPaymentGatewayIntent NotificationAction = "intent.request" + NAPaymentGatewayExecution NotificationAction = "execution.result" + NADiscoveryServiceAnnounce NotificationAction = "service.announce" NADiscoveryGatewayAnnounce NotificationAction = "gateway.announce" NADiscoveryHeartbeat NotificationAction = "service.heartbeat" diff --git a/api/pkg/model/notificationevent.go b/api/pkg/model/notificationevent.go index 9208114..58c7d5c 100644 --- a/api/pkg/model/notificationevent.go +++ b/api/pkg/model/notificationevent.go @@ -81,6 +81,9 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) { nm.NADeleted, nm.NAAssigned, nm.NAPasswordReset, + nm.NAConfirmationRequest, + nm.NAPaymentGatewayIntent, + nm.NAPaymentGatewayExecution, nm.NADiscoveryServiceAnnounce, nm.NADiscoveryGatewayAnnounce, nm.NADiscoveryHeartbeat, @@ -99,8 +102,15 @@ func StringToNotificationEvent(eventType, eventAction string) (NotificationEvent return nil, err } ea, err := StringToNotificationAction(eventAction) - if err != nil { - return nil, err + if err == nil { + return NewNotification(et, ea), nil } - return NewNotification(et, ea), nil + if et == mservice.Confirmations { + action := strings.TrimSpace(eventAction) + if action == "" { + return nil, err + } + return &NotificationEventImp{nType: et, nAction: nm.NotificationAction(action)}, nil + } + return nil, err } diff --git a/api/pkg/model/payment_gateway.go b/api/pkg/model/payment_gateway.go new file mode 100644 index 0000000..8c6bd46 --- /dev/null +++ b/api/pkg/model/payment_gateway.go @@ -0,0 +1,22 @@ +package model + +import paymenttypes "github.com/tech/sendico/pkg/payments/types" + +type PaymentGatewayIntent struct { + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"` + TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"` +} + +type PaymentGatewayExecution struct { + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"` + Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"` + RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` + RawReply *TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"` +} diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index 1571cd3..49d882c 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -14,6 +14,7 @@ const ( Clients Type = "clients" // Represents client information ChainGateway Type = "chain_gateway" // Represents chain gateway microservice MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice + PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice FXOracle Type = "fx_oracle" // Represents FX oracle microservice FeePlans Type = "fee_plans" // Represents fee plans microservice FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources @@ -36,6 +37,7 @@ const ( Organizations Type = "organizations" // Represents organizations in the system Payments Type = "payments" // Represents payments service PaymentRoutes Type = "payment_routes" // Represents payment routing definitions + PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates PaymentMethods Type = "payment_methods" // Represents payment methods service Permissions Type = "permissions" // Represents permissiosns service Policies Type = "policies" // Represents access control policies @@ -52,9 +54,9 @@ const ( func StringToSType(s string) (Type, error) { switch Type(s) { case Accounts, Confirmations, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, ChainWalletBalances, - ChainTransfers, ChainDeposits, MntxGateway, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger, + ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger, LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, - Organizations, Payments, PaymentRoutes, PaymentOrchestrator, Permissions, Policies, PolicyAssignements, + Organizations, Payments, PaymentRoutes, PaymentPlanTemplates, PaymentOrchestrator, Permissions, Policies, PolicyAssignements, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery: return Type(s), nil default: diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index aa6ce76..80d91e7 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -92,6 +92,7 @@ message PaymentEndpoint { CardEndpoint card = 4; } map metadata = 10; + string instance_id = 11; } message FXIntent { @@ -114,6 +115,7 @@ message PaymentIntent { map attributes = 8; SettlementMode settlement_mode = 9; Customer customer = 10; + string settlement_currency = 11; } message Customer { @@ -178,6 +180,11 @@ message PaymentStep { common.gateway.v1.RailOperation action = 3; common.money.v1.Money amount = 4; string ref = 5; + string step_id = 6; + string instance_id = 7; + repeated string depends_on = 8; + string commit_policy = 9; + repeated string commit_after = 10; } message PaymentPlan { @@ -185,6 +192,8 @@ message PaymentPlan { repeated PaymentStep steps = 2; string idempotency_key = 3; google.protobuf.Timestamp created_at = 4; + oracle.v1.Quote fx_quote = 5; + repeated fees.v1.DerivedPostingLine fees = 6; } // Card payout gateway tracking info. diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index 0042903..a5901d3 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -160,3 +160,19 @@ CHAIN_GATEWAY_MONGO_PORT=27017 CHAIN_GATEWAY_MONGO_DATABASE=chain_gateway CHAIN_GATEWAY_MONGO_AUTH_SOURCE=admin CHAIN_GATEWAY_MONGO_REPLICA_SET=sendico-rs + +# TGSettle gateway stack +TGSETTLE_GATEWAY_DIR=tgsettle_gateway +TGSETTLE_GATEWAY_COMPOSE_PROJECT=sendico-tgsettle-gateway +TGSETTLE_GATEWAY_SERVICE_NAME=sendico_tgsettle_gateway +TGSETTLE_GATEWAY_GRPC_PORT=50080 +TGSETTLE_GATEWAY_METRICS_PORT=9406 +# Update TGSETTLE_GATEWAY_CHAT_ID with the Telegram group id for confirmations. +TGSETTLE_GATEWAY_CHAT_ID=-100 + +# TGSettle gateway Mongo settings +TGSETTLE_GATEWAY_MONGO_HOST=sendico_db1 +TGSETTLE_GATEWAY_MONGO_PORT=27017 +TGSETTLE_GATEWAY_MONGO_DATABASE=tgsettle_gateway +TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE=admin +TGSETTLE_GATEWAY_MONGO_REPLICA_SET=sendico-rs diff --git a/ci/prod/compose/tgsettle_gateway.dockerfile b/ci/prod/compose/tgsettle_gateway.dockerfile new file mode 100644 index 0000000..53459eb --- /dev/null +++ b/ci/prod/compose/tgsettle_gateway.dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1.7 + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +FROM golang:alpine AS build +ARG APP_VERSION=dev +ARG GIT_REV=unknown +ARG BUILD_BRANCH=unknown +ARG BUILD_DATE=unknown +ARG BUILD_USER=ci +ENV GO111MODULE=on +ENV PATH="/go/bin:${PATH}" +WORKDIR /src +COPY . . +RUN apk add --no-cache bash git build-base protoc protobuf-dev \ + && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \ + && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest \ + && bash ci/scripts/proto/generate.sh +WORKDIR /src/api/gateway/tgsettle +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "\ + -s -w \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.Version=${APP_VERSION} \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.Revision=${GIT_REV} \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.Branch=${BUILD_BRANCH} \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.BuildUser=${BUILD_USER} \ + -X github.com/tech/sendico/gateway/tgsettle/internal/appversion.BuildDate=${BUILD_DATE}" \ + -o /out/tgsettle-gateway . + +FROM alpine:latest AS runtime +RUN apk add --no-cache ca-certificates tzdata wget +WORKDIR /app +COPY api/gateway/tgsettle/config.yml /app/config.yml +COPY --from=build /out/tgsettle-gateway /app/tgsettle-gateway +EXPOSE 50080 9406 +ENTRYPOINT ["/app/tgsettle-gateway"] +CMD ["--config.file", "/app/config.yml"] diff --git a/ci/prod/compose/tgsettle_gateway.yml b/ci/prod/compose/tgsettle_gateway.yml new file mode 100644 index 0000000..85cd61a --- /dev/null +++ b/ci/prod/compose/tgsettle_gateway.yml @@ -0,0 +1,53 @@ +# Compose v2 - TGSettle Gateway + +x-common-env: &common-env + env_file: + - ../env/.env.runtime + - ../env/.env.version + +networks: + sendico-net: + external: true + name: sendico-net + +services: + sendico_tgsettle_gateway: + <<: *common-env + container_name: sendico-tgsettle-gateway + restart: unless-stopped + image: ${REGISTRY_URL}/gateway/tgsettle:${APP_V} + pull_policy: always + environment: + TGSETTLE_GATEWAY_MONGO_HOST: ${TGSETTLE_GATEWAY_MONGO_HOST} + TGSETTLE_GATEWAY_MONGO_PORT: ${TGSETTLE_GATEWAY_MONGO_PORT} + TGSETTLE_GATEWAY_MONGO_DATABASE: ${TGSETTLE_GATEWAY_MONGO_DATABASE} + TGSETTLE_GATEWAY_MONGO_USER: ${TGSETTLE_GATEWAY_MONGO_USER} + TGSETTLE_GATEWAY_MONGO_PASSWORD: ${TGSETTLE_GATEWAY_MONGO_PASSWORD} + TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE: ${TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE} + TGSETTLE_GATEWAY_MONGO_REPLICA_SET: ${TGSETTLE_GATEWAY_MONGO_REPLICA_SET} + MONGO_HOSTS_0: ${MONGO_HOSTS_0} + MONGO_PORTS_0: ${MONGO_PORTS_0} + MONGO_HOSTS_1: ${MONGO_HOSTS_1} + MONGO_PORTS_1: ${MONGO_PORTS_1} + MONGO_HOSTS_2: ${MONGO_HOSTS_2} + MONGO_PORTS_2: ${MONGO_PORTS_2} + NATS_URL: ${NATS_URL} + NATS_HOST: ${NATS_HOST} + NATS_PORT: ${NATS_PORT} + NATS_USER: ${NATS_USER} + NATS_PASSWORD: ${NATS_PASSWORD} + TGSETTLE_GATEWAY_CHAT_ID: ${TGSETTLE_GATEWAY_CHAT_ID} + TGSETTLE_GATEWAY_GRPC_PORT: ${TGSETTLE_GATEWAY_GRPC_PORT} + TGSETTLE_GATEWAY_METRICS_PORT: ${TGSETTLE_GATEWAY_METRICS_PORT} + command: ["--config.file", "/app/config.yml"] + ports: + - "0.0.0.0:${TGSETTLE_GATEWAY_GRPC_PORT}:50080" + - "0.0.0.0:${TGSETTLE_GATEWAY_METRICS_PORT}:9406" + healthcheck: + test: ["CMD-SHELL","wget -qO- http://localhost:9406/health | grep -q '\"status\":\"ok\"'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - sendico-net diff --git a/ci/prod/scripts/deploy/tgsettle_gateway.sh b/ci/prod/scripts/deploy/tgsettle_gateway.sh new file mode 100755 index 0000000..2b4e9ba --- /dev/null +++ b/ci/prod/scripts/deploy/tgsettle_gateway.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x +trap 'echo "[deploy-tgsettle-gateway] error at line $LINENO" >&2' ERR + +: "${REMOTE_BASE:?missing REMOTE_BASE}" +: "${SSH_USER:?missing SSH_USER}" +: "${SSH_HOST:?missing SSH_HOST}" +: "${TGSETTLE_GATEWAY_DIR:?missing TGSETTLE_GATEWAY_DIR}" +: "${TGSETTLE_GATEWAY_COMPOSE_PROJECT:?missing TGSETTLE_GATEWAY_COMPOSE_PROJECT}" +: "${TGSETTLE_GATEWAY_SERVICE_NAME:?missing TGSETTLE_GATEWAY_SERVICE_NAME}" + +REMOTE_DIR="${REMOTE_BASE%/}/${TGSETTLE_GATEWAY_DIR}" +REMOTE_TARGET="${SSH_USER}@${SSH_HOST}" +COMPOSE_FILE="tgsettle_gateway.yml" +SERVICE_NAMES="${TGSETTLE_GATEWAY_SERVICE_NAME}" + +REQUIRED_SECRETS=( + TGSETTLE_GATEWAY_MONGO_USER + TGSETTLE_GATEWAY_MONGO_PASSWORD + NATS_USER + NATS_PASSWORD + NATS_URL +) + +for var in "${REQUIRED_SECRETS[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "missing required secret env: ${var}" >&2 + exit 65 + fi +done + +if [[ ! -s .env.version ]]; then + echo ".env.version is missing; run version step first" >&2 + exit 66 +fi + +b64enc() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +TGSETTLE_GATEWAY_MONGO_USER_B64="$(b64enc "${TGSETTLE_GATEWAY_MONGO_USER}")" +TGSETTLE_GATEWAY_MONGO_PASSWORD_B64="$(b64enc "${TGSETTLE_GATEWAY_MONGO_PASSWORD}")" +NATS_USER_B64="$(b64enc "${NATS_USER}")" +NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")" +NATS_URL_B64="$(b64enc "${NATS_URL}")" + +SSH_OPTS=( + -i /root/.ssh/id_rsa + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o LogLevel=ERROR + -q +) +if [[ "${DEBUG_DEPLOY:-0}" = "1" ]]; then + SSH_OPTS=("${SSH_OPTS[@]/-q/}" -vv) +fi + +RSYNC_FLAGS=(-az --delete) +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && RSYNC_FLAGS=(-avz --delete) + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p ${REMOTE_DIR}/{compose,env}" + +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/compose/ "$REMOTE_TARGET:${REMOTE_DIR}/compose/" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/.env.runtime "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.runtime" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" .env.version "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.version" + +SERVICES_LINE="${SERVICE_NAMES}" + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \ + REMOTE_DIR="$REMOTE_DIR" \ + COMPOSE_FILE="$COMPOSE_FILE" \ + COMPOSE_PROJECT="$TGSETTLE_GATEWAY_COMPOSE_PROJECT" \ + SERVICES_LINE="$SERVICES_LINE" \ + TGSETTLE_GATEWAY_MONGO_USER_B64="$TGSETTLE_GATEWAY_MONGO_USER_B64" \ + TGSETTLE_GATEWAY_MONGO_PASSWORD_B64="$TGSETTLE_GATEWAY_MONGO_PASSWORD_B64" \ + NATS_USER_B64="$NATS_USER_B64" \ + NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \ + NATS_URL_B64="$NATS_URL_B64" \ + bash -s <<'EOSSH' +set -euo pipefail +cd "${REMOTE_DIR}/compose" +set -a +. ../env/.env.runtime +load_kv_file() { + local file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then + local key="${line%%=*}" + local value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ -n "$key" ]]; then + export "$key=$value" + fi + fi + done <"$file" +} +load_kv_file ../env/.env.version +set +a + +if base64 -d >/dev/null 2>&1 <<<'AA=='; then + BASE64_DECODE_FLAG='-d' +else + BASE64_DECODE_FLAG='--decode' +fi + +decode_b64() { + val="$1" + if [[ -z "$val" ]]; then + printf '' + return + fi + printf '%s' "$val" | base64 "${BASE64_DECODE_FLAG}" +} + +TGSETTLE_GATEWAY_MONGO_USER="$(decode_b64 "$TGSETTLE_GATEWAY_MONGO_USER_B64")" +TGSETTLE_GATEWAY_MONGO_PASSWORD="$(decode_b64 "$TGSETTLE_GATEWAY_MONGO_PASSWORD_B64")" +NATS_USER="$(decode_b64 "$NATS_USER_B64")" +NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")" +NATS_URL="$(decode_b64 "$NATS_URL_B64")" + +export TGSETTLE_GATEWAY_MONGO_USER TGSETTLE_GATEWAY_MONGO_PASSWORD NATS_USER NATS_PASSWORD NATS_URL +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" +export COMPOSE_PROJECT_NAME +read -r -a SERVICES <<<"${SERVICES_LINE}" + +pull_cmd=(docker compose -f "$COMPOSE_FILE" pull) +up_cmd=(docker compose -f "$COMPOSE_FILE" up -d --remove-orphans) +ps_cmd=(docker compose -f "$COMPOSE_FILE" ps) +if [[ "${#SERVICES[@]}" -gt 0 ]]; then + pull_cmd+=("${SERVICES[@]}") + up_cmd+=("${SERVICES[@]}") + ps_cmd+=("${SERVICES[@]}") +fi + +"${pull_cmd[@]}" +"${up_cmd[@]}" +"${ps_cmd[@]}" + +date -Is > .last_deploy +logger -t "deploy-${COMPOSE_PROJECT_NAME}" "${COMPOSE_PROJECT_NAME} deployed at $(date -Is) in ${REMOTE_DIR}" +EOSSH diff --git a/ci/scripts/tgsettle/build-image.sh b/ci/scripts/tgsettle/build-image.sh new file mode 100755 index 0000000..cef8f1a --- /dev/null +++ b/ci/scripts/tgsettle/build-image.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +TGSETTLE_GATEWAY_ENV_NAME="${TGSETTLE_GATEWAY_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${TGSETTLE_GATEWAY_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[tgsettle-gateway-build] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +REGISTRY_URL="${REGISTRY_URL:?missing REGISTRY_URL}" +APP_V="${APP_V:?missing APP_V}" +TGSETTLE_GATEWAY_DOCKERFILE="${TGSETTLE_GATEWAY_DOCKERFILE:?missing TGSETTLE_GATEWAY_DOCKERFILE}" +TGSETTLE_GATEWAY_IMAGE_PATH="${TGSETTLE_GATEWAY_IMAGE_PATH:?missing TGSETTLE_GATEWAY_IMAGE_PATH}" + +REGISTRY_HOST="${REGISTRY_URL#http://}" +REGISTRY_HOST="${REGISTRY_HOST#https://}" +REGISTRY_USER="$(cat secrets/REGISTRY_USER)" +REGISTRY_PASSWORD="$(cat secrets/REGISTRY_PASSWORD)" +: "${REGISTRY_USER:?missing registry user}" +: "${REGISTRY_PASSWORD:?missing registry password}" + +mkdir -p /kaniko/.docker +AUTH_B64="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" +cat </kaniko/.docker/config.json +{ + "auths": { + "https://${REGISTRY_HOST}": { "auth": "${AUTH_B64}" } + } +} +EOF + +BUILD_CONTEXT="${TGSETTLE_GATEWAY_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}" +if [ ! -d "${BUILD_CONTEXT}" ]; then + BUILD_CONTEXT="/workspace" +fi + +/kaniko/executor \ + --context "${BUILD_CONTEXT}" \ + --dockerfile "${TGSETTLE_GATEWAY_DOCKERFILE}" \ + --destination "${REGISTRY_URL}/${TGSETTLE_GATEWAY_IMAGE_PATH}:${APP_V}" \ + --build-arg APP_VERSION="${APP_V}" \ + --build-arg GIT_REV="${GIT_REV}" \ + --build-arg BUILD_BRANCH="${BUILD_BRANCH}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg BUILD_USER="${BUILD_USER}" \ + --single-snapshot diff --git a/ci/scripts/tgsettle/deploy.sh b/ci/scripts/tgsettle/deploy.sh new file mode 100755 index 0000000..3f10501 --- /dev/null +++ b/ci/scripts/tgsettle/deploy.sh @@ -0,0 +1,61 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +TGSETTLE_GATEWAY_ENV_NAME="${TGSETTLE_GATEWAY_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${TGSETTLE_GATEWAY_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[tgsettle-gateway-deploy] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +TGSETTLE_GATEWAY_MONGO_SECRET_PATH="${TGSETTLE_GATEWAY_MONGO_SECRET_PATH:?missing TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" +: "${NATS_HOST:?missing NATS_HOST}" +: "${NATS_PORT:?missing NATS_PORT}" + +export TGSETTLE_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" user)" +export TGSETTLE_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" password)" + +export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" +export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)" +export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}" + +bash ci/prod/scripts/bootstrap/network.sh +bash ci/prod/scripts/deploy/tgsettle_gateway.sh