TG settlement service

This commit is contained in:
Stephan D
2026-01-02 14:54:18 +01:00
parent ea1c69f14a
commit 743f683d92
82 changed files with 4693 additions and 503 deletions

View File

@@ -8,7 +8,7 @@ grpc:
enable_health: true
metrics:
address: ":9403"
address: ":9406"
database:
driver: mongodb

View File

@@ -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
View File

@@ -0,0 +1 @@
/mntx-gateway

View 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: []

View 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
View 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=

View 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)
}

View 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
}

View 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)
}

View 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)

View 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")
}
}

View 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)
}

View 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"`
}

View 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)

View 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)

View File

@@ -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)

View 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
}

View File

@@ -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)

View File

@@ -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 {

View 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)."
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View 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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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())

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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 == "" {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 ""

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View 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
}

View File

@@ -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)

View 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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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())

View File

@@ -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,
}
}

View File

@@ -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),
}
}

View File

@@ -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,
}
}

View File

@@ -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(),
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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"`
}

View 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
}

View File

@@ -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"

View File

@@ -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
}

View 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"`
}

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View 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"]

View 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

View 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

View 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
View 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