TG settlement service
This commit is contained in:
@@ -8,7 +8,7 @@ grpc:
|
||||
enable_health: true
|
||||
|
||||
metrics:
|
||||
address: ":9403"
|
||||
address: ":9406"
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
|
||||
@@ -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:
|
||||
|
||||
1
api/gateway/tgsettle/.gitignore
vendored
Normal file
1
api/gateway/tgsettle/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/mntx-gateway
|
||||
40
api/gateway/tgsettle/config.yml
Normal file
40
api/gateway/tgsettle/config.yml
Normal file
@@ -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: []
|
||||
51
api/gateway/tgsettle/go.mod
Normal file
51
api/gateway/tgsettle/go.mod
Normal file
@@ -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
|
||||
)
|
||||
225
api/gateway/tgsettle/go.sum
Normal file
225
api/gateway/tgsettle/go.sum
Normal file
@@ -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=
|
||||
27
api/gateway/tgsettle/internal/appversion/version.go
Normal file
27
api/gateway/tgsettle/internal/appversion/version.go
Normal file
@@ -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)
|
||||
}
|
||||
136
api/gateway/tgsettle/internal/server/internal/serverimp.go
Normal file
136
api/gateway/tgsettle/internal/server/internal/serverimp.go
Normal file
@@ -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
|
||||
}
|
||||
11
api/gateway/tgsettle/internal/server/server.go
Normal file
11
api/gateway/tgsettle/internal/server/server.go
Normal file
@@ -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)
|
||||
}
|
||||
334
api/gateway/tgsettle/internal/service/gateway/service.go
Normal file
334
api/gateway/tgsettle/internal/service/gateway/service.go
Normal file
@@ -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)
|
||||
289
api/gateway/tgsettle/internal/service/gateway/service_test.go
Normal file
289
api/gateway/tgsettle/internal/service/gateway/service_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
17
api/gateway/tgsettle/main.go
Normal file
17
api/gateway/tgsettle/main.go
Normal file
@@ -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)
|
||||
}
|
||||
28
api/gateway/tgsettle/storage/model/execution.go
Normal file
28
api/gateway/tgsettle/storage/model/execution.go
Normal file
@@ -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"`
|
||||
}
|
||||
68
api/gateway/tgsettle/storage/mongo/repository.go
Normal file
68
api/gateway/tgsettle/storage/mongo/repository.go
Normal file
@@ -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)
|
||||
82
api/gateway/tgsettle/storage/mongo/store/payments.go
Normal file
82
api/gateway/tgsettle/storage/mongo/store/payments.go
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
24
api/gateway/tgsettle/storage/storage.go
Normal file
24
api/gateway/tgsettle/storage/storage.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
404
api/notification/internal/server/notificationimp/confirmation.go
Normal file
404
api/notification/internal/server/notificationimp/confirmation.go
Normal file
@@ -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 \"<amount> <currency>\" (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 \"<amount> <currency>\" (e.g., 12.34 USD)."
|
||||
case "missing_amount":
|
||||
return "Amount is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
||||
case "invalid_amount":
|
||||
return "Amount must be a decimal number. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
||||
case "invalid_currency":
|
||||
return "Currency must be a code like USD or EUR. Reply with \"<amount> <currency>\"."
|
||||
default:
|
||||
return "Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
30
api/notification/internal/server/notificationimp/webhook.go
Normal file
30
api/notification/internal/server/notificationimp/webhook.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
69
api/payments/orchestrator/storage/model/plan_template.go
Normal file
69
api/payments/orchestrator/storage/model/plan_template.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
168
api/payments/orchestrator/storage/mongo/store/plan_templates.go
Normal file
168
api/payments/orchestrator/storage/mongo/store/plan_templates.go
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
36
api/pkg/model/confirmation_code.go
Normal file
36
api/pkg/model/confirmation_code.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
22
api/pkg/model/payment_gateway.go
Normal file
22
api/pkg/model/payment_gateway.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -92,6 +92,7 @@ message PaymentEndpoint {
|
||||
CardEndpoint card = 4;
|
||||
}
|
||||
map<string, string> metadata = 10;
|
||||
string instance_id = 11;
|
||||
}
|
||||
|
||||
message FXIntent {
|
||||
@@ -114,6 +115,7 @@ message PaymentIntent {
|
||||
map<string, string> 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
40
ci/prod/compose/tgsettle_gateway.dockerfile
Normal file
40
ci/prod/compose/tgsettle_gateway.dockerfile
Normal file
@@ -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"]
|
||||
53
ci/prod/compose/tgsettle_gateway.yml
Normal file
53
ci/prod/compose/tgsettle_gateway.yml
Normal file
@@ -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
|
||||
147
ci/prod/scripts/deploy/tgsettle_gateway.sh
Executable file
147
ci/prod/scripts/deploy/tgsettle_gateway.sh
Executable file
@@ -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
|
||||
85
ci/scripts/tgsettle/build-image.sh
Executable file
85
ci/scripts/tgsettle/build-image.sh
Executable file
@@ -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 <<EOF >/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
|
||||
61
ci/scripts/tgsettle/deploy.sh
Executable file
61
ci/scripts/tgsettle/deploy.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user