Compare commits
31 Commits
19b7b69bd8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dedde76dd7 | |||
|
|
9e747e7251 | ||
| 33647a0f3d | |||
|
|
890f78a42e | ||
| c0ba167f69 | |||
|
|
3aa5d56cc3 | ||
| 326fc5a885 | |||
|
|
43edbc109d | ||
| 12700c5595 | |||
|
|
4da9e0b522 | ||
| 5d443230f4 | |||
|
|
3e83cc51d7 | ||
| 9c2ef52d07 | |||
|
|
e84854d875 | ||
|
|
2f34b5a827 | ||
| 9a5c087940 | |||
|
|
4fb2e0433c | ||
| cd89171cf0 | |||
|
|
7424ef751c | ||
| fcd831902a | |||
|
|
03f4988a99 | ||
|
|
5684a959f5 | ||
| 94406f65cb | |||
|
|
49ba144d8c | ||
| 2c5f2b8cb1 | |||
|
|
ee28c13558 | ||
| 6a57afc057 | |||
|
|
59c83e414a | ||
|
|
743f683d92 | ||
|
|
ea1c69f14a | ||
|
|
97ba7500dc |
72
.woodpecker/discovery.yml
Normal file
72
.woodpecker/discovery.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- DISCOVERY_IMAGE_PATH: discovery/service
|
||||||
|
DISCOVERY_DOCKERFILE: ci/prod/compose/discovery.dockerfile
|
||||||
|
DISCOVERY_ENV: prod
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: version
|
||||||
|
image: alpine:latest
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail 2>/dev/null || set -eu
|
||||||
|
- apk add --no-cache git
|
||||||
|
- GIT_REV="$(git rev-parse --short HEAD)"
|
||||||
|
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
- APP_V="$(cat version)"
|
||||||
|
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
|
||||||
|
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
|
||||||
|
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
|
||||||
|
|
||||||
|
- name: proto
|
||||||
|
image: golang:alpine
|
||||||
|
depends_on: [ version ]
|
||||||
|
commands:
|
||||||
|
- set -eu
|
||||||
|
- 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
|
||||||
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
|
- name: secrets
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on: [ version ]
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||||
|
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||||
|
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||||
|
- mkdir -p secrets
|
||||||
|
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||||
|
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||||
|
- chmod 600 secrets/SSH_KEY
|
||||||
|
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||||
|
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||||
|
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||||
|
|
||||||
|
- name: build-image
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
depends_on: [ proto, secrets ]
|
||||||
|
commands:
|
||||||
|
- sh ci/scripts/discovery/build-image.sh
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on: [ secrets, build-image ]
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||||
|
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||||
|
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
|
||||||
|
- mkdir -p /root/.ssh
|
||||||
|
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
|
||||||
|
- sh ci/scripts/discovery/deploy.sh
|
||||||
73
.woodpecker/gateway_tgsettle.yml
Normal file
73
.woodpecker/gateway_tgsettle.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- TGSETTLE_GATEWAY_IMAGE_PATH: gateway/tgsettle
|
||||||
|
TGSETTLE_GATEWAY_DOCKERFILE: ci/prod/compose/tgsettle_gateway.dockerfile
|
||||||
|
TGSETTLE_GATEWAY_MONGO_SECRET_PATH: sendico/db
|
||||||
|
TGSETTLE_GATEWAY_ENV: prod
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: version
|
||||||
|
image: alpine:latest
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail 2>/dev/null || set -eu
|
||||||
|
- apk add --no-cache git
|
||||||
|
- GIT_REV="$(git rev-parse --short HEAD)"
|
||||||
|
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
- APP_V="$(cat version)"
|
||||||
|
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
|
||||||
|
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
|
||||||
|
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
|
||||||
|
|
||||||
|
- name: proto
|
||||||
|
image: golang:alpine
|
||||||
|
depends_on: [ version ]
|
||||||
|
commands:
|
||||||
|
- set -eu
|
||||||
|
- 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
|
||||||
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
|
- name: secrets
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on: [ version ]
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||||
|
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||||
|
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||||
|
- mkdir -p secrets
|
||||||
|
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||||
|
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||||
|
- chmod 600 secrets/SSH_KEY
|
||||||
|
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||||
|
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||||
|
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||||
|
|
||||||
|
- name: build-image
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
depends_on: [ proto, secrets ]
|
||||||
|
commands:
|
||||||
|
- sh ci/scripts/tgsettle/build-image.sh
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on: [ secrets, build-image ]
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||||
|
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||||
|
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
|
||||||
|
- mkdir -p /root/.ssh
|
||||||
|
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
|
||||||
|
- sh ci/scripts/tgsettle/deploy.sh
|
||||||
@@ -36,7 +36,7 @@ require (
|
|||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Imp struct {
|
|||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
oracleClient oracleclient.Client
|
oracleClient oracleclient.Client
|
||||||
|
service *fees.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -65,6 +66,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
|||||||
|
|
||||||
func (i *Imp) Shutdown() {
|
func (i *Imp) Shutdown() {
|
||||||
if i.app == nil {
|
if i.app == nil {
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
if i.oracleClient != nil {
|
if i.oracleClient != nil {
|
||||||
_ = i.oracleClient.Close()
|
_ = i.oracleClient.Close()
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,10 @@ func (i *Imp) Shutdown() {
|
|||||||
timeout = i.config.Runtime.ShutdownTimeout()
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
i.app.Shutdown(ctx)
|
i.app.Shutdown(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -121,7 +129,9 @@ func (i *Imp) Start() error {
|
|||||||
if oracleClient != nil {
|
if oracleClient != nil {
|
||||||
opts = append(opts, fees.WithOracleClient(oracleClient))
|
opts = append(opts, fees.WithOracleClient(oracleClient))
|
||||||
}
|
}
|
||||||
return fees.NewService(logger, repo, producer, opts...), nil
|
svc := fees.NewService(logger, repo, producer, opts...)
|
||||||
|
i.service = svc
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/appversion"
|
||||||
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||||
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
|
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
@@ -15,9 +16,11 @@ import (
|
|||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
@@ -36,6 +39,7 @@ type Service struct {
|
|||||||
calculator Calculator
|
calculator Calculator
|
||||||
oracle oracleclient.Client
|
oracle oracleclient.Client
|
||||||
resolver FeeResolver
|
resolver FeeResolver
|
||||||
|
announcer *discovery.Announcer
|
||||||
feesv1.UnimplementedFeeEngineServer
|
feesv1.UnimplementedFeeEngineServer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +66,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svc.startDiscoveryAnnouncer()
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +77,28 @@ func (s *Service) Register(router routers.GRPC) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.announcer != nil {
|
||||||
|
s.announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "BILLING_FEES",
|
||||||
|
Operations: []string{"fee.calc"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
|
||||||
|
s.announcer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||||
var (
|
var (
|
||||||
meta *feesv1.RequestMeta
|
meta *feesv1.RequestMeta
|
||||||
|
|||||||
3
api/discovery/.gitignore
vendored
Normal file
3
api/discovery/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
app
|
||||||
20
api/discovery/config.yml
Normal file
20
api/discovery/config.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9405"
|
||||||
|
|
||||||
|
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: Discovery Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
|
||||||
|
registry:
|
||||||
|
kv_ttl_seconds: 3600
|
||||||
51
api/discovery/go.mod
Normal file
51
api/discovery/go.mod
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
module github.com/tech/sendico/discovery
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
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/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_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.5 // 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.mongodb.org/mongo-driver v1.17.6 // 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/discovery/go.sum
Normal file
225
api/discovery/go.sum
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||||
|
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
|
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||||
|
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||||
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
27
api/discovery/internal/appversion/version.go
Normal file
27
api/discovery/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package appversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/pkg/version"
|
||||||
|
vf "github.com/tech/sendico/pkg/version/factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build information. Populated at build-time.
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
Revision string
|
||||||
|
Branch string
|
||||||
|
BuildUser string
|
||||||
|
BuildDate string
|
||||||
|
)
|
||||||
|
|
||||||
|
func Create() version.Printer {
|
||||||
|
vi := version.Info{
|
||||||
|
Program: "Sendico Discovery Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&vi)
|
||||||
|
}
|
||||||
52
api/discovery/internal/server/internal/config.go
Normal file
52
api/discovery/internal/server/internal/config.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultMetricsAddress = ":9405"
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
|
||||||
|
Messaging *msg.Config `yaml:"messaging"`
|
||||||
|
Metrics *metricsConfig `yaml:"metrics"`
|
||||||
|
Registry *registryConfig `yaml:"registry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type metricsConfig struct {
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registryConfig struct {
|
||||||
|
KVTTLSeconds *int `yaml:"kv_ttl_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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{}
|
||||||
|
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.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" {
|
||||||
|
cfg.Metrics.Address = defaultMetricsAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
69
api/discovery/internal/server/internal/discovery.go
Normal file
69
api/discovery/internal/server/internal/discovery.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/discovery/internal/appversion"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *Imp) startDiscovery(cfg *config) error {
|
||||||
|
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
|
||||||
|
return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging")
|
||||||
|
}
|
||||||
|
|
||||||
|
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
||||||
|
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
|
||||||
|
|
||||||
|
registry := discovery.NewRegistry()
|
||||||
|
var registryOpts []discovery.RegistryOption
|
||||||
|
if cfg.Registry != nil && cfg.Registry.KVTTLSeconds != nil {
|
||||||
|
ttlSeconds := *cfg.Registry.KVTTLSeconds
|
||||||
|
if ttlSeconds < 0 {
|
||||||
|
i.logger.Warn("Discovery registry TTL is negative, disabling TTL", zap.Int("ttl_seconds", ttlSeconds))
|
||||||
|
ttlSeconds = 0
|
||||||
|
}
|
||||||
|
registryOpts = append(registryOpts, discovery.WithRegistryKVTTL(time.Duration(ttlSeconds)*time.Second))
|
||||||
|
}
|
||||||
|
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery), registryOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc.Start()
|
||||||
|
i.registrySvc = svc
|
||||||
|
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "DISCOVERY",
|
||||||
|
InstanceID: discovery.InstanceID(),
|
||||||
|
Operations: []string{"discovery.lookup"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
i.announcer = discovery.NewAnnouncer(i.logger, producer, string(mservice.Discovery), announce)
|
||||||
|
i.announcer.Start()
|
||||||
|
|
||||||
|
i.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) stopDiscovery() {
|
||||||
|
if i == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.announcer != nil {
|
||||||
|
i.announcer.Stop()
|
||||||
|
i.announcer = nil
|
||||||
|
}
|
||||||
|
if i.registrySvc != nil {
|
||||||
|
i.registrySvc.Stop()
|
||||||
|
i.registrySvc = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
85
api/discovery/internal/server/internal/metrics.go
Normal file
85
api/discovery/internal/server/internal/metrics.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *Imp) startMetrics(cfg *metricsConfig) {
|
||||||
|
if i == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
address := ""
|
||||||
|
if cfg != nil {
|
||||||
|
address = strings.TrimSpace(cfg.Address)
|
||||||
|
}
|
||||||
|
if address == "" {
|
||||||
|
i.logger.Info("Metrics endpoint disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
var healthRouter routers.Health
|
||||||
|
if hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, ""); err != nil {
|
||||||
|
i.logger.Warn("Failed to initialise health router", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
hr.SetStatus(health.SSStarting)
|
||||||
|
healthRouter = hr
|
||||||
|
}
|
||||||
|
|
||||||
|
i.metricsHealth = healthRouter
|
||||||
|
i.metricsSrv = &http.Server{
|
||||||
|
Addr: address,
|
||||||
|
Handler: router,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if healthRouter != nil {
|
||||||
|
healthRouter.SetStatus(health.SSRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
|
||||||
|
if err := i.metricsSrv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
|
||||||
|
if healthRouter != nil {
|
||||||
|
healthRouter.SetStatus(health.SSTerminating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) shutdownMetrics(ctx context.Context) {
|
||||||
|
if i.metricsHealth != nil {
|
||||||
|
i.metricsHealth.SetStatus(health.SSTerminating)
|
||||||
|
i.metricsHealth.Finish()
|
||||||
|
i.metricsHealth = nil
|
||||||
|
}
|
||||||
|
if i.metricsSrv == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := i.metricsSrv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
i.logger.Warn("Failed to stop metrics server", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
i.logger.Info("Metrics server stopped")
|
||||||
|
}
|
||||||
|
i.metricsSrv = nil
|
||||||
|
}
|
||||||
109
api/discovery/internal/server/internal/serverimp.go
Normal file
109
api/discovery/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) Start() error {
|
||||||
|
i.initStopChannels()
|
||||||
|
defer i.closeDone()
|
||||||
|
|
||||||
|
i.logger.Info("Starting discovery service", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
|
||||||
|
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
messagingDriver := "none"
|
||||||
|
if cfg.Messaging != nil {
|
||||||
|
messagingDriver = string(cfg.Messaging.Driver)
|
||||||
|
}
|
||||||
|
metricsAddress := ""
|
||||||
|
if cfg.Metrics != nil {
|
||||||
|
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
|
||||||
|
}
|
||||||
|
if metricsAddress == "" {
|
||||||
|
metricsAddress = "disabled"
|
||||||
|
}
|
||||||
|
i.logger.Info("Discovery config loaded", zap.String("messaging_driver", messagingDriver), zap.String("metrics_address", metricsAddress))
|
||||||
|
|
||||||
|
i.startMetrics(cfg.Metrics)
|
||||||
|
|
||||||
|
if err := i.startDiscovery(cfg); err != nil {
|
||||||
|
i.stopDiscovery()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout())
|
||||||
|
i.shutdownMetrics(ctx)
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
i.logger.Info("Discovery service ready", zap.String("messaging_driver", messagingDriver))
|
||||||
|
|
||||||
|
<-i.stopCh
|
||||||
|
i.logger.Info("Discovery service stop signal received")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Shutdown() {
|
||||||
|
timeout := i.shutdownTimeout()
|
||||||
|
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
|
||||||
|
|
||||||
|
i.stopDiscovery()
|
||||||
|
i.signalStop()
|
||||||
|
|
||||||
|
if i.doneCh != nil {
|
||||||
|
<-i.doneCh
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
i.shutdownMetrics(ctx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
i.logger.Info("Discovery service stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initStopChannels() {
|
||||||
|
if i.stopCh == nil {
|
||||||
|
i.stopCh = make(chan struct{})
|
||||||
|
}
|
||||||
|
if i.doneCh == nil {
|
||||||
|
i.doneCh = make(chan struct{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) signalStop() {
|
||||||
|
i.stopOnce.Do(func() {
|
||||||
|
if i.stopCh != nil {
|
||||||
|
close(i.stopCh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) closeDone() {
|
||||||
|
i.doneOnce.Do(func() {
|
||||||
|
if i.doneCh != nil {
|
||||||
|
close(i.doneCh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) shutdownTimeout() time.Duration {
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
return i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
return 15 * time.Second
|
||||||
|
}
|
||||||
28
api/discovery/internal/server/internal/types.go
Normal file
28
api/discovery/internal/server/internal/types.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
|
||||||
|
config *config
|
||||||
|
registrySvc *discovery.RegistryService
|
||||||
|
announcer *discovery.Announcer
|
||||||
|
|
||||||
|
metricsSrv *http.Server
|
||||||
|
metricsHealth routers.Health
|
||||||
|
|
||||||
|
stopOnce sync.Once
|
||||||
|
doneOnce sync.Once
|
||||||
|
stopCh chan struct{}
|
||||||
|
doneCh chan struct{}
|
||||||
|
}
|
||||||
11
api/discovery/internal/server/server.go
Normal file
11
api/discovery/internal/server/server.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/discovery/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)
|
||||||
|
}
|
||||||
17
api/discovery/main.go
Normal file
17
api/discovery/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/discovery/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/discovery/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("main", appversion.Create(), factory)
|
||||||
|
}
|
||||||
@@ -49,6 +49,18 @@ metrics:
|
|||||||
enabled: true
|
enabled: true
|
||||||
address: ":9102"
|
address: ":9102"
|
||||||
|
|
||||||
|
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: FX Ingestor
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
|
||||||
database:
|
database:
|
||||||
driver: mongodb
|
driver: mongodb
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ require (
|
|||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import (
|
|||||||
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
||||||
"github.com/tech/sendico/pkg/api/routers/health"
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -68,6 +71,24 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var announcer *discovery.Announcer
|
||||||
|
if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" {
|
||||||
|
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("Failed to initialize discovery broker", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker)
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "FX_INGESTOR",
|
||||||
|
Operations: []string{"fx.ingest"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce)
|
||||||
|
announcer.Start()
|
||||||
|
defer announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
|
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
|
||||||
metricsSrv.SetStatus(health.SSRunning)
|
metricsSrv.SetStatus(health.SSRunning)
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import (
|
|||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/messaging"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultPollInterval = 30 * time.Second
|
const defaultPollInterval = 30 * time.Second
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||||
Market MarketConfig `yaml:"market"`
|
Market MarketConfig `yaml:"market"`
|
||||||
Database *db.Config `yaml:"database"`
|
Database *db.Config `yaml:"database"`
|
||||||
Metrics *MetricsConfig `yaml:"metrics"`
|
Metrics *MetricsConfig `yaml:"metrics"`
|
||||||
|
Messaging *messaging.Config `yaml:"messaging"`
|
||||||
|
|
||||||
pairs []Pair
|
pairs []Pair
|
||||||
pairsBySource map[mmodel.Driver][]PairConfig
|
pairsBySource map[mmodel.Driver][]PairConfig
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/tech/sendico/fx/ingestor/internal/app"
|
"github.com/tech/sendico/fx/ingestor/internal/app"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/signalctx"
|
"github.com/tech/sendico/fx/ingestor/internal/signalctx"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
lf "github.com/tech/sendico/pkg/mlogger/factory"
|
lf "github.com/tech/sendico/pkg/mlogger/factory"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,7 @@ func main() {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
|
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
|
||||||
|
logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
|
||||||
defer logger.Sync()
|
defer logger.Sync()
|
||||||
|
|
||||||
av := appversion.Create()
|
av := appversion.Create()
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ require (
|
|||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ type Imp struct {
|
|||||||
file string
|
file string
|
||||||
debug bool
|
debug bool
|
||||||
|
|
||||||
config *grpcapp.Config
|
config *grpcapp.Config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
|
service *oracle.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
@@ -38,6 +39,9 @@ func (i *Imp) Shutdown() {
|
|||||||
if i.app == nil {
|
if i.app == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
timeout := 15 * time.Second
|
timeout := 15 * time.Second
|
||||||
if i.config != nil && i.config.Runtime != nil {
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
timeout = i.config.Runtime.ShutdownTimeout()
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
@@ -59,10 +63,12 @@ func (i *Imp) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
return oracle.NewService(logger, repo, producer), nil
|
svc := oracle.NewService(logger, repo, producer)
|
||||||
|
i.service = svc
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "fx", cfg, i.debug, repoFactory, serviceFactory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/oracle/internal/appversion"
|
||||||
"github.com/tech/sendico/fx/storage"
|
"github.com/tech/sendico/fx/storage"
|
||||||
"github.com/tech/sendico/fx/storage/model"
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
pmessaging "github.com/tech/sendico/pkg/messaging"
|
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
@@ -36,19 +38,22 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
producer pmessaging.Producer
|
producer pmessaging.Producer
|
||||||
|
announcer *discovery.Announcer
|
||||||
oraclev1.UnimplementedOracleServer
|
oraclev1.UnimplementedOracleServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
|
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
|
||||||
initMetrics()
|
initMetrics()
|
||||||
return &Service{
|
svc := &Service{
|
||||||
logger: logger.Named("oracle"),
|
logger: logger.Named("oracle"),
|
||||||
storage: repo,
|
storage: repo,
|
||||||
producer: prod,
|
producer: prod,
|
||||||
}
|
}
|
||||||
|
svc.startDiscoveryAnnouncer()
|
||||||
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Register(router routers.GRPC) error {
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
@@ -57,6 +62,28 @@ func (s *Service) Register(router routers.GRPC) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.announcer != nil {
|
||||||
|
s.announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "FX_ORACLE",
|
||||||
|
Operations: []string{"fx.quote"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FXOracle), announce)
|
||||||
|
s.announcer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
|
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
responder := s.getQuoteResponder(ctx, req)
|
responder := s.getQuoteResponder(ctx, req)
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const chainConnectorID = "chain"
|
||||||
|
|
||||||
// Client exposes typed helpers around the chain gateway gRPC API.
|
// Client exposes typed helpers around the chain gateway gRPC API.
|
||||||
type Client interface {
|
type Client interface {
|
||||||
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
|
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
|
||||||
@@ -29,23 +36,21 @@ type Client interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
type grpcGatewayClient interface {
|
type grpcConnectorClient interface {
|
||||||
CreateManagedWallet(ctx context.Context, in *chainv1.CreateManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.CreateManagedWalletResponse, error)
|
GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error)
|
||||||
GetManagedWallet(ctx context.Context, in *chainv1.GetManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.GetManagedWalletResponse, error)
|
OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error)
|
||||||
ListManagedWallets(ctx context.Context, in *chainv1.ListManagedWalletsRequest, opts ...grpc.CallOption) (*chainv1.ListManagedWalletsResponse, error)
|
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
|
||||||
GetWalletBalance(ctx context.Context, in *chainv1.GetWalletBalanceRequest, opts ...grpc.CallOption) (*chainv1.GetWalletBalanceResponse, error)
|
ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error)
|
||||||
SubmitTransfer(ctx context.Context, in *chainv1.SubmitTransferRequest, opts ...grpc.CallOption) (*chainv1.SubmitTransferResponse, error)
|
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
|
||||||
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
|
||||||
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
|
||||||
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error)
|
||||||
ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error)
|
|
||||||
EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type chainGatewayClient struct {
|
type chainGatewayClient struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
client grpcGatewayClient
|
client grpcConnectorClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// New dials the chain gateway endpoint and returns a ready client.
|
// New dials the chain gateway endpoint and returns a ready client.
|
||||||
@@ -75,12 +80,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
|||||||
return &chainGatewayClient{
|
return &chainGatewayClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
client: chainv1.NewChainGatewayServiceClient(conn),
|
client: connectorv1.NewConnectorServiceClient(conn),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithClient injects a pre-built gateway client (useful for tests).
|
// NewWithClient injects a pre-built gateway client (useful for tests).
|
||||||
func NewWithClient(cfg Config, gc grpcGatewayClient) Client {
|
func NewWithClient(cfg Config, gc grpcConnectorClient) Client {
|
||||||
cfg.setDefaults()
|
cfg.setDefaults()
|
||||||
return &chainGatewayClient{
|
return &chainGatewayClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -98,61 +103,213 @@ func (c *chainGatewayClient) Close() error {
|
|||||||
func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
|
func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.CreateManagedWallet(ctx, req)
|
|
||||||
|
params, err := walletParamsFromRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
label := ""
|
||||||
|
if desc := req.GetDescribable(); desc != nil {
|
||||||
|
label = strings.TrimSpace(desc.GetName())
|
||||||
|
}
|
||||||
|
resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||||
|
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
|
||||||
|
Asset: assetStringFromChainAsset(req.GetAsset()),
|
||||||
|
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
|
||||||
|
Label: label,
|
||||||
|
Params: params,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetError())
|
||||||
|
}
|
||||||
|
wallet := managedWalletFromAccount(resp.GetAccount())
|
||||||
|
return &chainv1.CreateManagedWalletResponse{Wallet: wallet}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
|
func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.GetManagedWallet(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
|
||||||
|
}
|
||||||
|
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chainv1.GetManagedWalletResponse{Wallet: managedWalletFromAccount(resp.GetAccount())}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
|
func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.ListManagedWallets(ctx, req)
|
assetString := ""
|
||||||
|
ownerRef := ""
|
||||||
|
var page *paginationv1.CursorPageRequest
|
||||||
|
if req != nil {
|
||||||
|
assetString = assetStringFromChainAsset(req.GetAsset())
|
||||||
|
ownerRef = strings.TrimSpace(req.GetOwnerRef())
|
||||||
|
page = req.GetPage()
|
||||||
|
}
|
||||||
|
resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{
|
||||||
|
OwnerRef: ownerRef,
|
||||||
|
Asset: assetString,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wallets := make([]*chainv1.ManagedWallet, 0, len(resp.GetAccounts()))
|
||||||
|
for _, account := range resp.GetAccounts() {
|
||||||
|
wallets = append(wallets, managedWalletFromAccount(account))
|
||||||
|
}
|
||||||
|
return &chainv1.ListManagedWalletsResponse{Wallets: wallets, Page: resp.GetPage()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
|
func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.GetWalletBalance(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
|
||||||
|
}
|
||||||
|
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
balance := resp.GetBalance()
|
||||||
|
if balance == nil {
|
||||||
|
return nil, merrors.Internal("chain-gateway: balance response missing")
|
||||||
|
}
|
||||||
|
return &chainv1.GetWalletBalanceResponse{Balance: &chainv1.WalletBalance{
|
||||||
|
Available: balance.GetAvailable(),
|
||||||
|
PendingInbound: balance.GetPendingInbound(),
|
||||||
|
PendingOutbound: balance.GetPendingOutbound(),
|
||||||
|
CalculatedAt: balance.GetCalculatedAt(),
|
||||||
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.SubmitTransfer(ctx, req)
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: request is required")
|
||||||
|
}
|
||||||
|
operation, err := operationFromTransfer(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetReceipt().GetError())
|
||||||
|
}
|
||||||
|
transfer := transferFromReceipt(req, resp.GetReceipt())
|
||||||
|
return &chainv1.SubmitTransferResponse{Transfer: transfer}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
|
func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.GetTransfer(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetTransferRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: transfer_ref is required")
|
||||||
|
}
|
||||||
|
resp, err := c.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetTransferRef())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chainv1.GetTransferResponse{Transfer: transferFromOperation(resp.GetOperation())}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
|
func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.ListTransfers(ctx, req)
|
source := ""
|
||||||
|
status := chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
|
||||||
|
var page *paginationv1.CursorPageRequest
|
||||||
|
if req != nil {
|
||||||
|
source = strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
|
status = req.GetStatus()
|
||||||
|
page = req.GetPage()
|
||||||
|
}
|
||||||
|
resp, err := c.client.ListOperations(ctx, &connectorv1.ListOperationsRequest{
|
||||||
|
AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: source},
|
||||||
|
Status: operationStatusFromTransfer(status),
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transfers := make([]*chainv1.Transfer, 0, len(resp.GetOperations()))
|
||||||
|
for _, op := range resp.GetOperations() {
|
||||||
|
transfers = append(transfers, transferFromOperation(op))
|
||||||
|
}
|
||||||
|
return &chainv1.ListTransfersResponse{Transfers: transfers, Page: resp.GetPage()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
|
func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.EstimateTransferFee(ctx, req)
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: request is required")
|
||||||
|
}
|
||||||
|
operation, err := feeEstimateOperation(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetReceipt().GetError())
|
||||||
|
}
|
||||||
|
return estimateFromReceipt(resp.GetReceipt()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.ComputeGasTopUp(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
|
||||||
|
}
|
||||||
|
operation, err := gasTopUpComputeOperation(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetReceipt().GetError())
|
||||||
|
}
|
||||||
|
return computeGasTopUpFromReceipt(resp.GetReceipt()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.EnsureGasTopUp(ctx, req)
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: request is required")
|
||||||
|
}
|
||||||
|
operation, err := gasTopUpEnsureOperation(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetReceipt().GetError())
|
||||||
|
}
|
||||||
|
return ensureGasTopUpFromReceipt(resp.GetReceipt()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
@@ -162,3 +319,507 @@ func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context,
|
|||||||
}
|
}
|
||||||
return context.WithTimeout(ctx, timeout)
|
return context.WithTimeout(ctx, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func walletParamsFromRequest(req *chainv1.CreateManagedWalletRequest) (*structpb.Struct, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
|
||||||
|
}
|
||||||
|
if asset := req.GetAsset(); asset != nil {
|
||||||
|
params["network"] = asset.GetChain().String()
|
||||||
|
params["token_symbol"] = strings.TrimSpace(asset.GetTokenSymbol())
|
||||||
|
params["contract_address"] = strings.TrimSpace(asset.GetContractAddress())
|
||||||
|
}
|
||||||
|
desc := ""
|
||||||
|
if describable := req.GetDescribable(); describable != nil {
|
||||||
|
desc = strings.TrimSpace(describable.GetDescription())
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
params["description"] = desc
|
||||||
|
}
|
||||||
|
if len(req.GetMetadata()) > 0 {
|
||||||
|
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||||
|
}
|
||||||
|
return structpb.NewStruct(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedWalletFromAccount(account *connectorv1.Account) *chainv1.ManagedWallet {
|
||||||
|
if account == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
details := map[string]interface{}{}
|
||||||
|
if account.GetProviderDetails() != nil {
|
||||||
|
details = account.GetProviderDetails().AsMap()
|
||||||
|
}
|
||||||
|
walletRef := ""
|
||||||
|
if ref := account.GetRef(); ref != nil {
|
||||||
|
walletRef = strings.TrimSpace(ref.GetAccountId())
|
||||||
|
}
|
||||||
|
if v := stringFromDetails(details, "wallet_ref"); v != "" {
|
||||||
|
walletRef = v
|
||||||
|
}
|
||||||
|
organizationRef := stringFromDetails(details, "organization_ref")
|
||||||
|
ownerRef := stringFromDetails(details, "owner_ref")
|
||||||
|
if ownerRef == "" {
|
||||||
|
ownerRef = strings.TrimSpace(account.GetOwnerRef())
|
||||||
|
}
|
||||||
|
asset := &chainv1.Asset{
|
||||||
|
Chain: chainNetworkFromString(stringFromDetails(details, "network")),
|
||||||
|
TokenSymbol: strings.TrimSpace(stringFromDetails(details, "token_symbol")),
|
||||||
|
ContractAddress: strings.TrimSpace(stringFromDetails(details, "contract_address")),
|
||||||
|
}
|
||||||
|
if asset.GetTokenSymbol() == "" {
|
||||||
|
asset.TokenSymbol = strings.TrimSpace(tokenFromAssetString(account.GetAsset()))
|
||||||
|
}
|
||||||
|
describable := account.GetDescribable()
|
||||||
|
label := strings.TrimSpace(account.GetLabel())
|
||||||
|
if describable == nil {
|
||||||
|
if label != "" {
|
||||||
|
describable = &describablev1.Describable{Name: label}
|
||||||
|
}
|
||||||
|
} else if strings.TrimSpace(describable.GetName()) == "" && label != "" {
|
||||||
|
desc := strings.TrimSpace(describable.GetDescription())
|
||||||
|
if desc == "" {
|
||||||
|
describable = &describablev1.Describable{Name: label}
|
||||||
|
} else {
|
||||||
|
describable = &describablev1.Describable{Name: label, Description: &desc}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &chainv1.ManagedWallet{
|
||||||
|
WalletRef: walletRef,
|
||||||
|
OrganizationRef: organizationRef,
|
||||||
|
OwnerRef: ownerRef,
|
||||||
|
Asset: asset,
|
||||||
|
DepositAddress: stringFromDetails(details, "deposit_address"),
|
||||||
|
Status: managedWalletStatusFromAccount(account.GetState()),
|
||||||
|
CreatedAt: account.GetCreatedAt(),
|
||||||
|
UpdatedAt: account.GetUpdatedAt(),
|
||||||
|
Describable: describable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Operation, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: request is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetIdempotencyKey()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: idempotency_key is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
|
||||||
|
}
|
||||||
|
if req.GetDestination() == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
|
||||||
|
}
|
||||||
|
if req.GetAmount() == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: amount is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
|
||||||
|
"client_reference": strings.TrimSpace(req.GetClientReference()),
|
||||||
|
}
|
||||||
|
if memo := strings.TrimSpace(req.GetDestination().GetMemo()); memo != "" {
|
||||||
|
params["destination_memo"] = memo
|
||||||
|
}
|
||||||
|
if len(req.GetMetadata()) > 0 {
|
||||||
|
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||||
|
}
|
||||||
|
if len(req.GetFees()) > 0 {
|
||||||
|
params["fees"] = feesToInterface(req.GetFees())
|
||||||
|
}
|
||||||
|
|
||||||
|
op := &connectorv1.Operation{
|
||||||
|
Type: connectorv1.OperationType_TRANSFER,
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||||
|
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
|
||||||
|
Money: req.GetAmount(),
|
||||||
|
Params: structFromMap(params),
|
||||||
|
}
|
||||||
|
to, err := destinationToParty(req.GetDestination())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
op.To = to
|
||||||
|
return op, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func destinationToParty(dest *chainv1.TransferDestination) (*connectorv1.OperationParty, error) {
|
||||||
|
if dest == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
|
||||||
|
}
|
||||||
|
switch d := dest.GetDestination().(type) {
|
||||||
|
case *chainv1.TransferDestination_ManagedWalletRef:
|
||||||
|
return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(d.ManagedWalletRef)}}}, nil
|
||||||
|
case *chainv1.TransferDestination_ExternalAddress:
|
||||||
|
return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{ExternalRef: strings.TrimSpace(d.ExternalAddress)}}}, nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferFromReceipt(req *chainv1.SubmitTransferRequest, receipt *connectorv1.OperationReceipt) *chainv1.Transfer {
|
||||||
|
transfer := &chainv1.Transfer{}
|
||||||
|
if req != nil {
|
||||||
|
transfer.IdempotencyKey = strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
|
transfer.Destination = req.GetDestination()
|
||||||
|
transfer.RequestedAmount = req.GetAmount()
|
||||||
|
transfer.NetAmount = req.GetAmount()
|
||||||
|
}
|
||||||
|
if receipt != nil {
|
||||||
|
transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId())
|
||||||
|
transfer.Status = transferStatusFromOperation(receipt.GetStatus())
|
||||||
|
transfer.TransactionHash = strings.TrimSpace(receipt.GetProviderRef())
|
||||||
|
}
|
||||||
|
return transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferFromOperation(op *connectorv1.Operation) *chainv1.Transfer {
|
||||||
|
if op == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
transfer := &chainv1.Transfer{
|
||||||
|
TransferRef: strings.TrimSpace(op.GetOperationId()),
|
||||||
|
IdempotencyKey: strings.TrimSpace(op.GetOperationId()),
|
||||||
|
RequestedAmount: op.GetMoney(),
|
||||||
|
NetAmount: op.GetMoney(),
|
||||||
|
Status: transferStatusFromOperation(op.GetStatus()),
|
||||||
|
TransactionHash: strings.TrimSpace(op.GetProviderRef()),
|
||||||
|
CreatedAt: op.GetCreatedAt(),
|
||||||
|
UpdatedAt: op.GetUpdatedAt(),
|
||||||
|
}
|
||||||
|
if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
|
||||||
|
transfer.SourceWalletRef = strings.TrimSpace(from.GetAccount().GetAccountId())
|
||||||
|
}
|
||||||
|
if to := op.GetTo(); to != nil {
|
||||||
|
if account := to.GetAccount(); account != nil {
|
||||||
|
transfer.Destination = &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}
|
||||||
|
}
|
||||||
|
if external := to.GetExternal(); external != nil {
|
||||||
|
transfer.Destination = &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(external.GetExternalRef())}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
func feeEstimateOperation(req *chainv1.EstimateTransferFeeRequest) (*connectorv1.Operation, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: request is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
|
||||||
|
}
|
||||||
|
if req.GetDestination() == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
|
||||||
|
}
|
||||||
|
if req.GetAmount() == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: amount is required")
|
||||||
|
}
|
||||||
|
params := map[string]interface{}{}
|
||||||
|
op := &connectorv1.Operation{
|
||||||
|
Type: connectorv1.OperationType_FEE_ESTIMATE,
|
||||||
|
IdempotencyKey: feeEstimateKey(req),
|
||||||
|
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
|
||||||
|
Money: req.GetAmount(),
|
||||||
|
Params: structFromMap(params),
|
||||||
|
}
|
||||||
|
to, err := destinationToParty(req.GetDestination())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
op.To = to
|
||||||
|
return op, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.EstimateTransferFeeResponse {
|
||||||
|
resp := &chainv1.EstimateTransferFeeResponse{}
|
||||||
|
if receipt == nil || receipt.GetResult() == nil {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
data := receipt.GetResult().AsMap()
|
||||||
|
if networkFee, ok := data["network_fee"].(map[string]interface{}); ok {
|
||||||
|
amount := strings.TrimSpace(fmt.Sprint(networkFee["amount"]))
|
||||||
|
currency := strings.TrimSpace(fmt.Sprint(networkFee["currency"]))
|
||||||
|
if amount != "" && currency != "" {
|
||||||
|
resp.NetworkFee = &moneyv1.Money{Amount: amount, Currency: currency}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx, ok := data["estimation_context"].(string); ok {
|
||||||
|
resp.EstimationContext = strings.TrimSpace(ctx)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func gasTopUpComputeOperation(req *chainv1.ComputeGasTopUpRequest) (*connectorv1.Operation, error) {
|
||||||
|
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
|
||||||
|
}
|
||||||
|
fee := req.GetEstimatedTotalFee()
|
||||||
|
if fee == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: estimated_total_fee is required")
|
||||||
|
}
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"mode": "compute",
|
||||||
|
"estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()},
|
||||||
|
}
|
||||||
|
return &connectorv1.Operation{
|
||||||
|
Type: connectorv1.OperationType_GAS_TOPUP,
|
||||||
|
IdempotencyKey: fmt.Sprintf("gas_topup_compute:%s:%s", strings.TrimSpace(req.GetWalletRef()), strings.TrimSpace(fee.GetAmount())),
|
||||||
|
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}}},
|
||||||
|
Params: structFromMap(params),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gasTopUpEnsureOperation(req *chainv1.EnsureGasTopUpRequest) (*connectorv1.Operation, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: request is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetIdempotencyKey()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: idempotency_key is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetTargetWalletRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: target_wallet_ref is required")
|
||||||
|
}
|
||||||
|
fee := req.GetEstimatedTotalFee()
|
||||||
|
if fee == nil {
|
||||||
|
return nil, merrors.InvalidArgument("chain-gateway: estimated_total_fee is required")
|
||||||
|
}
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"mode": "ensure",
|
||||||
|
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
|
||||||
|
"target_wallet_ref": strings.TrimSpace(req.GetTargetWalletRef()),
|
||||||
|
"client_reference": strings.TrimSpace(req.GetClientReference()),
|
||||||
|
"estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()},
|
||||||
|
}
|
||||||
|
if len(req.GetMetadata()) > 0 {
|
||||||
|
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||||
|
}
|
||||||
|
return &connectorv1.Operation{
|
||||||
|
Type: connectorv1.OperationType_GAS_TOPUP,
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||||
|
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
|
||||||
|
Params: structFromMap(params),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeGasTopUpFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.ComputeGasTopUpResponse {
|
||||||
|
resp := &chainv1.ComputeGasTopUpResponse{}
|
||||||
|
if receipt == nil || receipt.GetResult() == nil {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
data := receipt.GetResult().AsMap()
|
||||||
|
if amount, ok := data["topup_amount"].(map[string]interface{}); ok {
|
||||||
|
resp.TopupAmount = &moneyv1.Money{
|
||||||
|
Amount: strings.TrimSpace(fmt.Sprint(amount["amount"])),
|
||||||
|
Currency: strings.TrimSpace(fmt.Sprint(amount["currency"])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if capHit, ok := data["cap_hit"].(bool); ok {
|
||||||
|
resp.CapHit = capHit
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureGasTopUpFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.EnsureGasTopUpResponse {
|
||||||
|
resp := &chainv1.EnsureGasTopUpResponse{}
|
||||||
|
if receipt == nil || receipt.GetResult() == nil {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
data := receipt.GetResult().AsMap()
|
||||||
|
if amount, ok := data["topup_amount"].(map[string]interface{}); ok {
|
||||||
|
resp.TopupAmount = &moneyv1.Money{
|
||||||
|
Amount: strings.TrimSpace(fmt.Sprint(amount["amount"])),
|
||||||
|
Currency: strings.TrimSpace(fmt.Sprint(amount["currency"])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if capHit, ok := data["cap_hit"].(bool); ok {
|
||||||
|
resp.CapHit = capHit
|
||||||
|
}
|
||||||
|
if transferRef, ok := data["transfer_ref"].(string); ok {
|
||||||
|
resp.Transfer = &chainv1.Transfer{TransferRef: strings.TrimSpace(transferRef)}
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func feeEstimateKey(req *chainv1.EstimateTransferFeeRequest) string {
|
||||||
|
if req == nil || req.GetAmount() == nil {
|
||||||
|
return "fee_estimate"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("fee_estimate:%s:%s:%s", strings.TrimSpace(req.GetSourceWalletRef()), strings.TrimSpace(req.GetAmount().GetCurrency()), strings.TrimSpace(req.GetAmount().GetAmount()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectorError(err *connectorv1.ConnectorError) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(err.GetMessage())
|
||||||
|
switch err.GetCode() {
|
||||||
|
case connectorv1.ErrorCode_INVALID_PARAMS:
|
||||||
|
return merrors.InvalidArgument(msg)
|
||||||
|
case connectorv1.ErrorCode_NOT_FOUND:
|
||||||
|
return merrors.NoData(msg)
|
||||||
|
case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
|
||||||
|
return merrors.NotImplemented(msg)
|
||||||
|
case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
|
||||||
|
return merrors.Internal(msg)
|
||||||
|
default:
|
||||||
|
return merrors.Internal(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func structFromMap(data map[string]interface{}) *structpb.Struct {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result, err := structpb.NewStruct(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStringToInterface(input map[string]string) map[string]interface{} {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{}, len(input))
|
||||||
|
for k, v := range input {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func feesToInterface(fees []*chainv1.ServiceFeeBreakdown) []interface{} {
|
||||||
|
if len(fees) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]interface{}, 0, len(fees))
|
||||||
|
for _, fee := range fees {
|
||||||
|
if fee == nil || fee.GetAmount() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"fee_code": strings.TrimSpace(fee.GetFeeCode()),
|
||||||
|
"description": strings.TrimSpace(fee.GetDescription()),
|
||||||
|
"amount": strings.TrimSpace(fee.GetAmount().GetAmount()),
|
||||||
|
"currency": strings.TrimSpace(fee.GetAmount().GetCurrency()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromDetails(details map[string]interface{}, key string) string {
|
||||||
|
if details == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value, ok := details[key]; ok {
|
||||||
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedWalletStatusFromAccount(state connectorv1.AccountState) chainv1.ManagedWalletStatus {
|
||||||
|
switch state {
|
||||||
|
case connectorv1.AccountState_ACCOUNT_ACTIVE:
|
||||||
|
return chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
|
||||||
|
case connectorv1.AccountState_ACCOUNT_SUSPENDED:
|
||||||
|
return chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
|
||||||
|
case connectorv1.AccountState_ACCOUNT_CLOSED:
|
||||||
|
return chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
|
||||||
|
default:
|
||||||
|
return chainv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
|
||||||
|
switch status {
|
||||||
|
case connectorv1.OperationStatus_CONFIRMED:
|
||||||
|
return chainv1.TransferStatus_TRANSFER_CONFIRMED
|
||||||
|
case connectorv1.OperationStatus_FAILED:
|
||||||
|
return chainv1.TransferStatus_TRANSFER_FAILED
|
||||||
|
case connectorv1.OperationStatus_CANCELED:
|
||||||
|
return chainv1.TransferStatus_TRANSFER_CANCELLED
|
||||||
|
default:
|
||||||
|
return chainv1.TransferStatus_TRANSFER_PENDING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationStatusFromTransfer(status chainv1.TransferStatus) connectorv1.OperationStatus {
|
||||||
|
switch status {
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||||
|
return connectorv1.OperationStatus_CONFIRMED
|
||||||
|
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||||
|
return connectorv1.OperationStatus_FAILED
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||||
|
return connectorv1.OperationStatus_CANCELED
|
||||||
|
default:
|
||||||
|
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetStringFromChainAsset(asset *chainv1.Asset) string {
|
||||||
|
if asset == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
symbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||||
|
if symbol == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
suffix := chainAssetSuffix(asset.GetChain())
|
||||||
|
if suffix == "" {
|
||||||
|
return symbol
|
||||||
|
}
|
||||||
|
return symbol + "-" + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainAssetSuffix(chain chainv1.ChainNetwork) string {
|
||||||
|
switch chain {
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
|
||||||
|
return "ETH"
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
|
||||||
|
return "ARB"
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
|
||||||
|
return "TRC20"
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
|
||||||
|
return "TRC20"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenFromAssetString(asset string) string {
|
||||||
|
if asset == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if idx := strings.Index(asset, "-"); idx > 0 {
|
||||||
|
return asset[:idx]
|
||||||
|
}
|
||||||
|
return asset
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainNetworkFromString(value string) chainv1.ChainNetwork {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||||
|
}
|
||||||
|
if val, ok := chainv1.ChainNetwork_value[value]; ok {
|
||||||
|
return chainv1.ChainNetwork(val)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(value, "CHAIN_NETWORK_") {
|
||||||
|
value = "CHAIN_NETWORK_" + value
|
||||||
|
}
|
||||||
|
if val, ok := chainv1.ChainNetwork_value[value]; ok {
|
||||||
|
return chainv1.ChainNetwork(val)
|
||||||
|
}
|
||||||
|
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|||||||
258
api/gateway/chain/client/rail_gateway.go
Normal file
258
api/gateway/chain/client/rail_gateway.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/payments/rail"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RailGatewayConfig defines metadata for the rail gateway adapter.
|
||||||
|
type RailGatewayConfig struct {
|
||||||
|
Rail string
|
||||||
|
Network string
|
||||||
|
Capabilities rail.RailCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
type chainRailGateway struct {
|
||||||
|
client Client
|
||||||
|
rail string
|
||||||
|
network string
|
||||||
|
capabilities rail.RailCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRailGateway wraps a chain gateway client into a rail gateway adapter.
|
||||||
|
func NewRailGateway(client Client, cfg RailGatewayConfig) rail.RailGateway {
|
||||||
|
railName := strings.ToUpper(strings.TrimSpace(cfg.Rail))
|
||||||
|
if railName == "" {
|
||||||
|
railName = "CRYPTO"
|
||||||
|
}
|
||||||
|
return &chainRailGateway{
|
||||||
|
client: client,
|
||||||
|
rail: railName,
|
||||||
|
network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
|
||||||
|
capabilities: cfg.Capabilities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Rail() string {
|
||||||
|
return g.rail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Network() string {
|
||||||
|
return g.network
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Capabilities() rail.RailCapabilities {
|
||||||
|
return g.capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) {
|
||||||
|
if g.client == nil {
|
||||||
|
return rail.RailResult{}, merrors.Internal("chain gateway: client is required")
|
||||||
|
}
|
||||||
|
orgRef := strings.TrimSpace(req.OrganizationRef)
|
||||||
|
if orgRef == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: organization_ref is required")
|
||||||
|
}
|
||||||
|
source := strings.TrimSpace(req.FromAccountID)
|
||||||
|
if source == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: from_account_id is required")
|
||||||
|
}
|
||||||
|
destRef := strings.TrimSpace(req.ToAccountID)
|
||||||
|
if destRef == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: to_account_id is required")
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(req.Currency)
|
||||||
|
amountValue := strings.TrimSpace(req.Amount)
|
||||||
|
if currency == "" || amountValue == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: amount is required")
|
||||||
|
}
|
||||||
|
reqNetwork := strings.TrimSpace(req.Network)
|
||||||
|
if g.network != "" && reqNetwork != "" && !strings.EqualFold(g.network, reqNetwork) {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: network mismatch")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.IdempotencyKey) == "" {
|
||||||
|
return rail.RailResult{}, merrors.InvalidArgument("chain gateway: idempotency_key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
dest, err := g.resolveDestination(ctx, destRef, strings.TrimSpace(req.DestinationMemo))
|
||||||
|
if err != nil {
|
||||||
|
return rail.RailResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fees := toServiceFees(req.Fees)
|
||||||
|
if len(fees) == 0 && req.Fee != nil {
|
||||||
|
if amt := moneyFromRail(req.Fee); amt != nil {
|
||||||
|
fees = []*chainv1.ServiceFeeBreakdown{{
|
||||||
|
FeeCode: "fee",
|
||||||
|
Amount: amt,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := g.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
SourceWalletRef: source,
|
||||||
|
Destination: dest,
|
||||||
|
Amount: &moneyv1.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: amountValue,
|
||||||
|
},
|
||||||
|
Fees: fees,
|
||||||
|
Metadata: cloneMetadata(req.Metadata),
|
||||||
|
ClientReference: strings.TrimSpace(req.ClientReference),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return rail.RailResult{}, err
|
||||||
|
}
|
||||||
|
if resp == nil || resp.GetTransfer() == nil {
|
||||||
|
return rail.RailResult{}, merrors.Internal("chain gateway: missing transfer response")
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer := resp.GetTransfer()
|
||||||
|
return rail.RailResult{
|
||||||
|
ReferenceID: strings.TrimSpace(transfer.GetTransferRef()),
|
||||||
|
Status: statusFromTransfer(transfer.GetStatus()),
|
||||||
|
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) {
|
||||||
|
if g.client == nil {
|
||||||
|
return rail.ObserveResult{}, merrors.Internal("chain gateway: client is required")
|
||||||
|
}
|
||||||
|
ref := strings.TrimSpace(referenceID)
|
||||||
|
if ref == "" {
|
||||||
|
return rail.ObserveResult{}, merrors.InvalidArgument("chain gateway: reference_id is required")
|
||||||
|
}
|
||||||
|
resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref})
|
||||||
|
if err != nil {
|
||||||
|
return rail.ObserveResult{}, err
|
||||||
|
}
|
||||||
|
if resp == nil || resp.GetTransfer() == nil {
|
||||||
|
return rail.ObserveResult{}, merrors.Internal("chain gateway: missing transfer response")
|
||||||
|
}
|
||||||
|
transfer := resp.GetTransfer()
|
||||||
|
return rail.ObserveResult{
|
||||||
|
ReferenceID: ref,
|
||||||
|
Status: statusFromTransfer(transfer.GetStatus()),
|
||||||
|
FinalAmount: railMoneyFromProto(transfer.GetNetAmount()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) resolveDestination(ctx context.Context, destRef, memo string) (*chainv1.TransferDestination, error) {
|
||||||
|
managed, err := g.isManagedWallet(ctx, destRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if managed {
|
||||||
|
return &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: destRef},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &chainv1.TransferDestination{
|
||||||
|
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef},
|
||||||
|
Memo: memo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string) (bool, error) {
|
||||||
|
resp, err := g.client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
|
||||||
|
if err != nil {
|
||||||
|
if status.Code(err) == codes.NotFound {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if resp == nil || resp.GetWallet() == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusFromTransfer(status chainv1.TransferStatus) string {
|
||||||
|
switch status {
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||||
|
return rail.TransferStatusSuccess
|
||||||
|
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||||
|
return rail.TransferStatusFailed
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||||
|
return rail.TransferStatusRejected
|
||||||
|
case chainv1.TransferStatus_TRANSFER_SIGNING,
|
||||||
|
chainv1.TransferStatus_TRANSFER_PENDING,
|
||||||
|
chainv1.TransferStatus_TRANSFER_SUBMITTED:
|
||||||
|
return rail.TransferStatusPending
|
||||||
|
default:
|
||||||
|
return rail.TransferStatusPending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toServiceFees(fees []rail.FeeBreakdown) []*chainv1.ServiceFeeBreakdown {
|
||||||
|
if len(fees) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*chainv1.ServiceFeeBreakdown, 0, len(fees))
|
||||||
|
for _, fee := range fees {
|
||||||
|
amount := moneyFromRail(fee.Amount)
|
||||||
|
if amount == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, &chainv1.ServiceFeeBreakdown{
|
||||||
|
FeeCode: strings.TrimSpace(fee.FeeCode),
|
||||||
|
Amount: amount,
|
||||||
|
Description: strings.TrimSpace(fee.Description),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyFromRail(m *rail.Money) *moneyv1.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
amount := strings.TrimSpace(m.GetAmount())
|
||||||
|
if currency == "" || amount == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func railMoneyFromProto(m *moneyv1.Money) *rail.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
amount := strings.TrimSpace(m.GetAmount())
|
||||||
|
if currency == "" || amount == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &rail.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMetadata(input map[string]string) map[string]string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]string, len(input))
|
||||||
|
for key, value := range input {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ grpc:
|
|||||||
enable_health: true
|
enable_health: true
|
||||||
|
|
||||||
metrics:
|
metrics:
|
||||||
address: ":9403"
|
address: ":9406"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
driver: mongodb
|
driver: mongodb
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
@@ -65,7 +65,7 @@ require (
|
|||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||||
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
|
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
|||||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358 h1:B6uGMdZ4maUTJm+LYgBwEIDuJxgOUACw8K0Yg6jpNbY=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260104020744-7268a54d0358/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -239,8 +239,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type Imp struct {
|
|||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
|
|
||||||
rpcClients *rpcclient.Clients
|
rpcClients *rpcclient.Clients
|
||||||
|
service *gatewayservice.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -100,6 +101,10 @@ func (i *Imp) Shutdown() {
|
|||||||
timeout = i.config.Runtime.ShutdownTimeout()
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -151,7 +156,9 @@ func (i *Imp) Start() error {
|
|||||||
gatewayservice.WithDriverRegistry(driverRegistry),
|
gatewayservice.WithDriverRegistry(driverRegistry),
|
||||||
gatewayservice.WithSettings(cfg.Settings),
|
gatewayservice.WithSettings(cfg.Settings),
|
||||||
}
|
}
|
||||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
svc := gatewayservice.NewService(logger, repo, producer, opts...)
|
||||||
|
i.service = svc
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
|
|||||||
692
api/gateway/chain/internal/service/gateway/connector.go
Normal file
692
api/gateway/chain/internal/service/gateway/connector.go
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/appversion"
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/pkg/connector/params"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const chainConnectorID = "chain"
|
||||||
|
|
||||||
|
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
|
||||||
|
return &connectorv1.GetCapabilitiesResponse{
|
||||||
|
Capabilities: &connectorv1.ConnectorCapabilities{
|
||||||
|
ConnectorType: chainConnectorID,
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
SupportedAccountKinds: []connectorv1.AccountKind{connectorv1.AccountKind_CHAIN_MANAGED_WALLET},
|
||||||
|
SupportedOperationTypes: []connectorv1.OperationType{
|
||||||
|
connectorv1.OperationType_TRANSFER,
|
||||||
|
connectorv1.OperationType_FEE_ESTIMATE,
|
||||||
|
connectorv1.OperationType_GAS_TOPUP,
|
||||||
|
},
|
||||||
|
OpenAccountParams: chainOpenAccountParams(),
|
||||||
|
OperationParams: chainOperationParams(),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) OpenAccount(ctx context.Context, req *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: request is required", nil, "")}, nil
|
||||||
|
}
|
||||||
|
if req.GetKind() != connectorv1.AccountKind_CHAIN_MANAGED_WALLET {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported account kind", nil, "")}, nil
|
||||||
|
}
|
||||||
|
reader := params.New(req.GetParams())
|
||||||
|
orgRef := strings.TrimSpace(reader.String("organization_ref"))
|
||||||
|
if orgRef == "" {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref is required", nil, "")}, nil
|
||||||
|
}
|
||||||
|
asset, err := parseChainAsset(strings.TrimSpace(req.GetAsset()), reader)
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), nil, "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.CreateManagedWallet(ctx, &chainv1.CreateManagedWalletRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
|
||||||
|
Asset: asset,
|
||||||
|
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
|
||||||
|
Describable: describableFromLabel(req.GetLabel(), reader.String("description")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, "")}, nil
|
||||||
|
}
|
||||||
|
return &connectorv1.OpenAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetAccount(ctx context.Context, req *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
|
||||||
|
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_account: account_ref.account_id is required")
|
||||||
|
}
|
||||||
|
resp, err := s.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &connectorv1.GetAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("list_accounts: request is required")
|
||||||
|
}
|
||||||
|
asset := (*chainv1.Asset)(nil)
|
||||||
|
if assetString := strings.TrimSpace(req.GetAsset()); assetString != "" {
|
||||||
|
parsed, err := parseChainAsset(assetString, params.New(nil))
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument(err.Error())
|
||||||
|
}
|
||||||
|
asset = parsed
|
||||||
|
}
|
||||||
|
resp, err := s.ListManagedWallets(ctx, &chainv1.ListManagedWalletsRequest{
|
||||||
|
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
|
||||||
|
Asset: asset,
|
||||||
|
Page: req.GetPage(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts := make([]*connectorv1.Account, 0, len(resp.GetWallets()))
|
||||||
|
for _, wallet := range resp.GetWallets() {
|
||||||
|
accounts = append(accounts, chainWalletToAccount(wallet))
|
||||||
|
}
|
||||||
|
return &connectorv1.ListAccountsResponse{
|
||||||
|
Accounts: accounts,
|
||||||
|
Page: resp.GetPage(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetBalance(ctx context.Context, req *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
|
||||||
|
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_balance: account_ref.account_id is required")
|
||||||
|
}
|
||||||
|
resp, err := s.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bal := resp.GetBalance()
|
||||||
|
return &connectorv1.GetBalanceResponse{
|
||||||
|
Balance: &connectorv1.Balance{
|
||||||
|
AccountRef: req.GetAccountRef(),
|
||||||
|
Available: bal.GetAvailable(),
|
||||||
|
PendingInbound: bal.GetPendingInbound(),
|
||||||
|
PendingOutbound: bal.GetPendingOutbound(),
|
||||||
|
CalculatedAt: bal.GetCalculatedAt(),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
|
||||||
|
if req == nil || req.GetOperation() == nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
|
||||||
|
}
|
||||||
|
op := req.GetOperation()
|
||||||
|
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
reader := params.New(op.GetParams())
|
||||||
|
orgRef := strings.TrimSpace(reader.String("organization_ref"))
|
||||||
|
source := operationAccountID(op.GetFrom())
|
||||||
|
if source == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "operation: from.account is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op.GetType() {
|
||||||
|
case connectorv1.OperationType_TRANSFER:
|
||||||
|
dest, err := transferDestinationFromOperation(op)
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
amount := op.GetMoney()
|
||||||
|
if amount == nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
amount = normalizeMoneyForChain(amount)
|
||||||
|
if orgRef == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: organization_ref is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
SourceWalletRef: source,
|
||||||
|
Destination: dest,
|
||||||
|
Amount: amount,
|
||||||
|
Fees: parseChainFees(reader),
|
||||||
|
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
|
||||||
|
ClientReference: strings.TrimSpace(reader.String("client_reference")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
transfer := resp.GetTransfer()
|
||||||
|
return &connectorv1.SubmitOperationResponse{
|
||||||
|
Receipt: &connectorv1.OperationReceipt{
|
||||||
|
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||||
|
Status: chainTransferStatusToOperation(transfer.GetStatus()),
|
||||||
|
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
case connectorv1.OperationType_FEE_ESTIMATE:
|
||||||
|
dest, err := transferDestinationFromOperation(op)
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
amount := op.GetMoney()
|
||||||
|
if amount == nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "estimate: money is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
amount = normalizeMoneyForChain(amount)
|
||||||
|
opID := strings.TrimSpace(op.GetOperationId())
|
||||||
|
if opID == "" {
|
||||||
|
opID = strings.TrimSpace(op.GetIdempotencyKey())
|
||||||
|
}
|
||||||
|
resp, err := s.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
|
||||||
|
SourceWalletRef: source,
|
||||||
|
Destination: dest,
|
||||||
|
Amount: amount,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
result := feeEstimateResult(resp)
|
||||||
|
return &connectorv1.SubmitOperationResponse{
|
||||||
|
Receipt: &connectorv1.OperationReceipt{
|
||||||
|
OperationId: opID,
|
||||||
|
Status: connectorv1.OperationStatus_CONFIRMED,
|
||||||
|
Result: result,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
case connectorv1.OperationType_GAS_TOPUP:
|
||||||
|
fee, err := parseMoneyFromMap(reader.Map("estimated_total_fee"))
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
fee = normalizeMoneyForChain(fee)
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(reader.String("mode")))
|
||||||
|
if mode == "" {
|
||||||
|
mode = "compute"
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "compute":
|
||||||
|
opID := strings.TrimSpace(op.GetOperationId())
|
||||||
|
if opID == "" {
|
||||||
|
opID = strings.TrimSpace(op.GetIdempotencyKey())
|
||||||
|
}
|
||||||
|
resp, err := s.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
|
||||||
|
WalletRef: source,
|
||||||
|
EstimatedTotalFee: fee,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
return &connectorv1.SubmitOperationResponse{
|
||||||
|
Receipt: &connectorv1.OperationReceipt{
|
||||||
|
OperationId: opID,
|
||||||
|
Status: connectorv1.OperationStatus_CONFIRMED,
|
||||||
|
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
case "ensure":
|
||||||
|
opID := strings.TrimSpace(op.GetOperationId())
|
||||||
|
if opID == "" {
|
||||||
|
opID = strings.TrimSpace(op.GetIdempotencyKey())
|
||||||
|
}
|
||||||
|
if orgRef == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: organization_ref is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(reader.String("target_wallet_ref"))
|
||||||
|
if target == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: target_wallet_ref is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
SourceWalletRef: source,
|
||||||
|
TargetWalletRef: target,
|
||||||
|
EstimatedTotalFee: fee,
|
||||||
|
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
|
||||||
|
ClientReference: strings.TrimSpace(reader.String("client_reference")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
transferRef := ""
|
||||||
|
if transfer := resp.GetTransfer(); transfer != nil {
|
||||||
|
transferRef = strings.TrimSpace(transfer.GetTransferRef())
|
||||||
|
}
|
||||||
|
return &connectorv1.SubmitOperationResponse{
|
||||||
|
Receipt: &connectorv1.OperationReceipt{
|
||||||
|
OperationId: opID,
|
||||||
|
Status: connectorv1.OperationStatus_CONFIRMED,
|
||||||
|
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: invalid mode", op, "")}}, nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
|
||||||
|
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||||
|
}
|
||||||
|
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("list_operations: request is required")
|
||||||
|
}
|
||||||
|
source := ""
|
||||||
|
if req.GetAccountRef() != nil {
|
||||||
|
source = strings.TrimSpace(req.GetAccountRef().GetAccountId())
|
||||||
|
}
|
||||||
|
resp, err := s.ListTransfers(ctx, &chainv1.ListTransfersRequest{
|
||||||
|
SourceWalletRef: source,
|
||||||
|
Status: chainStatusFromOperation(req.GetStatus()),
|
||||||
|
Page: req.GetPage(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops := make([]*connectorv1.Operation, 0, len(resp.GetTransfers()))
|
||||||
|
for _, transfer := range resp.GetTransfers() {
|
||||||
|
ops = append(ops, chainTransferToOperation(transfer))
|
||||||
|
}
|
||||||
|
return &connectorv1.ListOperationsResponse{Operations: ops, Page: resp.GetPage()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainOpenAccountParams() []*connectorv1.ParamSpec {
|
||||||
|
return []*connectorv1.ParamSpec{
|
||||||
|
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the wallet."},
|
||||||
|
{Key: "network", Type: connectorv1.ParamType_STRING, Required: true, Description: "Blockchain network name."},
|
||||||
|
{Key: "token_symbol", Type: connectorv1.ParamType_STRING, Required: true, Description: "Token symbol (e.g., USDT)."},
|
||||||
|
{Key: "contract_address", Type: connectorv1.ParamType_STRING, Required: false, Description: "Token contract address override."},
|
||||||
|
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Additional metadata map."},
|
||||||
|
{Key: "description", Type: connectorv1.ParamType_STRING, Required: false, Description: "Wallet description."},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainOperationParams() []*connectorv1.OperationParamSpec {
|
||||||
|
return []*connectorv1.OperationParamSpec{
|
||||||
|
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
|
||||||
|
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference."},
|
||||||
|
{Key: "destination_memo", Type: connectorv1.ParamType_STRING, Required: false, Description: "Destination memo/tag."},
|
||||||
|
{Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference id."},
|
||||||
|
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Transfer metadata."},
|
||||||
|
{Key: "fees", Type: connectorv1.ParamType_JSON, Required: false, Description: "Service fee breakdowns."},
|
||||||
|
}},
|
||||||
|
{OperationType: connectorv1.OperationType_FEE_ESTIMATE, Params: []*connectorv1.ParamSpec{
|
||||||
|
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference."},
|
||||||
|
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Estimate metadata."},
|
||||||
|
}},
|
||||||
|
{OperationType: connectorv1.OperationType_GAS_TOPUP, Params: []*connectorv1.ParamSpec{
|
||||||
|
{Key: "mode", Type: connectorv1.ParamType_STRING, Required: false, Description: "compute | ensure."},
|
||||||
|
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference (required for ensure)."},
|
||||||
|
{Key: "target_wallet_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Target wallet ref (ensure)."},
|
||||||
|
{Key: "estimated_total_fee", Type: connectorv1.ParamType_JSON, Required: true, Description: "Estimated total fee {amount,currency}."},
|
||||||
|
{Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference."},
|
||||||
|
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Top-up metadata."},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainWalletToAccount(wallet *chainv1.ManagedWallet) *connectorv1.Account {
|
||||||
|
if wallet == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
details, _ := structpb.NewStruct(map[string]interface{}{
|
||||||
|
"deposit_address": wallet.GetDepositAddress(),
|
||||||
|
"organization_ref": wallet.GetOrganizationRef(),
|
||||||
|
"owner_ref": wallet.GetOwnerRef(),
|
||||||
|
"network": wallet.GetAsset().GetChain().String(),
|
||||||
|
"token_symbol": wallet.GetAsset().GetTokenSymbol(),
|
||||||
|
"contract_address": wallet.GetAsset().GetContractAddress(),
|
||||||
|
"wallet_ref": wallet.GetWalletRef(),
|
||||||
|
})
|
||||||
|
return &connectorv1.Account{
|
||||||
|
Ref: &connectorv1.AccountRef{
|
||||||
|
ConnectorId: chainConnectorID,
|
||||||
|
AccountId: strings.TrimSpace(wallet.GetWalletRef()),
|
||||||
|
},
|
||||||
|
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
|
||||||
|
Asset: assetStringFromChainAsset(wallet.GetAsset()),
|
||||||
|
State: chainWalletState(wallet.GetStatus()),
|
||||||
|
Label: strings.TrimSpace(wallet.GetDescribable().GetName()),
|
||||||
|
OwnerRef: strings.TrimSpace(wallet.GetOwnerRef()),
|
||||||
|
ProviderDetails: details,
|
||||||
|
CreatedAt: wallet.GetCreatedAt(),
|
||||||
|
UpdatedAt: wallet.GetUpdatedAt(),
|
||||||
|
Describable: wallet.GetDescribable(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainWalletState(status chainv1.ManagedWalletStatus) connectorv1.AccountState {
|
||||||
|
switch status {
|
||||||
|
case chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE:
|
||||||
|
return connectorv1.AccountState_ACCOUNT_ACTIVE
|
||||||
|
case chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED:
|
||||||
|
return connectorv1.AccountState_ACCOUNT_SUSPENDED
|
||||||
|
case chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED:
|
||||||
|
return connectorv1.AccountState_ACCOUNT_CLOSED
|
||||||
|
default:
|
||||||
|
return connectorv1.AccountState_ACCOUNT_STATE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
|
||||||
|
if op == nil {
|
||||||
|
return nil, merrors.InvalidArgument("transfer: operation is required")
|
||||||
|
}
|
||||||
|
if to := op.GetTo(); to != nil {
|
||||||
|
if account := to.GetAccount(); account != nil {
|
||||||
|
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
|
||||||
|
}
|
||||||
|
if ext := to.GetExternal(); ext != nil {
|
||||||
|
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, merrors.InvalidArgument("transfer: to.account or to.external is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMoneyForChain(m *moneyv1.Money) *moneyv1.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
if idx := strings.Index(currency, "-"); idx > 0 {
|
||||||
|
currency = currency[:idx]
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Amount: strings.TrimSpace(m.GetAmount()),
|
||||||
|
Currency: currency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChainFees(reader params.Reader) []*chainv1.ServiceFeeBreakdown {
|
||||||
|
rawFees := reader.List("fees")
|
||||||
|
if len(rawFees) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*chainv1.ServiceFeeBreakdown, 0, len(rawFees))
|
||||||
|
for _, item := range rawFees {
|
||||||
|
raw, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
|
||||||
|
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
|
||||||
|
if amount == "" || currency == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, &chainv1.ServiceFeeBreakdown{
|
||||||
|
FeeCode: strings.TrimSpace(fmt.Sprint(raw["fee_code"])),
|
||||||
|
Description: strings.TrimSpace(fmt.Sprint(raw["description"])),
|
||||||
|
Amount: &moneyv1.Money{Amount: amount, Currency: currency},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, merrors.InvalidArgument("money is required")
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
|
||||||
|
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
|
||||||
|
if amount == "" || currency == "" {
|
||||||
|
return nil, merrors.InvalidArgument("money is required")
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Amount: amount,
|
||||||
|
Currency: currency,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Struct {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"estimation_context": strings.TrimSpace(resp.GetEstimationContext()),
|
||||||
|
}
|
||||||
|
if fee := resp.GetNetworkFee(); fee != nil {
|
||||||
|
payload["network_fee"] = map[string]interface{}{
|
||||||
|
"amount": strings.TrimSpace(fee.GetAmount()),
|
||||||
|
"currency": strings.TrimSpace(fee.GetCurrency()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := structpb.NewStruct(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"cap_hit": capHit,
|
||||||
|
}
|
||||||
|
if amount != nil {
|
||||||
|
payload["topup_amount"] = map[string]interface{}{
|
||||||
|
"amount": strings.TrimSpace(amount.GetAmount()),
|
||||||
|
"currency": strings.TrimSpace(amount.GetCurrency()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(transferRef) != "" {
|
||||||
|
payload["transfer_ref"] = strings.TrimSpace(transferRef)
|
||||||
|
}
|
||||||
|
result, err := structpb.NewStruct(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
|
||||||
|
if transfer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
op := &connectorv1.Operation{
|
||||||
|
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||||
|
Type: connectorv1.OperationType_TRANSFER,
|
||||||
|
Status: chainTransferStatusToOperation(transfer.GetStatus()),
|
||||||
|
Money: transfer.GetRequestedAmount(),
|
||||||
|
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
|
||||||
|
CreatedAt: transfer.GetCreatedAt(),
|
||||||
|
UpdatedAt: transfer.GetUpdatedAt(),
|
||||||
|
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||||
|
ConnectorId: chainConnectorID,
|
||||||
|
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
if dest := transfer.GetDestination(); dest != nil {
|
||||||
|
switch d := dest.GetDestination().(type) {
|
||||||
|
case *chainv1.TransferDestination_ManagedWalletRef:
|
||||||
|
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||||
|
ConnectorId: chainConnectorID,
|
||||||
|
AccountId: strings.TrimSpace(d.ManagedWalletRef),
|
||||||
|
}}}
|
||||||
|
case *chainv1.TransferDestination_ExternalAddress:
|
||||||
|
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
|
||||||
|
ExternalRef: strings.TrimSpace(d.ExternalAddress),
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
|
||||||
|
switch status {
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||||
|
return connectorv1.OperationStatus_CONFIRMED
|
||||||
|
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||||
|
return connectorv1.OperationStatus_FAILED
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||||
|
return connectorv1.OperationStatus_CANCELED
|
||||||
|
default:
|
||||||
|
return connectorv1.OperationStatus_PENDING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
|
||||||
|
switch status {
|
||||||
|
case connectorv1.OperationStatus_CONFIRMED:
|
||||||
|
return chainv1.TransferStatus_TRANSFER_CONFIRMED
|
||||||
|
case connectorv1.OperationStatus_FAILED:
|
||||||
|
return chainv1.TransferStatus_TRANSFER_FAILED
|
||||||
|
case connectorv1.OperationStatus_CANCELED:
|
||||||
|
return chainv1.TransferStatus_TRANSFER_CANCELLED
|
||||||
|
default:
|
||||||
|
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChainAsset(assetString string, reader params.Reader) (*chainv1.Asset, error) {
|
||||||
|
network := strings.TrimSpace(reader.String("network"))
|
||||||
|
token := strings.TrimSpace(reader.String("token_symbol"))
|
||||||
|
contract := strings.TrimSpace(reader.String("contract_address"))
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
token = tokenFromAssetString(assetString)
|
||||||
|
}
|
||||||
|
if network == "" {
|
||||||
|
network = networkFromAssetString(assetString)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil, merrors.InvalidArgument("asset: token_symbol is required")
|
||||||
|
}
|
||||||
|
chain := shared.ChainEnumFromName(network)
|
||||||
|
if chain == chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED {
|
||||||
|
return nil, merrors.InvalidArgument("asset: network is required")
|
||||||
|
}
|
||||||
|
return &chainv1.Asset{
|
||||||
|
Chain: chain,
|
||||||
|
TokenSymbol: strings.ToUpper(token),
|
||||||
|
ContractAddress: strings.ToLower(contract),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenFromAssetString(asset string) string {
|
||||||
|
if asset == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if idx := strings.Index(asset, "-"); idx > 0 {
|
||||||
|
return asset[:idx]
|
||||||
|
}
|
||||||
|
return asset
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkFromAssetString(asset string) string {
|
||||||
|
if asset == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
idx := strings.Index(asset, "-")
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(asset[idx+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetStringFromChainAsset(asset *chainv1.Asset) string {
|
||||||
|
if asset == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
symbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||||
|
if symbol == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
suffix := chainAssetSuffix(asset.GetChain())
|
||||||
|
if suffix == "" {
|
||||||
|
return symbol
|
||||||
|
}
|
||||||
|
return symbol + "-" + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainAssetSuffix(chain chainv1.ChainNetwork) string {
|
||||||
|
switch chain {
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
|
||||||
|
return "ETH"
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
|
||||||
|
return "ARB"
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
|
||||||
|
return "TRC20"
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
|
||||||
|
return "TRC20"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func describableFromLabel(label, desc string) *describablev1.Describable {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
desc = strings.TrimSpace(desc)
|
||||||
|
if label == "" && desc == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &describablev1.Describable{
|
||||||
|
Name: label,
|
||||||
|
Description: &desc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationAccountID(party *connectorv1.OperationParty) string {
|
||||||
|
if party == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if account := party.GetAccount(); account != nil {
|
||||||
|
return strings.TrimSpace(account.GetAccountId())
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
||||||
|
err := &connectorv1.ConnectorError{
|
||||||
|
Code: code,
|
||||||
|
Message: strings.TrimSpace(message),
|
||||||
|
AccountId: strings.TrimSpace(accountID),
|
||||||
|
}
|
||||||
|
if op != nil {
|
||||||
|
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
|
||||||
|
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
|
||||||
|
err.OperationId = strings.TrimSpace(op.GetOperationId())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapErrorCode(err error) connectorv1.ErrorCode {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return connectorv1.ErrorCode_INVALID_PARAMS
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return connectorv1.ErrorCode_NOT_FOUND
|
||||||
|
case errors.Is(err, merrors.ErrNotImplemented):
|
||||||
|
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
|
||||||
|
case errors.Is(err, merrors.ErrInternal):
|
||||||
|
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
|
||||||
|
default:
|
||||||
|
return connectorv1.ErrorCode_PROVIDER_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package gateway
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/appversion"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
||||||
@@ -14,9 +15,11 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
@@ -31,7 +34,7 @@ var (
|
|||||||
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
|
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service implements the ChainGatewayService RPC contract.
|
// Service implements the ConnectorService RPC contract for chain operations.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
@@ -47,8 +50,9 @@ type Service struct {
|
|||||||
networkRegistry *rpcclient.Registry
|
networkRegistry *rpcclient.Registry
|
||||||
drivers *drivers.Registry
|
drivers *drivers.Registry
|
||||||
commands commands.Registry
|
commands commands.Registry
|
||||||
|
announcers []*discovery.Announcer
|
||||||
|
|
||||||
chainv1.UnimplementedChainGatewayServiceServer
|
connectorv1.UnimplementedConnectorServiceServer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs the chain gateway service skeleton.
|
// NewService constructs the chain gateway service skeleton.
|
||||||
@@ -83,6 +87,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
Wallet: commandsWalletDeps(svc),
|
Wallet: commandsWalletDeps(svc),
|
||||||
Transfer: commandsTransferDeps(svc),
|
Transfer: commandsTransferDeps(svc),
|
||||||
})
|
})
|
||||||
|
svc.startDiscoveryAnnouncers()
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
@@ -90,10 +95,21 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
// Register wires the service onto the provided gRPC router.
|
// Register wires the service onto the provided gRPC router.
|
||||||
func (s *Service) Register(router routers.GRPC) error {
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
chainv1.RegisterChainGatewayServiceServer(reg, s)
|
connectorv1.RegisterConnectorServiceServer(reg, s)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, announcer := range s.announcers {
|
||||||
|
if announcer != nil {
|
||||||
|
announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
|
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
|
||||||
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
|
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
|
||||||
}
|
}
|
||||||
@@ -174,3 +190,30 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
|
|||||||
observeRPC(method, err, svc.clock.Now().Sub(start))
|
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncers() {
|
||||||
|
if s == nil || s.producer == nil || len(s.networks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version := appversion.Create().Short()
|
||||||
|
for _, network := range s.networks {
|
||||||
|
currencies := []string{shared.NativeCurrency(network)}
|
||||||
|
for _, token := range network.TokenConfigs {
|
||||||
|
if token.Symbol != "" {
|
||||||
|
currencies = append(currencies, token.Symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "CRYPTO_RAIL_GATEWAY",
|
||||||
|
Rail: "CRYPTO",
|
||||||
|
Network: network.Name,
|
||||||
|
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send"},
|
||||||
|
Currencies: currencies,
|
||||||
|
InvokeURI: discovery.DefaultInvokeURI(string(mservice.ChainGateway)),
|
||||||
|
Version: version,
|
||||||
|
}
|
||||||
|
announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce)
|
||||||
|
announcer.Start()
|
||||||
|
s.announcers = append(s.announcers, announcer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package shared
|
package shared
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errHexEmpty = errors.New("hex value is empty")
|
errHexEmpty = merrors.InvalidArgument("hex value is empty")
|
||||||
errHexInvalid = errors.New("invalid hex number")
|
errHexInvalid = merrors.InvalidArgument("invalid hex number")
|
||||||
errHexOutOfRange = errors.New("hex number out of range")
|
errHexOutOfRange = merrors.InvalidArgument("hex number out of range")
|
||||||
)
|
)
|
||||||
|
|
||||||
// DecodeHexBig parses a hex string that may include leading zero digits.
|
// DecodeHexBig parses a hex string that may include leading zero digits.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
This service now supports Monetix “payout by card”.
|
This service now supports Monetix “payout by card”.
|
||||||
|
|
||||||
## Runtime entry points
|
## Runtime entry points
|
||||||
- gRPC: `MntxGatewayService.CreateCardPayout` and `GetCardPayoutStatus`.
|
- gRPC: `MntxGatewayService.CreateCardPayout`, `CreateCardTokenPayout`, `GetCardPayoutStatus`, `ListGatewayInstances`.
|
||||||
- Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications.
|
- Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications.
|
||||||
- Metrics: Prometheus on `:9404/metrics`.
|
- Metrics: Prometheus on `:9404/metrics`.
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ This service now supports Monetix “payout by card”.
|
|||||||
- `MONETIX_PROJECT_ID` – integer project ID
|
- `MONETIX_PROJECT_ID` – integer project ID
|
||||||
- `MONETIX_SECRET_KEY` – signature secret
|
- `MONETIX_SECRET_KEY` – signature secret
|
||||||
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
|
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
|
||||||
|
- Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits`
|
||||||
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
|
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
|
||||||
|
|
||||||
## Outbound request (CreateCardPayout)
|
## Outbound request (CreateCardPayout)
|
||||||
@@ -39,7 +40,8 @@ Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_
|
|||||||
- `sendico_mntx_gateway_card_payout_requests_total{outcome}`
|
- `sendico_mntx_gateway_card_payout_requests_total{outcome}`
|
||||||
- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}`
|
- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}`
|
||||||
- `sendico_mntx_gateway_card_payout_callbacks_total{status}`
|
- `sendico_mntx_gateway_card_payout_callbacks_total{status}`
|
||||||
- Existing RPC/payout counters remain for compatibility.
|
- `sendico_mntx_gateway_rpc_requests_total{method,status}`
|
||||||
|
- `sendico_mntx_gateway_rpc_latency_seconds{method}`
|
||||||
|
|
||||||
## Notes / PCI
|
## Notes / PCI
|
||||||
- PAN is only logged in masked form; do not persist raw PAN.
|
- PAN is only logged in masked form; do not persist raw PAN.
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client wraps the Monetix gateway gRPC API.
|
// Client wraps the Monetix gateway gRPC API.
|
||||||
@@ -17,12 +21,18 @@ type Client interface {
|
|||||||
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
||||||
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
||||||
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
||||||
|
ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type grpcConnectorClient interface {
|
||||||
|
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
|
||||||
|
GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
type gatewayClient struct {
|
type gatewayClient struct {
|
||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
client mntxv1.MntxGatewayServiceClient
|
client grpcConnectorClient
|
||||||
cfg Config
|
cfg Config
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
@@ -42,12 +52,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
|||||||
|
|
||||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
|
return nil, merrors.Internal("mntx: dial failed: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return &gatewayClient{
|
return &gatewayClient{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
client: mntxv1.NewMntxGatewayServiceClient(conn),
|
client: connectorv1.NewConnectorServiceClient(conn),
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: cfg.Logger,
|
logger: cfg.Logger,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -68,31 +78,253 @@ func (g *gatewayClient) callContext(ctx context.Context, method string) (context
|
|||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 5 * time.Second
|
timeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
fields := []zap.Field{
|
if g.logger != nil {
|
||||||
zap.String("method", method),
|
fields := []zap.Field{
|
||||||
zap.Duration("timeout", timeout),
|
zap.String("method", method),
|
||||||
|
zap.Duration("timeout", timeout),
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
|
||||||
|
}
|
||||||
|
g.logger.Info("Mntx gateway client call timeout applied", fields...)
|
||||||
}
|
}
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
|
||||||
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
|
|
||||||
}
|
|
||||||
g.logger.Info("Mntx gateway client call timeout applied", fields...)
|
|
||||||
return context.WithTimeout(ctx, timeout)
|
return context.WithTimeout(ctx, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx, "CreateCardPayout")
|
ctx, cancel := g.callContext(ctx, "CreateCardPayout")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.CreateCardPayout(ctx, req)
|
operation, err := operationFromCardPayout(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetReceipt().GetError())
|
||||||
|
}
|
||||||
|
return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout")
|
ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.CreateCardTokenPayout(ctx, req)
|
operation, err := operationFromTokenPayout(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetReceipt().GetError())
|
||||||
|
}
|
||||||
|
return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus")
|
ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.GetCardPayoutStatus(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetPayoutId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("mntx: payout_id is required")
|
||||||
|
}
|
||||||
|
resp, err := g.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetPayoutId())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mntxv1.GetCardPayoutStatusResponse{Payout: payoutFromOperation(resp.GetOperation())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("mntx: ListGatewayInstances not supported via connector")
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationFromCardPayout(req *mntxv1.CardPayoutRequest) (*connectorv1.Operation, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mntx: request is required")
|
||||||
|
}
|
||||||
|
params := payoutParamsFromCard(req)
|
||||||
|
money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency())
|
||||||
|
return &connectorv1.Operation{
|
||||||
|
Type: connectorv1.OperationType_PAYOUT,
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.GetPayoutId()),
|
||||||
|
Money: money,
|
||||||
|
Params: structFromMap(params),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationFromTokenPayout(req *mntxv1.CardTokenPayoutRequest) (*connectorv1.Operation, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mntx: request is required")
|
||||||
|
}
|
||||||
|
params := payoutParamsFromToken(req)
|
||||||
|
money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency())
|
||||||
|
return &connectorv1.Operation{
|
||||||
|
Type: connectorv1.OperationType_PAYOUT,
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.GetPayoutId()),
|
||||||
|
Money: money,
|
||||||
|
Params: structFromMap(params),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"payout_id": strings.TrimSpace(req.GetPayoutId()),
|
||||||
|
"project_id": req.GetProjectId(),
|
||||||
|
"customer_id": strings.TrimSpace(req.GetCustomerId()),
|
||||||
|
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
|
||||||
|
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
|
||||||
|
"customer_last_name": strings.TrimSpace(req.GetCustomerLastName()),
|
||||||
|
"customer_ip": strings.TrimSpace(req.GetCustomerIp()),
|
||||||
|
"customer_zip": strings.TrimSpace(req.GetCustomerZip()),
|
||||||
|
"customer_country": strings.TrimSpace(req.GetCustomerCountry()),
|
||||||
|
"customer_state": strings.TrimSpace(req.GetCustomerState()),
|
||||||
|
"customer_city": strings.TrimSpace(req.GetCustomerCity()),
|
||||||
|
"customer_address": strings.TrimSpace(req.GetCustomerAddress()),
|
||||||
|
"amount_minor": req.GetAmountMinor(),
|
||||||
|
"currency": strings.TrimSpace(req.GetCurrency()),
|
||||||
|
"card_pan": strings.TrimSpace(req.GetCardPan()),
|
||||||
|
"card_exp_year": req.GetCardExpYear(),
|
||||||
|
"card_exp_month": req.GetCardExpMonth(),
|
||||||
|
"card_holder": strings.TrimSpace(req.GetCardHolder()),
|
||||||
|
}
|
||||||
|
if len(req.GetMetadata()) > 0 {
|
||||||
|
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"payout_id": strings.TrimSpace(req.GetPayoutId()),
|
||||||
|
"project_id": req.GetProjectId(),
|
||||||
|
"customer_id": strings.TrimSpace(req.GetCustomerId()),
|
||||||
|
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
|
||||||
|
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
|
||||||
|
"customer_last_name": strings.TrimSpace(req.GetCustomerLastName()),
|
||||||
|
"customer_ip": strings.TrimSpace(req.GetCustomerIp()),
|
||||||
|
"customer_zip": strings.TrimSpace(req.GetCustomerZip()),
|
||||||
|
"customer_country": strings.TrimSpace(req.GetCustomerCountry()),
|
||||||
|
"customer_state": strings.TrimSpace(req.GetCustomerState()),
|
||||||
|
"customer_city": strings.TrimSpace(req.GetCustomerCity()),
|
||||||
|
"customer_address": strings.TrimSpace(req.GetCustomerAddress()),
|
||||||
|
"amount_minor": req.GetAmountMinor(),
|
||||||
|
"currency": strings.TrimSpace(req.GetCurrency()),
|
||||||
|
"card_token": strings.TrimSpace(req.GetCardToken()),
|
||||||
|
"card_holder": strings.TrimSpace(req.GetCardHolder()),
|
||||||
|
"masked_pan": strings.TrimSpace(req.GetMaskedPan()),
|
||||||
|
}
|
||||||
|
if len(req.GetMetadata()) > 0 {
|
||||||
|
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyFromMinor(amount int64, currency string) *moneyv1.Money {
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100))
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Amount: dec.StringFixed(2),
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(currency)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutFromReceipt(payoutID string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState {
|
||||||
|
state := &mntxv1.CardPayoutState{PayoutId: strings.TrimSpace(payoutID)}
|
||||||
|
if receipt == nil {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
state.Status = payoutStatusFromOperation(receipt.GetStatus())
|
||||||
|
state.ProviderPaymentId = strings.TrimSpace(receipt.GetProviderRef())
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutFromOperation(op *connectorv1.Operation) *mntxv1.CardPayoutState {
|
||||||
|
if op == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state := &mntxv1.CardPayoutState{
|
||||||
|
PayoutId: strings.TrimSpace(op.GetOperationId()),
|
||||||
|
Status: payoutStatusFromOperation(op.GetStatus()),
|
||||||
|
ProviderPaymentId: strings.TrimSpace(op.GetProviderRef()),
|
||||||
|
}
|
||||||
|
if money := op.GetMoney(); money != nil {
|
||||||
|
state.Currency = strings.TrimSpace(money.GetCurrency())
|
||||||
|
state.AmountMinor = minorFromMoney(money)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func minorFromMoney(m *moneyv1.Money) int64 {
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(m.GetAmount())
|
||||||
|
if amount == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
dec, err := decimal.NewFromString(amount)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return dec.Mul(decimal.NewFromInt(100)).IntPart()
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutStatusFromOperation(status connectorv1.OperationStatus) mntxv1.PayoutStatus {
|
||||||
|
switch status {
|
||||||
|
case connectorv1.OperationStatus_CONFIRMED:
|
||||||
|
return mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
|
||||||
|
case connectorv1.OperationStatus_FAILED:
|
||||||
|
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
|
case connectorv1.OperationStatus_PENDING, connectorv1.OperationStatus_SUBMITTED:
|
||||||
|
return mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
|
||||||
|
default:
|
||||||
|
return mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectorError(err *connectorv1.ConnectorError) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(err.GetMessage())
|
||||||
|
switch err.GetCode() {
|
||||||
|
case connectorv1.ErrorCode_INVALID_PARAMS:
|
||||||
|
return merrors.InvalidArgument(msg)
|
||||||
|
case connectorv1.ErrorCode_NOT_FOUND:
|
||||||
|
return merrors.NoData(msg)
|
||||||
|
case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
|
||||||
|
return merrors.NotImplemented(msg)
|
||||||
|
case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
|
||||||
|
return merrors.Internal(msg)
|
||||||
|
default:
|
||||||
|
return merrors.Internal(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func structFromMap(data map[string]interface{}) *structpb.Struct {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result, err := structpb.NewStruct(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStringToInterface(input map[string]string) map[string]interface{} {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{}, len(input))
|
||||||
|
for k, v := range input {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Fake struct {
|
|||||||
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
||||||
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
||||||
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
||||||
|
ListGatewayInstancesFn func(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
@@ -34,4 +35,11 @@ func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayou
|
|||||||
return &mntxv1.GetCardPayoutStatusResponse{}, nil
|
return &mntxv1.GetCardPayoutStatusResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
|
||||||
|
if f.ListGatewayInstancesFn != nil {
|
||||||
|
return f.ListGatewayInstancesFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &mntxv1.ListGatewayInstancesResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fake) Close() error { return nil }
|
func (f *Fake) Close() error { return nil }
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ monetix:
|
|||||||
status_success: "success"
|
status_success: "success"
|
||||||
status_processing: "processing"
|
status_processing: "processing"
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
id: "monetix"
|
||||||
|
is_enabled: true
|
||||||
|
network: "VISA_DIRECT"
|
||||||
|
currencies: ["RUB"]
|
||||||
|
limits:
|
||||||
|
min_amount: "0"
|
||||||
|
|
||||||
http:
|
http:
|
||||||
callback:
|
callback:
|
||||||
address: ":8084"
|
address: ":8084"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ replace github.com/tech/sendico/pkg => ../../pkg
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
@@ -24,6 +23,7 @@ require (
|
|||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // 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/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -34,7 +34,7 @@ require (
|
|||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||||
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
|
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
|
||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -28,14 +30,16 @@ type Imp struct {
|
|||||||
file string
|
file string
|
||||||
debug bool
|
debug bool
|
||||||
|
|
||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[struct{}]
|
app *grpcapp.App[struct{}]
|
||||||
http *http.Server
|
http *http.Server
|
||||||
|
service *mntxservice.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
*grpcapp.Config `yaml:",inline"`
|
*grpcapp.Config `yaml:",inline"`
|
||||||
Monetix monetixConfig `yaml:"monetix"`
|
Monetix monetixConfig `yaml:"monetix"`
|
||||||
|
Gateway gatewayConfig `yaml:"gateway"`
|
||||||
HTTP httpConfig `yaml:"http"`
|
HTTP httpConfig `yaml:"http"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +57,33 @@ type monetixConfig struct {
|
|||||||
StatusProcessing string `yaml:"status_processing"`
|
StatusProcessing string `yaml:"status_processing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gatewayConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Network string `yaml:"network"`
|
||||||
|
Currencies []string `yaml:"currencies"`
|
||||||
|
IsEnabled *bool `yaml:"is_enabled"`
|
||||||
|
Limits limitsConfig `yaml:"limits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitsConfig struct {
|
||||||
|
MinAmount string `yaml:"min_amount"`
|
||||||
|
MaxAmount string `yaml:"max_amount"`
|
||||||
|
PerTxMaxFee string `yaml:"per_tx_max_fee"`
|
||||||
|
PerTxMinAmount string `yaml:"per_tx_min_amount"`
|
||||||
|
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
|
||||||
|
VolumeLimit map[string]string `yaml:"volume_limit"`
|
||||||
|
VelocityLimit map[string]int `yaml:"velocity_limit"`
|
||||||
|
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitsOverrideCfg struct {
|
||||||
|
MaxVolume string `yaml:"max_volume"`
|
||||||
|
MinAmount string `yaml:"min_amount"`
|
||||||
|
MaxAmount string `yaml:"max_amount"`
|
||||||
|
MaxFee string `yaml:"max_fee"`
|
||||||
|
MaxOps int `yaml:"max_ops"`
|
||||||
|
}
|
||||||
|
|
||||||
type httpConfig struct {
|
type httpConfig struct {
|
||||||
Callback callbackConfig `yaml:"callback"`
|
Callback callbackConfig `yaml:"callback"`
|
||||||
}
|
}
|
||||||
@@ -86,6 +117,9 @@ func (i *Imp) Shutdown() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
if i.http != nil {
|
if i.http != nil {
|
||||||
_ = i.http.Shutdown(ctx)
|
_ = i.http.Shutdown(ctx)
|
||||||
i.http = nil
|
i.http = nil
|
||||||
@@ -131,6 +165,17 @@ func (i *Imp) Start() error {
|
|||||||
zap.String("status_processing", monetixCfg.ProcessingStatus()),
|
zap.String("status_processing", monetixCfg.ProcessingStatus()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
gatewayDescriptor := resolveGatewayDescriptor(cfg.Gateway, monetixCfg)
|
||||||
|
if gatewayDescriptor != nil {
|
||||||
|
i.logger.Info("Gateway descriptor resolved",
|
||||||
|
zap.String("id", gatewayDescriptor.GetId()),
|
||||||
|
zap.String("rail", gatewayDescriptor.GetRail().String()),
|
||||||
|
zap.String("network", gatewayDescriptor.GetNetwork()),
|
||||||
|
zap.Int("currencies", len(gatewayDescriptor.GetCurrencies())),
|
||||||
|
zap.Bool("enabled", gatewayDescriptor.GetIsEnabled()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
i.logger.Info("Callback configuration resolved",
|
i.logger.Info("Callback configuration resolved",
|
||||||
zap.String("address", callbackCfg.Address),
|
zap.String("address", callbackCfg.Address),
|
||||||
zap.String("path", callbackCfg.Path),
|
zap.String("path", callbackCfg.Path),
|
||||||
@@ -142,8 +187,10 @@ func (i *Imp) Start() error {
|
|||||||
svc := mntxservice.NewService(logger,
|
svc := mntxservice.NewService(logger,
|
||||||
mntxservice.WithProducer(producer),
|
mntxservice.WithProducer(producer),
|
||||||
mntxservice.WithMonetixConfig(monetixCfg),
|
mntxservice.WithMonetixConfig(monetixCfg),
|
||||||
|
mntxservice.WithGatewayDescriptor(gatewayDescriptor),
|
||||||
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
|
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
|
||||||
)
|
)
|
||||||
|
i.service = svc
|
||||||
|
|
||||||
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
|
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -243,6 +290,129 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gatewayv1.GatewayInstanceDescriptor {
|
||||||
|
id := strings.TrimSpace(cfg.ID)
|
||||||
|
if id == "" {
|
||||||
|
id = "monetix"
|
||||||
|
}
|
||||||
|
|
||||||
|
network := strings.ToUpper(strings.TrimSpace(cfg.Network))
|
||||||
|
currencies := normalizeCurrencies(cfg.Currencies)
|
||||||
|
if len(currencies) == 0 {
|
||||||
|
currencies = normalizeCurrencies(monetixCfg.AllowedCurrencies)
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
if cfg.IsEnabled != nil {
|
||||||
|
enabled = *cfg.IsEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := buildGatewayLimits(cfg.Limits)
|
||||||
|
if limits == nil {
|
||||||
|
limits = &gatewayv1.Limits{MinAmount: "0"}
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(appversion.Version)
|
||||||
|
|
||||||
|
return &gatewayv1.GatewayInstanceDescriptor{
|
||||||
|
Id: id,
|
||||||
|
Rail: gatewayv1.Rail_RAIL_CARD_PAYOUT,
|
||||||
|
Network: network,
|
||||||
|
Currencies: currencies,
|
||||||
|
Capabilities: &gatewayv1.RailCapabilities{
|
||||||
|
CanPayOut: true,
|
||||||
|
CanPayIn: false,
|
||||||
|
CanReadBalance: false,
|
||||||
|
CanSendFee: false,
|
||||||
|
RequiresObserveConfirm: false,
|
||||||
|
},
|
||||||
|
Limits: limits,
|
||||||
|
Version: version,
|
||||||
|
IsEnabled: enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCurrencies(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
clean := strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if clean == "" || seen[clean] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[clean] = true
|
||||||
|
result = append(result, clean)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGatewayLimits(cfg limitsConfig) *gatewayv1.Limits {
|
||||||
|
hasValue := strings.TrimSpace(cfg.MinAmount) != "" ||
|
||||||
|
strings.TrimSpace(cfg.MaxAmount) != "" ||
|
||||||
|
strings.TrimSpace(cfg.PerTxMaxFee) != "" ||
|
||||||
|
strings.TrimSpace(cfg.PerTxMinAmount) != "" ||
|
||||||
|
strings.TrimSpace(cfg.PerTxMaxAmount) != "" ||
|
||||||
|
len(cfg.VolumeLimit) > 0 ||
|
||||||
|
len(cfg.VelocityLimit) > 0 ||
|
||||||
|
len(cfg.CurrencyLimits) > 0
|
||||||
|
if !hasValue {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := &gatewayv1.Limits{
|
||||||
|
MinAmount: strings.TrimSpace(cfg.MinAmount),
|
||||||
|
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
|
||||||
|
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
|
||||||
|
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
|
||||||
|
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.VolumeLimit) > 0 {
|
||||||
|
limits.VolumeLimit = map[string]string{}
|
||||||
|
for key, value := range cfg.VolumeLimit {
|
||||||
|
bucket := strings.TrimSpace(key)
|
||||||
|
amount := strings.TrimSpace(value)
|
||||||
|
if bucket == "" || amount == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.VolumeLimit[bucket] = amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.VelocityLimit) > 0 {
|
||||||
|
limits.VelocityLimit = map[string]int32{}
|
||||||
|
for key, value := range cfg.VelocityLimit {
|
||||||
|
bucket := strings.TrimSpace(key)
|
||||||
|
if bucket == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.VelocityLimit[bucket] = int32(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.CurrencyLimits) > 0 {
|
||||||
|
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
|
||||||
|
for key, override := range cfg.CurrencyLimits {
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(key))
|
||||||
|
if currency == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.CurrencyLimits[currency] = &gatewayv1.LimitsOverride{
|
||||||
|
MaxVolume: strings.TrimSpace(override.MaxVolume),
|
||||||
|
MinAmount: strings.TrimSpace(override.MinAmount),
|
||||||
|
MaxAmount: strings.TrimSpace(override.MaxAmount),
|
||||||
|
MaxFee: strings.TrimSpace(override.MaxFee),
|
||||||
|
MaxOps: int32(override.MaxOps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return limits
|
||||||
|
}
|
||||||
|
|
||||||
type callbackRuntimeConfig struct {
|
type callbackRuntimeConfig struct {
|
||||||
Address string
|
Address string
|
||||||
Path string
|
Path string
|
||||||
@@ -288,7 +458,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig,
|
|||||||
|
|
||||||
func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRuntimeConfig) error {
|
func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRuntimeConfig) error {
|
||||||
if svc == nil {
|
if svc == nil {
|
||||||
return errors.New("nil service provided for callback server")
|
return merrors.InvalidArgument("nil service provided for callback server")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.Address) == "" {
|
if strings.TrimSpace(cfg.Address) == "" {
|
||||||
i.logger.Info("Monetix callback server disabled: address is empty")
|
i.logger.Info("Monetix callback server disabled: address is empty")
|
||||||
|
|||||||
293
api/gateway/mntx/internal/service/gateway/connector.go
Normal file
293
api/gateway/mntx/internal/service/gateway/connector.go
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||||
|
"github.com/tech/sendico/pkg/connector/params"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mntxConnectorID = "mntx"
|
||||||
|
|
||||||
|
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
|
||||||
|
return &connectorv1.GetCapabilitiesResponse{
|
||||||
|
Capabilities: &connectorv1.ConnectorCapabilities{
|
||||||
|
ConnectorType: mntxConnectorID,
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
SupportedAccountKinds: nil,
|
||||||
|
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT},
|
||||||
|
OperationParams: mntxOperationParams(),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("get_account: unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("list_accounts: unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("get_balance: unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
|
||||||
|
if req == nil || req.GetOperation() == nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
|
||||||
|
}
|
||||||
|
op := req.GetOperation()
|
||||||
|
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
if op.GetType() != connectorv1.OperationType_PAYOUT {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
|
||||||
|
}
|
||||||
|
reader := params.New(op.GetParams())
|
||||||
|
amountMinor, currency, err := payoutAmount(op, reader)
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payoutID := strings.TrimSpace(reader.String("payout_id"))
|
||||||
|
if payoutID == "" {
|
||||||
|
payoutID = strings.TrimSpace(op.GetIdempotencyKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(reader.String("card_token")) != "" {
|
||||||
|
resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, amountMinor, currency))
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil
|
||||||
|
}
|
||||||
|
resp, err := s.CreateCardPayout(ctx, buildCardPayoutRequestFromParams(reader, payoutID, amountMinor, currency))
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
|
||||||
|
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||||
|
}
|
||||||
|
resp, err := s.GetCardPayoutStatus(ctx, &mntxv1.GetCardPayoutStatusRequest{PayoutId: strings.TrimSpace(req.GetOperationId())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(resp.GetPayout())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("list_operations: unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mntxOperationParams() []*connectorv1.OperationParamSpec {
|
||||||
|
return []*connectorv1.OperationParamSpec{
|
||||||
|
{OperationType: connectorv1.OperationType_PAYOUT, Params: []*connectorv1.ParamSpec{
|
||||||
|
{Key: "customer_id", Type: connectorv1.ParamType_STRING, Required: true},
|
||||||
|
{Key: "customer_first_name", Type: connectorv1.ParamType_STRING, Required: true},
|
||||||
|
{Key: "customer_last_name", Type: connectorv1.ParamType_STRING, Required: true},
|
||||||
|
{Key: "customer_ip", Type: connectorv1.ParamType_STRING, Required: true},
|
||||||
|
{Key: "card_token", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "card_pan", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "card_exp_year", Type: connectorv1.ParamType_INT, Required: false},
|
||||||
|
{Key: "card_exp_month", Type: connectorv1.ParamType_INT, Required: false},
|
||||||
|
{Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false},
|
||||||
|
{Key: "project_id", Type: connectorv1.ParamType_INT, Required: false},
|
||||||
|
{Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "customer_city", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "customer_address", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "customer_zip", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "masked_pan", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutAmount(op *connectorv1.Operation, reader params.Reader) (int64, string, error) {
|
||||||
|
if op == nil {
|
||||||
|
return 0, "", merrors.InvalidArgument("payout: operation is required")
|
||||||
|
}
|
||||||
|
currency := currencyFromOperation(op)
|
||||||
|
if currency == "" {
|
||||||
|
return 0, "", merrors.InvalidArgument("payout: currency is required")
|
||||||
|
}
|
||||||
|
if minor, ok := reader.Int64("amount_minor"); ok && minor > 0 {
|
||||||
|
return minor, currency, nil
|
||||||
|
}
|
||||||
|
money := op.GetMoney()
|
||||||
|
if money == nil {
|
||||||
|
return 0, "", merrors.InvalidArgument("payout: money is required")
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(money.GetAmount())
|
||||||
|
if amount == "" {
|
||||||
|
return 0, "", merrors.InvalidArgument("payout: amount is required")
|
||||||
|
}
|
||||||
|
dec, err := decimal.NewFromString(amount)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", merrors.InvalidArgument("payout: invalid amount")
|
||||||
|
}
|
||||||
|
minor := dec.Mul(decimal.NewFromInt(100)).IntPart()
|
||||||
|
return minor, currency, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func currencyFromOperation(op *connectorv1.Operation) string {
|
||||||
|
if op == nil || op.GetMoney() == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(op.GetMoney().GetCurrency())
|
||||||
|
if idx := strings.Index(currency, "-"); idx > 0 {
|
||||||
|
currency = currency[:idx]
|
||||||
|
}
|
||||||
|
return strings.ToUpper(currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest {
|
||||||
|
req := &mntxv1.CardTokenPayoutRequest{
|
||||||
|
PayoutId: payoutID,
|
||||||
|
ProjectId: readerInt64(reader, "project_id"),
|
||||||
|
CustomerId: strings.TrimSpace(reader.String("customer_id")),
|
||||||
|
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
|
||||||
|
CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")),
|
||||||
|
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
|
||||||
|
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
|
||||||
|
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
|
||||||
|
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
|
||||||
|
CustomerState: strings.TrimSpace(reader.String("customer_state")),
|
||||||
|
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
|
||||||
|
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
|
||||||
|
AmountMinor: amountMinor,
|
||||||
|
Currency: currency,
|
||||||
|
CardToken: strings.TrimSpace(reader.String("card_token")),
|
||||||
|
CardHolder: strings.TrimSpace(reader.String("card_holder")),
|
||||||
|
MaskedPan: strings.TrimSpace(reader.String("masked_pan")),
|
||||||
|
Metadata: reader.StringMap("metadata"),
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCardPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest {
|
||||||
|
return &mntxv1.CardPayoutRequest{
|
||||||
|
PayoutId: payoutID,
|
||||||
|
ProjectId: readerInt64(reader, "project_id"),
|
||||||
|
CustomerId: strings.TrimSpace(reader.String("customer_id")),
|
||||||
|
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
|
||||||
|
CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")),
|
||||||
|
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
|
||||||
|
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
|
||||||
|
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
|
||||||
|
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
|
||||||
|
CustomerState: strings.TrimSpace(reader.String("customer_state")),
|
||||||
|
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
|
||||||
|
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
|
||||||
|
AmountMinor: amountMinor,
|
||||||
|
Currency: currency,
|
||||||
|
CardPan: strings.TrimSpace(reader.String("card_pan")),
|
||||||
|
CardExpYear: uint32(readerInt64(reader, "card_exp_year")),
|
||||||
|
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")),
|
||||||
|
CardHolder: strings.TrimSpace(reader.String("card_holder")),
|
||||||
|
Metadata: reader.StringMap("metadata"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerInt64(reader params.Reader, key string) int64 {
|
||||||
|
if v, ok := reader.Int64(key); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt {
|
||||||
|
if state == nil {
|
||||||
|
return &connectorv1.OperationReceipt{
|
||||||
|
Status: connectorv1.OperationStatus_PENDING,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &connectorv1.OperationReceipt{
|
||||||
|
OperationId: strings.TrimSpace(state.GetPayoutId()),
|
||||||
|
Status: payoutStatusToOperation(state.GetStatus()),
|
||||||
|
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
|
||||||
|
if state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &connectorv1.Operation{
|
||||||
|
OperationId: strings.TrimSpace(state.GetPayoutId()),
|
||||||
|
Type: connectorv1.OperationType_PAYOUT,
|
||||||
|
Status: payoutStatusToOperation(state.GetStatus()),
|
||||||
|
Money: &moneyv1.Money{
|
||||||
|
Amount: minorToDecimal(state.GetAmountMinor()),
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())),
|
||||||
|
},
|
||||||
|
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
|
||||||
|
CreatedAt: state.GetCreatedAt(),
|
||||||
|
UpdatedAt: state.GetUpdatedAt(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func minorToDecimal(amount int64) string {
|
||||||
|
dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100))
|
||||||
|
return dec.StringFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus {
|
||||||
|
switch status {
|
||||||
|
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
|
||||||
|
return connectorv1.OperationStatus_CONFIRMED
|
||||||
|
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||||
|
return connectorv1.OperationStatus_FAILED
|
||||||
|
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
|
||||||
|
return connectorv1.OperationStatus_PENDING
|
||||||
|
default:
|
||||||
|
return connectorv1.OperationStatus_PENDING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
||||||
|
err := &connectorv1.ConnectorError{
|
||||||
|
Code: code,
|
||||||
|
Message: strings.TrimSpace(message),
|
||||||
|
AccountId: strings.TrimSpace(accountID),
|
||||||
|
}
|
||||||
|
if op != nil {
|
||||||
|
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
|
||||||
|
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
|
||||||
|
err.OperationId = strings.TrimSpace(op.GetOperationId())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapErrorCode(err error) connectorv1.ErrorCode {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return connectorv1.ErrorCode_INVALID_PARAMS
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return connectorv1.ErrorCode_NOT_FOUND
|
||||||
|
case errors.Is(err, merrors.ErrNotImplemented):
|
||||||
|
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
|
||||||
|
case errors.Is(err, merrors.ErrInternal):
|
||||||
|
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
|
||||||
|
default:
|
||||||
|
return connectorv1.ErrorCode_PROVIDER_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
62
api/gateway/mntx/internal/service/gateway/instances.go
Normal file
62
api/gateway/mntx/internal/service/gateway/instances.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListGatewayInstances exposes the Monetix gateway instance descriptors.
|
||||||
|
func (s *Service) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "ListGatewayInstances", s.handleListGatewayInstances, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleListGatewayInstances(_ context.Context, _ *mntxv1.ListGatewayInstancesRequest) gsresponse.Responder[mntxv1.ListGatewayInstancesResponse] {
|
||||||
|
items := make([]*gatewayv1.GatewayInstanceDescriptor, 0, 1)
|
||||||
|
if s.gatewayDescriptor != nil {
|
||||||
|
items = append(items, cloneGatewayDescriptor(s.gatewayDescriptor))
|
||||||
|
}
|
||||||
|
return gsresponse.Success(&mntxv1.ListGatewayInstancesResponse{Items: items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1.GatewayInstanceDescriptor {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := proto.Clone(src).(*gatewayv1.GatewayInstanceDescriptor)
|
||||||
|
if src.Currencies != nil {
|
||||||
|
cp.Currencies = append([]string(nil), src.Currencies...)
|
||||||
|
}
|
||||||
|
if src.Capabilities != nil {
|
||||||
|
cp.Capabilities = proto.Clone(src.Capabilities).(*gatewayv1.RailCapabilities)
|
||||||
|
}
|
||||||
|
if src.Limits != nil {
|
||||||
|
limits := &gatewayv1.Limits{}
|
||||||
|
if src.Limits.VolumeLimit != nil {
|
||||||
|
limits.VolumeLimit = map[string]string{}
|
||||||
|
for key, value := range src.Limits.VolumeLimit {
|
||||||
|
limits.VolumeLimit[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if src.Limits.VelocityLimit != nil {
|
||||||
|
limits.VelocityLimit = map[string]int32{}
|
||||||
|
for key, value := range src.Limits.VelocityLimit {
|
||||||
|
limits.VelocityLimit[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if src.Limits.CurrencyLimits != nil {
|
||||||
|
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
|
||||||
|
for key, value := range src.Limits.CurrencyLimits {
|
||||||
|
if value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.CurrencyLimits[key] = proto.Clone(value).(*gatewayv1.LimitsOverride)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cp.Limits = limits
|
||||||
|
}
|
||||||
|
return cp
|
||||||
|
}
|
||||||
@@ -2,15 +2,12 @@ package gateway
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -18,11 +15,6 @@ var (
|
|||||||
|
|
||||||
rpcLatency *prometheus.HistogramVec
|
rpcLatency *prometheus.HistogramVec
|
||||||
rpcStatus *prometheus.CounterVec
|
rpcStatus *prometheus.CounterVec
|
||||||
|
|
||||||
payoutCounter *prometheus.CounterVec
|
|
||||||
payoutAmountTotal *prometheus.CounterVec
|
|
||||||
payoutErrorCount *prometheus.CounterVec
|
|
||||||
payoutMissedAmounts *prometheus.CounterVec
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func initMetrics() {
|
func initMetrics() {
|
||||||
@@ -42,33 +34,6 @@ func initMetrics() {
|
|||||||
Help: "Total number of RPC invocations grouped by method and status.",
|
Help: "Total number of RPC invocations grouped by method and status.",
|
||||||
}, []string{"method", "status"})
|
}, []string{"method", "status"})
|
||||||
|
|
||||||
payoutCounter = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Namespace: "sendico",
|
|
||||||
Subsystem: "mntx_gateway",
|
|
||||||
Name: "payouts_total",
|
|
||||||
Help: "Total payouts processed grouped by outcome.",
|
|
||||||
}, []string{"status"})
|
|
||||||
|
|
||||||
payoutAmountTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Namespace: "sendico",
|
|
||||||
Subsystem: "mntx_gateway",
|
|
||||||
Name: "payout_amount_total",
|
|
||||||
Help: "Total payout amount grouped by outcome and currency.",
|
|
||||||
}, []string{"status", "currency"})
|
|
||||||
|
|
||||||
payoutErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Namespace: "sendico",
|
|
||||||
Subsystem: "mntx_gateway",
|
|
||||||
Name: "payout_errors_total",
|
|
||||||
Help: "Payout failures grouped by reason.",
|
|
||||||
}, []string{"reason"})
|
|
||||||
|
|
||||||
payoutMissedAmounts = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Namespace: "sendico",
|
|
||||||
Subsystem: "mntx_gateway",
|
|
||||||
Name: "payout_missed_amount_total",
|
|
||||||
Help: "Total payout volume that failed grouped by reason and currency.",
|
|
||||||
}, []string{"reason", "currency"})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,71 +46,6 @@ func observeRPC(method string, err error, duration time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func observePayoutSuccess(amount *moneyv1.Money) {
|
|
||||||
if payoutCounter != nil {
|
|
||||||
payoutCounter.WithLabelValues("processed").Inc()
|
|
||||||
}
|
|
||||||
value, currency := monetaryValue(amount)
|
|
||||||
if value > 0 && payoutAmountTotal != nil {
|
|
||||||
payoutAmountTotal.WithLabelValues("processed", currency).Add(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func observePayoutError(reason string, amount *moneyv1.Money) {
|
|
||||||
reason = reasonLabel(reason)
|
|
||||||
if payoutCounter != nil {
|
|
||||||
payoutCounter.WithLabelValues("failed").Inc()
|
|
||||||
}
|
|
||||||
if payoutErrorCount != nil {
|
|
||||||
payoutErrorCount.WithLabelValues(reason).Inc()
|
|
||||||
}
|
|
||||||
value, currency := monetaryValue(amount)
|
|
||||||
if value <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if payoutAmountTotal != nil {
|
|
||||||
payoutAmountTotal.WithLabelValues("failed", currency).Add(value)
|
|
||||||
}
|
|
||||||
if payoutMissedAmounts != nil {
|
|
||||||
payoutMissedAmounts.WithLabelValues(reason, currency).Add(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func monetaryValue(amount *moneyv1.Money) (float64, string) {
|
|
||||||
if amount == nil {
|
|
||||||
return 0, "unknown"
|
|
||||||
}
|
|
||||||
val := strings.TrimSpace(amount.Amount)
|
|
||||||
if val == "" {
|
|
||||||
return 0, currencyLabel(amount.Currency)
|
|
||||||
}
|
|
||||||
dec, err := decimal.NewFromString(val)
|
|
||||||
if err != nil {
|
|
||||||
return 0, currencyLabel(amount.Currency)
|
|
||||||
}
|
|
||||||
f, _ := dec.Float64()
|
|
||||||
if f < 0 {
|
|
||||||
return 0, currencyLabel(amount.Currency)
|
|
||||||
}
|
|
||||||
return f, currencyLabel(amount.Currency)
|
|
||||||
}
|
|
||||||
|
|
||||||
func currencyLabel(code string) string {
|
|
||||||
code = strings.ToUpper(strings.TrimSpace(code))
|
|
||||||
if code == "" {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
|
|
||||||
func reasonLabel(reason string) string {
|
|
||||||
reason = strings.TrimSpace(reason)
|
|
||||||
if reason == "" {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return strings.ToLower(reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func statusLabel(err error) string {
|
func statusLabel(err error) string {
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
"github.com/tech/sendico/pkg/clock"
|
"github.com/tech/sendico/pkg/clock"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Option configures optional service dependencies.
|
// Option configures optional service dependencies.
|
||||||
@@ -42,3 +43,12 @@ func WithMonetixConfig(cfg monetix.Config) Option {
|
|||||||
s.config = cfg
|
s.config = cfg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithGatewayDescriptor sets the self-declared gateway instance descriptor.
|
||||||
|
func WithGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if descriptor != nil {
|
||||||
|
s.gatewayDescriptor = descriptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
|
|
||||||
return executeUnary(ctx, s, "GetPayout", s.handleGetPayout, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
|
|
||||||
ref := strings.TrimSpace(req.GetPayoutRef())
|
|
||||||
log := s.logger.Named("payout")
|
|
||||||
log.Info("Get payout request received", zap.String("payout_ref", ref))
|
|
||||||
if ref == "" {
|
|
||||||
log.Warn("Get payout request missing payout_ref")
|
|
||||||
return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
|
|
||||||
}
|
|
||||||
|
|
||||||
payout, ok := s.store.Get(ref)
|
|
||||||
if !ok {
|
|
||||||
log.Warn("Payout not found", zap.String("payout_ref", ref))
|
|
||||||
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String()))
|
|
||||||
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type payoutStore struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
payouts map[string]*mntxv1.Payout
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPayoutStore() *payoutStore {
|
|
||||||
return &payoutStore{
|
|
||||||
payouts: make(map[string]*mntxv1.Payout),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *payoutStore) Save(p *mntxv1.Payout) {
|
|
||||||
if p == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.payouts[p.GetPayoutRef()] = clonePayout(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *payoutStore) Get(ref string) (*mntxv1.Payout, bool) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
p, ok := s.payouts[ref]
|
|
||||||
return clonePayout(p), ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func clonePayout(p *mntxv1.Payout) *mntxv1.Payout {
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := proto.Clone(p)
|
|
||||||
if cp, ok := cloned.(*mntxv1.Payout); ok {
|
|
||||||
return cp
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
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"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequest) (*mntxv1.SubmitPayoutResponse, error) {
|
|
||||||
return executeUnary(ctx, s, "SubmitPayout", s.handleSubmitPayout, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] {
|
|
||||||
log := s.logger.Named("payout")
|
|
||||||
log.Info("Submit payout request received",
|
|
||||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
|
||||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
|
||||||
zap.String("currency", strings.TrimSpace(req.GetAmount().GetCurrency())),
|
|
||||||
zap.String("amount", strings.TrimSpace(req.GetAmount().GetAmount())),
|
|
||||||
)
|
|
||||||
|
|
||||||
payout, err := s.buildPayout(req)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Submit payout validation failed", zap.Error(err))
|
|
||||||
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.store.Save(payout)
|
|
||||||
s.emitEvent(payout, nm.NAPending)
|
|
||||||
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason()))
|
|
||||||
|
|
||||||
log.Info("Payout accepted", zap.String("payout_ref", payout.GetPayoutRef()), zap.String("status", payout.GetStatus().String()))
|
|
||||||
return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout, error) {
|
|
||||||
if req == nil {
|
|
||||||
return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
|
|
||||||
}
|
|
||||||
|
|
||||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
|
||||||
if idempotencyKey == "" {
|
|
||||||
return nil, newPayoutError("missing_idempotency_key", merrors.InvalidArgument("idempotency_key is required", "idempotency_key"))
|
|
||||||
}
|
|
||||||
|
|
||||||
orgRef := strings.TrimSpace(req.OrganizationRef)
|
|
||||||
if orgRef == "" {
|
|
||||||
return nil, newPayoutError("missing_organization_ref", merrors.InvalidArgument("organization_ref is required", "organization_ref"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateAmount(req.Amount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateDestination(req.Destination); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if reason := strings.TrimSpace(req.SimulatedFailureReason); reason != "" {
|
|
||||||
return nil, newPayoutError(normalizeReason(reason), merrors.InvalidArgument("simulated payout failure requested"))
|
|
||||||
}
|
|
||||||
|
|
||||||
now := timestamppb.New(s.clock.Now())
|
|
||||||
payout := &mntxv1.Payout{
|
|
||||||
PayoutRef: newPayoutRef(),
|
|
||||||
IdempotencyKey: idempotencyKey,
|
|
||||||
OrganizationRef: orgRef,
|
|
||||||
Destination: req.Destination,
|
|
||||||
Amount: req.Amount,
|
|
||||||
Description: strings.TrimSpace(req.Description),
|
|
||||||
Metadata: req.Metadata,
|
|
||||||
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
return payout, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
|
|
||||||
log := s.logger.Named("payout")
|
|
||||||
outcome := clonePayout(original)
|
|
||||||
if outcome == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate async processing delay for realism.
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
|
|
||||||
outcome.UpdatedAt = timestamppb.New(s.clock.Now())
|
|
||||||
|
|
||||||
if simulatedFailure != "" {
|
|
||||||
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
|
||||||
outcome.FailureReason = simulatedFailure
|
|
||||||
observePayoutError(simulatedFailure, outcome.Amount)
|
|
||||||
s.store.Save(outcome)
|
|
||||||
s.emitEvent(outcome, nm.NAUpdated)
|
|
||||||
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()), zap.String("failure_reason", simulatedFailure))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
|
|
||||||
observePayoutSuccess(outcome.Amount)
|
|
||||||
s.store.Save(outcome)
|
|
||||||
s.emitEvent(outcome, nm.NAUpdated)
|
|
||||||
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) {
|
|
||||||
if payout == nil || s.producer == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Failed to marshal payout event", zapError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
|
|
||||||
if _, err := env.Wrap(payload); err != nil {
|
|
||||||
s.logger.Warn("Failed to wrap payout event payload", zapError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.producer.SendMessage(env); err != nil {
|
|
||||||
s.logger.Warn("Failed to publish payout event", zapError(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func zapError(err error) zap.Field {
|
|
||||||
return zap.Error(err)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func validateAmount(amount *moneyv1.Money) error {
|
|
||||||
if amount == nil {
|
|
||||||
return newPayoutError("missing_amount", merrors.InvalidArgument("amount is required", "amount"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(amount.Currency) == "" {
|
|
||||||
return newPayoutError("missing_currency", merrors.InvalidArgument("amount currency is required", "amount.currency"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val := strings.TrimSpace(amount.Amount)
|
|
||||||
if val == "" {
|
|
||||||
return newPayoutError("missing_amount_value", merrors.InvalidArgument("amount value is required", "amount.amount"))
|
|
||||||
}
|
|
||||||
dec, err := decimal.NewFromString(val)
|
|
||||||
if err != nil {
|
|
||||||
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount must be a decimal value", "amount.amount"))
|
|
||||||
}
|
|
||||||
if dec.Sign() <= 0 {
|
|
||||||
return newPayoutError("non_positive_amount", merrors.InvalidArgument("amount must be positive", "amount.amount"))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateDestination(dest *mntxv1.PayoutDestination) error {
|
|
||||||
if dest == nil {
|
|
||||||
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if bank := dest.GetBankAccount(); bank != nil {
|
|
||||||
return validateBankAccount(bank)
|
|
||||||
}
|
|
||||||
|
|
||||||
if card := dest.GetCard(); card != nil {
|
|
||||||
return validateCardDestination(card)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include bank_account or card", "destination"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateBankAccount(dest *mntxv1.BankAccount) error {
|
|
||||||
if dest == nil {
|
|
||||||
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
|
|
||||||
}
|
|
||||||
iban := strings.TrimSpace(dest.Iban)
|
|
||||||
holder := strings.TrimSpace(dest.AccountHolder)
|
|
||||||
|
|
||||||
if iban == "" && holder == "" {
|
|
||||||
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include iban or account_holder", "destination"))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCardDestination(card *mntxv1.CardDestination) error {
|
|
||||||
if card == nil {
|
|
||||||
return newPayoutError("missing_destination", merrors.InvalidArgument("destination.card is required", "destination.card"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pan := strings.TrimSpace(card.GetPan())
|
|
||||||
token := strings.TrimSpace(card.GetToken())
|
|
||||||
if pan == "" && token == "" {
|
|
||||||
return newPayoutError("invalid_card_destination", merrors.InvalidArgument("card destination must include pan or token", "destination.card"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(card.GetCardholderName()) == "" {
|
|
||||||
return newPayoutError("missing_cardholder_name", merrors.InvalidArgument("cardholder_name is required", "destination.card.cardholder_name"))
|
|
||||||
}
|
|
||||||
|
|
||||||
month := strings.TrimSpace(card.GetExpMonth())
|
|
||||||
year := strings.TrimSpace(card.GetExpYear())
|
|
||||||
if pan != "" {
|
|
||||||
if err := validateExpiry(month, year); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateExpiry(month, year string) error {
|
|
||||||
if month == "" || year == "" {
|
|
||||||
return newPayoutError("missing_expiry", merrors.InvalidArgument("exp_month and exp_year are required for card payouts", "destination.card.expiry"))
|
|
||||||
}
|
|
||||||
|
|
||||||
m, err := strconv.Atoi(month)
|
|
||||||
if err != nil || m < 1 || m > 12 {
|
|
||||||
return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("exp_month must be between 01 and 12", "destination.card.exp_month"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := strconv.Atoi(year); err != nil || len(year) < 2 {
|
|
||||||
return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("exp_year must be numeric", "destination.card.exp_year"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -5,30 +5,33 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
clock clockpkg.Clock
|
clock clockpkg.Clock
|
||||||
producer msg.Producer
|
producer msg.Producer
|
||||||
store *payoutStore
|
cardStore *cardPayoutStore
|
||||||
cardStore *cardPayoutStore
|
config monetix.Config
|
||||||
config monetix.Config
|
httpClient *http.Client
|
||||||
httpClient *http.Client
|
card *cardPayoutProcessor
|
||||||
card *cardPayoutProcessor
|
gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor
|
||||||
|
announcer *discovery.Announcer
|
||||||
|
|
||||||
mntxv1.UnimplementedMntxGatewayServiceServer
|
connectorv1.UnimplementedConnectorServiceServer
|
||||||
}
|
}
|
||||||
|
|
||||||
type payoutFailure interface {
|
type payoutFailure interface {
|
||||||
@@ -58,7 +61,6 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
|||||||
svc := &Service{
|
svc := &Service{
|
||||||
logger: logger.Named("service"),
|
logger: logger.Named("service"),
|
||||||
clock: clockpkg.NewSystem(),
|
clock: clockpkg.NewSystem(),
|
||||||
store: newPayoutStore(),
|
|
||||||
cardStore: newCardPayoutStore(),
|
cardStore: newCardPayoutStore(),
|
||||||
config: monetix.DefaultConfig(),
|
config: monetix.DefaultConfig(),
|
||||||
}
|
}
|
||||||
@@ -86,6 +88,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer)
|
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer)
|
||||||
|
svc.startDiscoveryAnnouncer()
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
@@ -93,10 +96,19 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
|||||||
// Register wires the service onto the provided gRPC router.
|
// Register wires the service onto the provided gRPC router.
|
||||||
func (s *Service) Register(router routers.GRPC) error {
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
mntxv1.RegisterMntxGatewayServiceServer(reg, s)
|
connectorv1.RegisterConnectorServiceServer(reg, s)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.announcer != nil {
|
||||||
|
s.announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||||
log := svc.logger.Named("rpc")
|
log := svc.logger.Named("rpc")
|
||||||
log.Info("RPC request started", zap.String("method", method))
|
log.Info("RPC request started", zap.String("method", method))
|
||||||
@@ -114,10 +126,6 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
|
|||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPayoutRef() string {
|
|
||||||
return "pyt_" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeReason(reason string) string {
|
func normalizeReason(reason string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(reason))
|
return strings.ToLower(strings.TrimSpace(reason))
|
||||||
}
|
}
|
||||||
@@ -128,3 +136,60 @@ func newPayoutError(reason string, err error) error {
|
|||||||
err: err,
|
err: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "CARD_PAYOUT_RAIL_GATEWAY",
|
||||||
|
Rail: "CARD_PAYOUT",
|
||||||
|
Operations: []string{"payout.card"},
|
||||||
|
InvokeURI: discovery.DefaultInvokeURI(string(mservice.MntxGateway)),
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
if s.gatewayDescriptor != nil {
|
||||||
|
if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" {
|
||||||
|
announce.ID = id
|
||||||
|
}
|
||||||
|
announce.Network = strings.TrimSpace(s.gatewayDescriptor.GetNetwork())
|
||||||
|
announce.Currencies = append([]string(nil), s.gatewayDescriptor.GetCurrencies()...)
|
||||||
|
announce.Limits = limitsFromDescriptor(s.gatewayDescriptor.GetLimits())
|
||||||
|
}
|
||||||
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
|
||||||
|
s.announcer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func limitsFromDescriptor(src *gatewayv1.Limits) *discovery.Limits {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
limits := &discovery.Limits{
|
||||||
|
MinAmount: strings.TrimSpace(src.GetMinAmount()),
|
||||||
|
MaxAmount: strings.TrimSpace(src.GetMaxAmount()),
|
||||||
|
VolumeLimit: map[string]string{},
|
||||||
|
VelocityLimit: map[string]int{},
|
||||||
|
}
|
||||||
|
for key, value := range src.GetVolumeLimit() {
|
||||||
|
k := strings.TrimSpace(key)
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
if k == "" || v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.VolumeLimit[k] = v
|
||||||
|
}
|
||||||
|
for key, value := range src.GetVelocityLimit() {
|
||||||
|
k := strings.TrimSpace(key)
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.VelocityLimit[k] = int(value)
|
||||||
|
}
|
||||||
|
if len(limits.VolumeLimit) == 0 {
|
||||||
|
limits.VolumeLimit = nil
|
||||||
|
}
|
||||||
|
if len(limits.VelocityLimit) == 0 {
|
||||||
|
limits.VelocityLimit = nil
|
||||||
|
}
|
||||||
|
return limits
|
||||||
|
}
|
||||||
|
|||||||
1
api/gateway/tgsettle/.gitignore
vendored
Normal file
1
api/gateway/tgsettle/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/mntx-gateway
|
||||||
40
api/gateway/tgsettle/config.yml
Normal file
40
api/gateway/tgsettle/config.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50080"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9406"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: TGSETTLE_GATEWAY_MONGO_HOST
|
||||||
|
port_env: TGSETTLE_GATEWAY_MONGO_PORT
|
||||||
|
database_env: TGSETTLE_GATEWAY_MONGO_DATABASE
|
||||||
|
user_env: TGSETTLE_GATEWAY_MONGO_USER
|
||||||
|
password_env: TGSETTLE_GATEWAY_MONGO_PASSWORD
|
||||||
|
auth_source_env: TGSETTLE_GATEWAY_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: TGSETTLE_GATEWAY_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: TGSettle Gateway Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
rail: "provider_settlement"
|
||||||
|
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
|
||||||
|
timeout_seconds: 120
|
||||||
|
accepted_user_ids: []
|
||||||
51
api/gateway/tgsettle/go.mod
Normal file
51
api/gateway/tgsettle/go.mod
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
module github.com/tech/sendico/gateway/tgsettle
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
|
go.uber.org/zap v1.27.1
|
||||||
|
google.golang.org/grpc v1.78.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.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.5 // 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
|
||||||
|
)
|
||||||
225
api/gateway/tgsettle/go.sum
Normal file
225
api/gateway/tgsettle/go.sum
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||||
|
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
|
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||||
|
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||||
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
27
api/gateway/tgsettle/internal/appversion/version.go
Normal file
27
api/gateway/tgsettle/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package appversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/pkg/version"
|
||||||
|
vf "github.com/tech/sendico/pkg/version/factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build information. Populated at build-time.
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
Revision string
|
||||||
|
Branch string
|
||||||
|
BuildUser string
|
||||||
|
BuildDate string
|
||||||
|
)
|
||||||
|
|
||||||
|
func Create() version.Printer {
|
||||||
|
info := version.Info{
|
||||||
|
Program: "Sendico Payment Gateway Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&info)
|
||||||
|
}
|
||||||
136
api/gateway/tgsettle/internal/server/internal/serverimp.go
Normal file
136
api/gateway/tgsettle/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/internal/service/gateway"
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||||
|
gatewaymongo "github.com/tech/sendico/gateway/tgsettle/storage/mongo"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
|
||||||
|
config *config
|
||||||
|
app *grpcapp.App[storage.Repository]
|
||||||
|
service *gateway.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
Gateway gatewayConfig `yaml:"gateway"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gatewayConfig struct {
|
||||||
|
Rail string `yaml:"rail"`
|
||||||
|
TargetChatIDEnv string `yaml:"target_chat_id_env"`
|
||||||
|
TimeoutSeconds int32 `yaml:"timeout_seconds"`
|
||||||
|
AcceptedUserIDs []string `yaml:"accepted_user_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
|
return &Imp{
|
||||||
|
logger: logger.Named("server"),
|
||||||
|
file: file,
|
||||||
|
debug: debug,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Shutdown() {
|
||||||
|
if i.app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
i.app.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
var broker mb.Broker
|
||||||
|
if cfg.Messaging != nil && cfg.Messaging.Driver != "" {
|
||||||
|
broker, err = msg.CreateMessagingBroker(i.logger, cfg.Messaging)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to create messaging broker", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
|
return gatewaymongo.New(logger, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
|
gwCfg := gateway.Config{
|
||||||
|
Rail: cfg.Gateway.Rail,
|
||||||
|
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
|
||||||
|
TimeoutSeconds: cfg.Gateway.TimeoutSeconds,
|
||||||
|
AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs,
|
||||||
|
}
|
||||||
|
svc := gateway.NewService(logger, repo, producer, broker, gwCfg)
|
||||||
|
i.service = svc
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := grpcapp.NewApp(i.logger, "tgsettle_gateway", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.app = app
|
||||||
|
return i.app.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
|
data, err := os.ReadFile(i.file)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg := &config{Config: &grpcapp.Config{}}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg.Runtime == nil {
|
||||||
|
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||||
|
}
|
||||||
|
if cfg.GRPC == nil {
|
||||||
|
cfg.GRPC = &routers.GRPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: ":50080",
|
||||||
|
EnableReflection: true,
|
||||||
|
EnableHealth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Metrics == nil {
|
||||||
|
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"}
|
||||||
|
}
|
||||||
|
if cfg.Gateway.Rail == "" {
|
||||||
|
return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
11
api/gateway/tgsettle/internal/server/server.go
Normal file
11
api/gateway/tgsettle/internal/server/server.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/gateway/tgsettle/internal/server/internal"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return serverimp.Create(logger, file, debug)
|
||||||
|
}
|
||||||
253
api/gateway/tgsettle/internal/service/gateway/connector.go
Normal file
253
api/gateway/tgsettle/internal/service/gateway/connector.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/connector/params"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tgsettleConnectorID = "tgsettle"
|
||||||
|
|
||||||
|
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
|
||||||
|
return &connectorv1.GetCapabilitiesResponse{
|
||||||
|
Capabilities: &connectorv1.ConnectorCapabilities{
|
||||||
|
ConnectorType: tgsettleConnectorID,
|
||||||
|
Version: "",
|
||||||
|
SupportedAccountKinds: nil,
|
||||||
|
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER},
|
||||||
|
OperationParams: tgsettleOperationParams(),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("get_account: unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("list_accounts: unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("get_balance: unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
|
||||||
|
if req == nil || req.GetOperation() == nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
|
||||||
|
}
|
||||||
|
op := req.GetOperation()
|
||||||
|
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
if op.GetType() != connectorv1.OperationType_TRANSFER {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
|
||||||
|
}
|
||||||
|
reader := params.New(op.GetParams())
|
||||||
|
paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id"))
|
||||||
|
if paymentIntentID == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: payment_intent_id is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
source := operationAccountID(op.GetFrom())
|
||||||
|
if source == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
dest, err := transferDestinationFromOperation(op)
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
amount := op.GetMoney()
|
||||||
|
if amount == nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := reader.StringMap("metadata")
|
||||||
|
if metadata == nil {
|
||||||
|
metadata = map[string]string{}
|
||||||
|
}
|
||||||
|
metadata[metadataPaymentIntentID] = paymentIntentID
|
||||||
|
if quoteRef := strings.TrimSpace(reader.String("quote_ref")); quoteRef != "" {
|
||||||
|
metadata[metadataQuoteRef] = quoteRef
|
||||||
|
}
|
||||||
|
if targetChatID := strings.TrimSpace(reader.String("target_chat_id")); targetChatID != "" {
|
||||||
|
metadata[metadataTargetChatID] = targetChatID
|
||||||
|
}
|
||||||
|
if outgoingLeg := strings.TrimSpace(reader.String("outgoing_leg")); outgoingLeg != "" {
|
||||||
|
metadata[metadataOutgoingLeg] = outgoingLeg
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: strings.TrimSpace(reader.String("organization_ref")),
|
||||||
|
SourceWalletRef: source,
|
||||||
|
Destination: dest,
|
||||||
|
Amount: normalizeMoneyForTransfer(amount),
|
||||||
|
Metadata: metadata,
|
||||||
|
ClientReference: paymentIntentID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
transfer := resp.GetTransfer()
|
||||||
|
return &connectorv1.SubmitOperationResponse{
|
||||||
|
Receipt: &connectorv1.OperationReceipt{
|
||||||
|
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||||
|
Status: transferStatusToOperation(transfer.GetStatus()),
|
||||||
|
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
|
||||||
|
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||||
|
}
|
||||||
|
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &connectorv1.GetOperationResponse{Operation: transferToOperation(resp.GetTransfer())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||||
|
return nil, merrors.NotImplemented("list_operations: unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tgsettleOperationParams() []*connectorv1.OperationParamSpec {
|
||||||
|
return []*connectorv1.OperationParamSpec{
|
||||||
|
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
|
||||||
|
{Key: "payment_intent_id", Type: connectorv1.ParamType_STRING, Required: true},
|
||||||
|
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "quote_ref", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "target_chat_id", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "outgoing_leg", Type: connectorv1.ParamType_STRING, Required: false},
|
||||||
|
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
|
||||||
|
if op == nil {
|
||||||
|
return nil, merrors.InvalidArgument("transfer: operation is required")
|
||||||
|
}
|
||||||
|
if to := op.GetTo(); to != nil {
|
||||||
|
if account := to.GetAccount(); account != nil {
|
||||||
|
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
|
||||||
|
}
|
||||||
|
if ext := to.GetExternal(); ext != nil {
|
||||||
|
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, merrors.InvalidArgument("transfer: to.account or to.external is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMoneyForTransfer(m *moneyv1.Money) *moneyv1.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(m.GetCurrency())
|
||||||
|
if idx := strings.Index(currency, "-"); idx > 0 {
|
||||||
|
currency = currency[:idx]
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Amount: strings.TrimSpace(m.GetAmount()),
|
||||||
|
Currency: currency,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
|
||||||
|
if transfer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
op := &connectorv1.Operation{
|
||||||
|
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||||
|
Type: connectorv1.OperationType_TRANSFER,
|
||||||
|
Status: transferStatusToOperation(transfer.GetStatus()),
|
||||||
|
Money: transfer.GetRequestedAmount(),
|
||||||
|
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
|
||||||
|
CreatedAt: transfer.GetCreatedAt(),
|
||||||
|
UpdatedAt: transfer.GetUpdatedAt(),
|
||||||
|
}
|
||||||
|
if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" {
|
||||||
|
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||||
|
ConnectorId: tgsettleConnectorID,
|
||||||
|
AccountId: source,
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
if dest := transfer.GetDestination(); dest != nil {
|
||||||
|
switch d := dest.GetDestination().(type) {
|
||||||
|
case *chainv1.TransferDestination_ManagedWalletRef:
|
||||||
|
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||||
|
ConnectorId: tgsettleConnectorID,
|
||||||
|
AccountId: strings.TrimSpace(d.ManagedWalletRef),
|
||||||
|
}}}
|
||||||
|
case *chainv1.TransferDestination_ExternalAddress:
|
||||||
|
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
|
||||||
|
ExternalRef: strings.TrimSpace(d.ExternalAddress),
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
|
||||||
|
switch status {
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||||
|
return connectorv1.OperationStatus_CONFIRMED
|
||||||
|
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||||
|
return connectorv1.OperationStatus_FAILED
|
||||||
|
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||||
|
return connectorv1.OperationStatus_CANCELED
|
||||||
|
default:
|
||||||
|
return connectorv1.OperationStatus_PENDING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationAccountID(party *connectorv1.OperationParty) string {
|
||||||
|
if party == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if account := party.GetAccount(); account != nil {
|
||||||
|
return strings.TrimSpace(account.GetAccountId())
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
||||||
|
err := &connectorv1.ConnectorError{
|
||||||
|
Code: code,
|
||||||
|
Message: strings.TrimSpace(message),
|
||||||
|
AccountId: strings.TrimSpace(accountID),
|
||||||
|
}
|
||||||
|
if op != nil {
|
||||||
|
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
|
||||||
|
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
|
||||||
|
err.OperationId = strings.TrimSpace(op.GetOperationId())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapErrorCode(err error) connectorv1.ErrorCode {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return connectorv1.ErrorCode_INVALID_PARAMS
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return connectorv1.ErrorCode_NOT_FOUND
|
||||||
|
case errors.Is(err, merrors.ErrNotImplemented):
|
||||||
|
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
|
||||||
|
case errors.Is(err, merrors.ErrInternal):
|
||||||
|
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
|
||||||
|
default:
|
||||||
|
return connectorv1.ErrorCode_PROVIDER_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
541
api/gateway/tgsettle/internal/service/gateway/service.go
Normal file
541
api/gateway/tgsettle/internal/service/gateway/service.go
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
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"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultConfirmationTimeoutSeconds = 120
|
||||||
|
executedStatus = "executed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
metadataPaymentIntentID = "payment_intent_id"
|
||||||
|
metadataQuoteRef = "quote_ref"
|
||||||
|
metadataTargetChatID = "target_chat_id"
|
||||||
|
metadataOutgoingLeg = "outgoing_leg"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
connectorv1.UnimplementedConnectorServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
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(router routers.GRPC) error {
|
||||||
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
|
connectorv1.RegisterConnectorServiceServer(reg, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("submit_transfer: request is required")
|
||||||
|
}
|
||||||
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
return nil, merrors.InvalidArgument("submit_transfer: idempotency_key is required")
|
||||||
|
}
|
||||||
|
amount := req.GetAmount()
|
||||||
|
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("submit_transfer: amount is required")
|
||||||
|
}
|
||||||
|
intent, err := intentFromSubmitTransfer(req, s.rail, s.chatID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.repo == nil || s.repo.Payments() == nil {
|
||||||
|
return nil, merrors.Internal("payment gateway storage unavailable")
|
||||||
|
}
|
||||||
|
existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return &chainv1.SubmitTransferResponse{Transfer: transferFromExecution(existing, req)}, nil
|
||||||
|
}
|
||||||
|
if err := s.onIntent(ctx, intent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chainv1.SubmitTransferResponse{Transfer: transferFromRequest(req)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("get_transfer: request is required")
|
||||||
|
}
|
||||||
|
transferRef := strings.TrimSpace(req.GetTransferRef())
|
||||||
|
if transferRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_transfer: transfer_ref is required")
|
||||||
|
}
|
||||||
|
if s.repo == nil || s.repo.Payments() == nil {
|
||||||
|
return nil, merrors.Internal("payment gateway storage unavailable")
|
||||||
|
}
|
||||||
|
existing, err := s.repo.Payments().FindByIdempotencyKey(ctx, transferRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return &chainv1.GetTransferResponse{Transfer: transferFromExecution(existing, nil)}, nil
|
||||||
|
}
|
||||||
|
if s.hasPending(transferRef) {
|
||||||
|
return &chainv1.GetTransferResponse{Transfer: transferPending(transferRef)}, nil
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.NotFound, "transfer not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
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) hasPending(requestID string) bool {
|
||||||
|
requestID = strings.TrimSpace(requestID)
|
||||||
|
if requestID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, ok := s.pending[requestID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caps := []string{"telegram_confirmation", "money_persistence", "observe.confirm", "payout.fiat"}
|
||||||
|
if s.rail != "" {
|
||||||
|
caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail))
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: string(mservice.PaymentGateway),
|
||||||
|
Rail: s.rail,
|
||||||
|
Operations: caps,
|
||||||
|
InvokeURI: discovery.DefaultInvokeURI(string(mservice.PaymentGateway)),
|
||||||
|
}
|
||||||
|
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 intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, defaultChatID string) (*model.PaymentGatewayIntent, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("submit_transfer: request is required")
|
||||||
|
}
|
||||||
|
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
return nil, merrors.InvalidArgument("submit_transfer: idempotency_key is required")
|
||||||
|
}
|
||||||
|
amount := req.GetAmount()
|
||||||
|
if amount == nil {
|
||||||
|
return nil, merrors.InvalidArgument("submit_transfer: amount is required")
|
||||||
|
}
|
||||||
|
requestedMoney := &paymenttypes.Money{
|
||||||
|
Amount: strings.TrimSpace(amount.GetAmount()),
|
||||||
|
Currency: strings.TrimSpace(amount.GetCurrency()),
|
||||||
|
}
|
||||||
|
if requestedMoney.Amount == "" || requestedMoney.Currency == "" {
|
||||||
|
return nil, merrors.InvalidArgument("submit_transfer: amount is required")
|
||||||
|
}
|
||||||
|
metadata := req.GetMetadata()
|
||||||
|
paymentIntentID := strings.TrimSpace(req.GetClientReference())
|
||||||
|
if paymentIntentID == "" {
|
||||||
|
paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID])
|
||||||
|
}
|
||||||
|
if paymentIntentID == "" {
|
||||||
|
return nil, merrors.InvalidArgument("submit_transfer: payment_intent_id is required")
|
||||||
|
}
|
||||||
|
quoteRef := strings.TrimSpace(metadata[metadataQuoteRef])
|
||||||
|
targetChatID := strings.TrimSpace(metadata[metadataTargetChatID])
|
||||||
|
outgoingLeg := strings.TrimSpace(metadata[metadataOutgoingLeg])
|
||||||
|
if outgoingLeg == "" {
|
||||||
|
outgoingLeg = strings.TrimSpace(defaultRail)
|
||||||
|
}
|
||||||
|
if targetChatID == "" {
|
||||||
|
targetChatID = strings.TrimSpace(defaultChatID)
|
||||||
|
}
|
||||||
|
return &model.PaymentGatewayIntent{
|
||||||
|
PaymentIntentID: paymentIntentID,
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
OutgoingLeg: outgoingLeg,
|
||||||
|
QuoteRef: quoteRef,
|
||||||
|
RequestedMoney: requestedMoney,
|
||||||
|
TargetChatID: targetChatID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer {
|
||||||
|
if req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
amount := req.GetAmount()
|
||||||
|
return &chainv1.Transfer{
|
||||||
|
TransferRef: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
|
||||||
|
SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()),
|
||||||
|
Destination: req.GetDestination(),
|
||||||
|
RequestedAmount: amount,
|
||||||
|
Status: chainv1.TransferStatus_TRANSFER_SUBMITTED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferFromExecution(exec *storagemodel.PaymentExecution, req *chainv1.SubmitTransferRequest) *chainv1.Transfer {
|
||||||
|
if exec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var requested *moneyv1.Money
|
||||||
|
if req != nil && req.GetAmount() != nil {
|
||||||
|
requested = req.GetAmount()
|
||||||
|
}
|
||||||
|
net := moneyFromPayment(exec.ExecutedMoney)
|
||||||
|
status := chainv1.TransferStatus_TRANSFER_CONFIRMED
|
||||||
|
if strings.TrimSpace(exec.Status) != "" && !strings.EqualFold(exec.Status, executedStatus) {
|
||||||
|
status = chainv1.TransferStatus_TRANSFER_PENDING
|
||||||
|
}
|
||||||
|
transfer := &chainv1.Transfer{
|
||||||
|
TransferRef: strings.TrimSpace(exec.IdempotencyKey),
|
||||||
|
IdempotencyKey: strings.TrimSpace(exec.IdempotencyKey),
|
||||||
|
RequestedAmount: requested,
|
||||||
|
NetAmount: net,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if req != nil {
|
||||||
|
transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
|
||||||
|
transfer.Destination = req.GetDestination()
|
||||||
|
}
|
||||||
|
if !exec.ExecutedAt.IsZero() {
|
||||||
|
ts := timestamppb.New(exec.ExecutedAt)
|
||||||
|
transfer.CreatedAt = ts
|
||||||
|
transfer.UpdatedAt = ts
|
||||||
|
}
|
||||||
|
return transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferPending(requestID string) *chainv1.Transfer {
|
||||||
|
ref := strings.TrimSpace(requestID)
|
||||||
|
if ref == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &chainv1.Transfer{
|
||||||
|
TransferRef: ref,
|
||||||
|
IdempotencyKey: ref,
|
||||||
|
Status: chainv1.TransferStatus_TRANSFER_SUBMITTED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyFromPayment(m *paymenttypes.Money) *moneyv1.Money {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(m.Currency)
|
||||||
|
amount := strings.TrimSpace(m.Amount)
|
||||||
|
if currency == "" || amount == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEnv(env string) string {
|
||||||
|
if strings.TrimSpace(env) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(os.Getenv(env))
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ grpcapp.Service = (*Service)(nil)
|
||||||
289
api/gateway/tgsettle/internal/service/gateway/service_test.go
Normal file
289
api/gateway/tgsettle/internal/service/gateway/service_test.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||||
|
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||||
|
envelope "github.com/tech/sendico/pkg/messaging/envelope"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
notification "github.com/tech/sendico/pkg/model/notification"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakePaymentsStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
executions map[string]*storagemodel.PaymentExecution
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentExecution, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.executions[key], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePaymentsStore) InsertExecution(_ context.Context, exec *storagemodel.PaymentExecution) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.executions == nil {
|
||||||
|
f.executions = map[string]*storagemodel.PaymentExecution{}
|
||||||
|
}
|
||||||
|
if _, ok := f.executions[exec.IdempotencyKey]; ok {
|
||||||
|
return storage.ErrDuplicate
|
||||||
|
}
|
||||||
|
f.executions[exec.IdempotencyKey] = exec
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTelegramStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records map[string]*storagemodel.TelegramConfirmation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.TelegramConfirmation) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.records == nil {
|
||||||
|
f.records = map[string]*storagemodel.TelegramConfirmation{}
|
||||||
|
}
|
||||||
|
f.records[record.RequestID] = record
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRepo struct {
|
||||||
|
payments *fakePaymentsStore
|
||||||
|
tg *fakeTelegramStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) Payments() storage.PaymentsStore {
|
||||||
|
return f.payments
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
|
||||||
|
return f.tg
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureProducer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
confirmationRequests []*model.ConfirmationRequest
|
||||||
|
executions []*model.PaymentGatewayExecution
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureProducer) SendMessage(env envelope.Envelope) error {
|
||||||
|
_, _ = env.Serialize()
|
||||||
|
switch env.GetSignature().ToString() {
|
||||||
|
case model.NewNotification(mservice.Notifications, notification.NAConfirmationRequest).ToString():
|
||||||
|
var req model.ConfirmationRequest
|
||||||
|
if err := json.Unmarshal(env.GetData(), &req); err == nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.confirmationRequests = append(c.confirmationRequests, &req)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
case model.NewNotification(mservice.PaymentGateway, notification.NAPaymentGatewayExecution).ToString():
|
||||||
|
var exec model.PaymentGatewayExecution
|
||||||
|
if err := json.Unmarshal(env.GetData(), &exec); err == nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.executions = append(c.executions, &exec)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureProducer) Reset() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.confirmationRequests = nil
|
||||||
|
c.executions = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnIntentCreatesConfirmationRequest(t *testing.T) {
|
||||||
|
logger := mloggerfactory.NewLogger(false)
|
||||||
|
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
|
||||||
|
prod := &captureProducer{}
|
||||||
|
t.Setenv("PGS_CHAT_ID", "-100")
|
||||||
|
svc := NewService(logger, repo, prod, nil, Config{
|
||||||
|
Rail: "card",
|
||||||
|
TargetChatIDEnv: "PGS_CHAT_ID",
|
||||||
|
TimeoutSeconds: 90,
|
||||||
|
AcceptedUserIDs: []string{"42"},
|
||||||
|
})
|
||||||
|
prod.Reset()
|
||||||
|
|
||||||
|
intent := &model.PaymentGatewayIntent{
|
||||||
|
PaymentIntentID: "pi-1",
|
||||||
|
IdempotencyKey: "idem-1",
|
||||||
|
OutgoingLeg: "card",
|
||||||
|
QuoteRef: "quote-1",
|
||||||
|
RequestedMoney: &paymenttypes.Money{Amount: "10.50", Currency: "USD"},
|
||||||
|
TargetChatID: "",
|
||||||
|
}
|
||||||
|
if err := svc.onIntent(context.Background(), intent); err != nil {
|
||||||
|
t.Fatalf("onIntent error: %v", err)
|
||||||
|
}
|
||||||
|
if len(prod.confirmationRequests) != 1 {
|
||||||
|
t.Fatalf("expected 1 confirmation request, got %d", len(prod.confirmationRequests))
|
||||||
|
}
|
||||||
|
req := prod.confirmationRequests[0]
|
||||||
|
if req.RequestID != "idem-1" || req.PaymentIntentID != "pi-1" || req.QuoteRef != "quote-1" {
|
||||||
|
t.Fatalf("unexpected confirmation request fields: %#v", req)
|
||||||
|
}
|
||||||
|
if req.TargetChatID != "-100" {
|
||||||
|
t.Fatalf("expected target chat id -100, got %q", req.TargetChatID)
|
||||||
|
}
|
||||||
|
if req.RequestedMoney == nil || req.RequestedMoney.Amount != "10.50" || req.RequestedMoney.Currency != "USD" {
|
||||||
|
t.Fatalf("requested money mismatch: %#v", req.RequestedMoney)
|
||||||
|
}
|
||||||
|
if req.TimeoutSeconds != 90 {
|
||||||
|
t.Fatalf("expected timeout 90, got %d", req.TimeoutSeconds)
|
||||||
|
}
|
||||||
|
if req.SourceService != string(mservice.PaymentGateway) || req.Rail != "card" {
|
||||||
|
t.Fatalf("unexpected source/rail: %#v", req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmationResultPersistsExecutionAndReply(t *testing.T) {
|
||||||
|
logger := mloggerfactory.NewLogger(false)
|
||||||
|
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
|
||||||
|
prod := &captureProducer{}
|
||||||
|
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
|
||||||
|
intent := &model.PaymentGatewayIntent{
|
||||||
|
PaymentIntentID: "pi-2",
|
||||||
|
IdempotencyKey: "idem-2",
|
||||||
|
QuoteRef: "quote-2",
|
||||||
|
OutgoingLeg: "card",
|
||||||
|
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
||||||
|
}
|
||||||
|
svc.trackIntent("idem-2", intent)
|
||||||
|
|
||||||
|
result := &model.ConfirmationResult{
|
||||||
|
RequestID: "idem-2",
|
||||||
|
Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
||||||
|
Status: model.ConfirmationStatusConfirmed,
|
||||||
|
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2", Text: "5 EUR"},
|
||||||
|
}
|
||||||
|
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
|
||||||
|
t.Fatalf("onConfirmationResult error: %v", err)
|
||||||
|
}
|
||||||
|
if repo.payments.executions["idem-2"] == nil {
|
||||||
|
t.Fatalf("expected payment execution to be stored")
|
||||||
|
}
|
||||||
|
if repo.payments.executions["idem-2"].ExecutedMoney == nil || repo.payments.executions["idem-2"].ExecutedMoney.Amount != "5" {
|
||||||
|
t.Fatalf("executed money not stored correctly")
|
||||||
|
}
|
||||||
|
if repo.tg.records["idem-2"] == nil || repo.tg.records["idem-2"].RawReply.Text != "5 EUR" {
|
||||||
|
t.Fatalf("telegram reply not stored correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClarifiedResultPersistsExecution(t *testing.T) {
|
||||||
|
logger := mloggerfactory.NewLogger(false)
|
||||||
|
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
|
||||||
|
prod := &captureProducer{}
|
||||||
|
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
|
||||||
|
intent := &model.PaymentGatewayIntent{
|
||||||
|
PaymentIntentID: "pi-clarified",
|
||||||
|
IdempotencyKey: "idem-clarified",
|
||||||
|
QuoteRef: "quote-clarified",
|
||||||
|
OutgoingLeg: "card",
|
||||||
|
RequestedMoney: &paymenttypes.Money{Amount: "12", Currency: "USD"},
|
||||||
|
}
|
||||||
|
svc.trackIntent("idem-clarified", intent)
|
||||||
|
|
||||||
|
result := &model.ConfirmationResult{
|
||||||
|
RequestID: "idem-clarified",
|
||||||
|
Money: &paymenttypes.Money{Amount: "12", Currency: "USD"},
|
||||||
|
Status: model.ConfirmationStatusClarified,
|
||||||
|
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "3", Text: "12 USD"},
|
||||||
|
}
|
||||||
|
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
|
||||||
|
t.Fatalf("onConfirmationResult error: %v", err)
|
||||||
|
}
|
||||||
|
if repo.payments.executions["idem-clarified"] == nil {
|
||||||
|
t.Fatalf("expected payment execution to be stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdempotencyPreventsDuplicateWrites(t *testing.T) {
|
||||||
|
logger := mloggerfactory.NewLogger(false)
|
||||||
|
repo := &fakeRepo{payments: &fakePaymentsStore{executions: map[string]*storagemodel.PaymentExecution{
|
||||||
|
"idem-3": {IdempotencyKey: "idem-3"},
|
||||||
|
}}, tg: &fakeTelegramStore{}}
|
||||||
|
prod := &captureProducer{}
|
||||||
|
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
|
||||||
|
intent := &model.PaymentGatewayIntent{
|
||||||
|
PaymentIntentID: "pi-3",
|
||||||
|
IdempotencyKey: "idem-3",
|
||||||
|
OutgoingLeg: "card",
|
||||||
|
QuoteRef: "quote-3",
|
||||||
|
RequestedMoney: &paymenttypes.Money{Amount: "1", Currency: "USD"},
|
||||||
|
TargetChatID: "chat",
|
||||||
|
}
|
||||||
|
if err := svc.onIntent(context.Background(), intent); err != nil {
|
||||||
|
t.Fatalf("onIntent error: %v", err)
|
||||||
|
}
|
||||||
|
if len(prod.confirmationRequests) != 0 {
|
||||||
|
t.Fatalf("expected no confirmation request for duplicate intent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeoutDoesNotPersistExecution(t *testing.T) {
|
||||||
|
logger := mloggerfactory.NewLogger(false)
|
||||||
|
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
|
||||||
|
prod := &captureProducer{}
|
||||||
|
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
|
||||||
|
intent := &model.PaymentGatewayIntent{
|
||||||
|
PaymentIntentID: "pi-4",
|
||||||
|
IdempotencyKey: "idem-4",
|
||||||
|
QuoteRef: "quote-4",
|
||||||
|
OutgoingLeg: "card",
|
||||||
|
RequestedMoney: &paymenttypes.Money{Amount: "8", Currency: "USD"},
|
||||||
|
}
|
||||||
|
svc.trackIntent("idem-4", intent)
|
||||||
|
|
||||||
|
result := &model.ConfirmationResult{
|
||||||
|
RequestID: "idem-4",
|
||||||
|
Status: model.ConfirmationStatusTimeout,
|
||||||
|
}
|
||||||
|
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
|
||||||
|
t.Fatalf("onConfirmationResult error: %v", err)
|
||||||
|
}
|
||||||
|
if repo.payments.executions["idem-4"] != nil {
|
||||||
|
t.Fatalf("expected no execution record for timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectedDoesNotPersistExecution(t *testing.T) {
|
||||||
|
logger := mloggerfactory.NewLogger(false)
|
||||||
|
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
|
||||||
|
prod := &captureProducer{}
|
||||||
|
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
|
||||||
|
intent := &model.PaymentGatewayIntent{
|
||||||
|
PaymentIntentID: "pi-reject",
|
||||||
|
IdempotencyKey: "idem-reject",
|
||||||
|
QuoteRef: "quote-reject",
|
||||||
|
OutgoingLeg: "card",
|
||||||
|
RequestedMoney: &paymenttypes.Money{Amount: "3", Currency: "USD"},
|
||||||
|
}
|
||||||
|
svc.trackIntent("idem-reject", intent)
|
||||||
|
|
||||||
|
result := &model.ConfirmationResult{
|
||||||
|
RequestID: "idem-reject",
|
||||||
|
Status: model.ConfirmationStatusRejected,
|
||||||
|
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "4", Text: "no"},
|
||||||
|
}
|
||||||
|
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
|
||||||
|
t.Fatalf("onConfirmationResult error: %v", err)
|
||||||
|
}
|
||||||
|
if repo.payments.executions["idem-reject"] != nil {
|
||||||
|
t.Fatalf("expected no execution record for rejection")
|
||||||
|
}
|
||||||
|
if repo.tg.records["idem-reject"] == nil {
|
||||||
|
t.Fatalf("expected raw reply to be stored for rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
17
api/gateway/tgsettle/main.go
Normal file
17
api/gateway/tgsettle/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/gateway/tgsettle/internal/server"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
smain "github.com/tech/sendico/pkg/server/main"
|
||||||
|
)
|
||||||
|
|
||||||
|
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return si.Create(logger, file, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
smain.RunServer("gateway", appversion.Create(), factory)
|
||||||
|
}
|
||||||
28
api/gateway/tgsettle/storage/model/execution.go
Normal file
28
api/gateway/tgsettle/storage/model/execution.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PaymentExecution struct {
|
||||||
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||||
|
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
|
||||||
|
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
|
||||||
|
ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"`
|
||||||
|
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
|
||||||
|
ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"`
|
||||||
|
Status string `bson:"status,omitempty" json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramConfirmation struct {
|
||||||
|
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||||
|
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
||||||
|
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
|
||||||
|
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
|
||||||
|
RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"`
|
||||||
|
ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"`
|
||||||
|
}
|
||||||
68
api/gateway/tgsettle/storage/mongo/repository.go
Normal file
68
api/gateway/tgsettle/storage/mongo/repository.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage/mongo/store"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
conn *db.MongoConnection
|
||||||
|
db *mongo.Database
|
||||||
|
|
||||||
|
payments storage.PaymentsStore
|
||||||
|
tg storage.TelegramConfirmationsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
|
||||||
|
if conn == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo connection is nil")
|
||||||
|
}
|
||||||
|
client := conn.Client()
|
||||||
|
if client == nil {
|
||||||
|
return nil, merrors.Internal("mongo client is not initialised")
|
||||||
|
}
|
||||||
|
result := &Repository{
|
||||||
|
logger: logger.Named("storage").Named("mongo"),
|
||||||
|
conn: conn,
|
||||||
|
db: conn.Database(),
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := result.conn.Ping(ctx); err != nil {
|
||||||
|
result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paymentsStore, err := store.NewPayments(result.logger, result.db)
|
||||||
|
if err != nil {
|
||||||
|
result.logger.Error("Failed to initialise payments store", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tgStore, err := store.NewTelegramConfirmations(result.logger, result.db)
|
||||||
|
if err != nil {
|
||||||
|
result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.payments = paymentsStore
|
||||||
|
result.tg = tgStore
|
||||||
|
result.logger.Info("Payment gateway MongoDB storage initialised")
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Payments() storage.PaymentsStore {
|
||||||
|
return r.payments
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore {
|
||||||
|
return r.tg
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Repository = (*Repository)(nil)
|
||||||
82
api/gateway/tgsettle/storage/mongo/store/payments.go
Normal file
82
api/gateway/tgsettle/storage/mongo/store/payments.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
paymentsCollection = "payments"
|
||||||
|
fieldIdempotencyKey = "idempotencyKey"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Payments struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
coll *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPayments(logger mlogger.Logger, db *mongo.Database) (*Payments, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo database is nil")
|
||||||
|
}
|
||||||
|
p := &Payments{
|
||||||
|
logger: logger.Named("payments"),
|
||||||
|
coll: db.Collection(paymentsCollection),
|
||||||
|
}
|
||||||
|
_, err := p.coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{
|
||||||
|
Keys: bson.D{{Key: fieldIdempotencyKey, Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Error("Failed to create payments idempotency index", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentExecution, error) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return nil, merrors.InvalidArgument("idempotency key is required", "idempotency_key")
|
||||||
|
}
|
||||||
|
var result model.PaymentExecution
|
||||||
|
err := p.coll.FindOne(ctx, bson.M{fieldIdempotencyKey: key}).Decode(&result)
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Payments) InsertExecution(ctx context.Context, exec *model.PaymentExecution) error {
|
||||||
|
if exec == nil {
|
||||||
|
return merrors.InvalidArgument("payment execution is nil", "execution")
|
||||||
|
}
|
||||||
|
exec.IdempotencyKey = strings.TrimSpace(exec.IdempotencyKey)
|
||||||
|
exec.PaymentIntentID = strings.TrimSpace(exec.PaymentIntentID)
|
||||||
|
exec.QuoteRef = strings.TrimSpace(exec.QuoteRef)
|
||||||
|
if exec.ExecutedAt.IsZero() {
|
||||||
|
exec.ExecutedAt = time.Now()
|
||||||
|
}
|
||||||
|
if _, err := p.coll.InsertOne(ctx, exec); err != nil {
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return storage.ErrDuplicate
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.PaymentsStore = (*Payments)(nil)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
telegramCollection = "telegram_confirmations"
|
||||||
|
fieldRequestID = "requestId"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TelegramConfirmations struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
coll *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTelegramConfirmations(logger mlogger.Logger, db *mongo.Database) (*TelegramConfirmations, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo database is nil")
|
||||||
|
}
|
||||||
|
t := &TelegramConfirmations{
|
||||||
|
logger: logger.Named("telegram_confirmations"),
|
||||||
|
coll: db.Collection(telegramCollection),
|
||||||
|
}
|
||||||
|
_, err := t.coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{
|
||||||
|
Keys: bson.D{{Key: fieldRequestID, Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.logger.Error("Failed to create telegram confirmations request_id index", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TelegramConfirmations) Upsert(ctx context.Context, record *model.TelegramConfirmation) error {
|
||||||
|
if record == nil {
|
||||||
|
return merrors.InvalidArgument("telegram confirmation is nil", "record")
|
||||||
|
}
|
||||||
|
record.RequestID = strings.TrimSpace(record.RequestID)
|
||||||
|
record.PaymentIntentID = strings.TrimSpace(record.PaymentIntentID)
|
||||||
|
record.QuoteRef = strings.TrimSpace(record.QuoteRef)
|
||||||
|
if record.RequestID == "" {
|
||||||
|
return merrors.InvalidArgument("request_id is required", "request_id")
|
||||||
|
}
|
||||||
|
if record.ReceivedAt.IsZero() {
|
||||||
|
record.ReceivedAt = time.Now()
|
||||||
|
}
|
||||||
|
update := bson.M{
|
||||||
|
"$set": record,
|
||||||
|
}
|
||||||
|
_, err := t.coll.UpdateOne(ctx, bson.M{fieldRequestID: record.RequestID}, update, options.Update().SetUpsert(true))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.TelegramConfirmationsStore = (*TelegramConfirmations)(nil)
|
||||||
24
api/gateway/tgsettle/storage/storage.go
Normal file
24
api/gateway/tgsettle/storage/storage.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrDuplicate = merrors.DataConflict("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
|
||||||
|
}
|
||||||
@@ -8,14 +8,27 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/payments/rail"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ledgerConnectorID = "ledger"
|
||||||
|
|
||||||
// Client exposes typed helpers around the ledger gRPC API.
|
// Client exposes typed helpers around the ledger gRPC API.
|
||||||
type Client interface {
|
type Client interface {
|
||||||
|
ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error)
|
||||||
|
CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error)
|
||||||
|
HoldBalance(ctx context.Context, accountID string, amount string) error
|
||||||
|
|
||||||
CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
|
CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
|
||||||
ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
|
ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
|
||||||
PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
|
PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
|
||||||
@@ -30,22 +43,20 @@ type Client interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
type grpcLedgerClient interface {
|
type grpcConnectorClient interface {
|
||||||
CreateAccount(ctx context.Context, in *ledgerv1.CreateAccountRequest, opts ...grpc.CallOption) (*ledgerv1.CreateAccountResponse, error)
|
OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error)
|
||||||
ListAccounts(ctx context.Context, in *ledgerv1.ListAccountsRequest, opts ...grpc.CallOption) (*ledgerv1.ListAccountsResponse, error)
|
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
|
||||||
PostCreditWithCharges(ctx context.Context, in *ledgerv1.PostCreditRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error)
|
ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error)
|
||||||
PostDebitWithCharges(ctx context.Context, in *ledgerv1.PostDebitRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error)
|
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
|
||||||
TransferInternal(ctx context.Context, in *ledgerv1.TransferRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error)
|
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
|
||||||
ApplyFXWithCharges(ctx context.Context, in *ledgerv1.FXRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error)
|
GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
|
||||||
GetBalance(ctx context.Context, in *ledgerv1.GetBalanceRequest, opts ...grpc.CallOption) (*ledgerv1.BalanceResponse, error)
|
ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error)
|
||||||
GetJournalEntry(ctx context.Context, in *ledgerv1.GetEntryRequest, opts ...grpc.CallOption) (*ledgerv1.JournalEntryResponse, error)
|
|
||||||
GetStatement(ctx context.Context, in *ledgerv1.GetStatementRequest, opts ...grpc.CallOption) (*ledgerv1.StatementResponse, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ledgerClient struct {
|
type ledgerClient struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
client grpcLedgerClient
|
client grpcConnectorClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// New dials the ledger endpoint and returns a ready client.
|
// New dials the ledger endpoint and returns a ready client.
|
||||||
@@ -75,12 +86,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
|||||||
return &ledgerClient{
|
return &ledgerClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
client: ledgerv1.NewLedgerServiceClient(conn),
|
client: connectorv1.NewConnectorServiceClient(conn),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithClient injects a pre-built ledger client (useful for tests).
|
// NewWithClient injects a pre-built ledger client (useful for tests).
|
||||||
func NewWithClient(cfg Config, lc grpcLedgerClient) Client {
|
func NewWithClient(cfg Config, lc grpcConnectorClient) Client {
|
||||||
cfg.setDefaults()
|
cfg.setDefaults()
|
||||||
return &ledgerClient{
|
return &ledgerClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -95,58 +106,561 @@ func (c *ledgerClient) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ledgerClient) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) {
|
||||||
|
if strings.TrimSpace(accountID) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: account_id is required")
|
||||||
|
}
|
||||||
|
resp, err := c.GetBalance(ctx, &ledgerv1.GetBalanceRequest{LedgerAccountRef: strings.TrimSpace(accountID)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp == nil || resp.GetBalance() == nil {
|
||||||
|
return nil, merrors.Internal("ledger: balance response missing")
|
||||||
|
}
|
||||||
|
return cloneMoney(resp.GetBalance()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) {
|
||||||
|
orgRef := strings.TrimSpace(tx.OrganizationRef)
|
||||||
|
if orgRef == "" {
|
||||||
|
return "", merrors.InvalidArgument("ledger: organization_ref is required")
|
||||||
|
}
|
||||||
|
accountRef := strings.TrimSpace(tx.LedgerAccountRef)
|
||||||
|
if accountRef == "" {
|
||||||
|
return "", merrors.InvalidArgument("ledger: ledger_account_ref is required")
|
||||||
|
}
|
||||||
|
money := &moneyv1.Money{
|
||||||
|
Currency: strings.TrimSpace(tx.Currency),
|
||||||
|
Amount: strings.TrimSpace(tx.Amount),
|
||||||
|
}
|
||||||
|
if money.GetCurrency() == "" || money.GetAmount() == "" {
|
||||||
|
return "", merrors.InvalidArgument("ledger: amount is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
description := strings.TrimSpace(tx.Description)
|
||||||
|
metadata := ledgerTxMetadata(tx.Metadata, tx)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case isLedgerRail(tx.FromRail) && !isLedgerRail(tx.ToRail):
|
||||||
|
resp, err := c.PostDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
LedgerAccountRef: accountRef,
|
||||||
|
Money: money,
|
||||||
|
Description: description,
|
||||||
|
Charges: tx.Charges,
|
||||||
|
Metadata: metadata,
|
||||||
|
ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(resp.GetJournalEntryRef()), nil
|
||||||
|
case isLedgerRail(tx.ToRail) && !isLedgerRail(tx.FromRail):
|
||||||
|
resp, err := c.PostCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
LedgerAccountRef: accountRef,
|
||||||
|
Money: money,
|
||||||
|
Description: description,
|
||||||
|
Charges: tx.Charges,
|
||||||
|
Metadata: metadata,
|
||||||
|
ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(resp.GetJournalEntryRef()), nil
|
||||||
|
default:
|
||||||
|
return "", merrors.InvalidArgument("ledger: unsupported transaction direction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ledgerClient) HoldBalance(ctx context.Context, accountID string, amount string) error {
|
||||||
|
return merrors.NotImplemented("ledger: hold balance not supported")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
|
func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.CreateAccount(ctx, req)
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: request is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCurrency()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: currency is required")
|
||||||
|
}
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
|
||||||
|
"account_code": strings.TrimSpace(req.GetAccountCode()),
|
||||||
|
"account_type": req.GetAccountType().String(),
|
||||||
|
"status": req.GetStatus().String(),
|
||||||
|
"allow_negative": req.GetAllowNegative(),
|
||||||
|
"is_settlement": req.GetIsSettlement(),
|
||||||
|
}
|
||||||
|
label := ""
|
||||||
|
if desc := req.GetDescribable(); desc != nil {
|
||||||
|
label = strings.TrimSpace(desc.GetName())
|
||||||
|
if desc.Description != nil {
|
||||||
|
trimmed := strings.TrimSpace(desc.GetDescription())
|
||||||
|
if trimmed != "" {
|
||||||
|
params["description"] = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(req.GetMetadata()) > 0 {
|
||||||
|
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||||
|
}
|
||||||
|
resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{
|
||||||
|
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
|
||||||
|
Asset: strings.TrimSpace(req.GetCurrency()),
|
||||||
|
Label: label,
|
||||||
|
Params: structFromMap(params),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetError())
|
||||||
|
}
|
||||||
|
return &ledgerv1.CreateAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) {
|
func (c *ledgerClient) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.ListAccounts(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetOrganizationRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: organization_ref is required")
|
||||||
|
}
|
||||||
|
resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{OwnerRef: strings.TrimSpace(req.GetOrganizationRef())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts := make([]*ledgerv1.LedgerAccount, 0, len(resp.GetAccounts()))
|
||||||
|
for _, account := range resp.GetAccounts() {
|
||||||
|
accounts = append(accounts, ledgerAccountFromConnector(account))
|
||||||
|
}
|
||||||
|
return &ledgerv1.ListAccountsResponse{Accounts: accounts}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
|
func (c *ledgerClient) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
return c.submitLedgerOperation(ctx, connectorv1.OperationType_CREDIT, "", req.GetLedgerAccountRef(), req.GetMoney(), req)
|
||||||
defer cancel()
|
|
||||||
return c.client.PostCreditWithCharges(ctx, req)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
|
func (c *ledgerClient) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
return c.submitLedgerOperation(ctx, connectorv1.OperationType_DEBIT, req.GetLedgerAccountRef(), "", req.GetMoney(), req)
|
||||||
defer cancel()
|
|
||||||
return c.client.PostDebitWithCharges(ctx, req)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
|
func (c *ledgerClient) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
return c.submitLedgerOperation(ctx, connectorv1.OperationType_TRANSFER, req.GetFromLedgerAccountRef(), req.GetToLedgerAccountRef(), req.GetMoney(), req)
|
||||||
defer cancel()
|
|
||||||
return c.client.TransferInternal(ctx, req)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
|
func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.ApplyFXWithCharges(ctx, req)
|
if req == nil {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: request is required")
|
||||||
|
}
|
||||||
|
if req.GetFromMoney() == nil || req.GetToMoney() == nil {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: from_money and to_money are required")
|
||||||
|
}
|
||||||
|
params := ledgerOperationParams(req.GetOrganizationRef(), req.GetDescription(), req.GetMetadata(), req.GetCharges(), req.GetEventTime())
|
||||||
|
params["rate"] = strings.TrimSpace(req.GetRate())
|
||||||
|
params["to_money"] = map[string]interface{}{"amount": req.GetToMoney().GetAmount(), "currency": req.GetToMoney().GetCurrency()}
|
||||||
|
operation := &connectorv1.Operation{
|
||||||
|
Type: connectorv1.OperationType_FX,
|
||||||
|
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||||
|
From: accountParty(req.GetFromLedgerAccountRef()),
|
||||||
|
To: accountParty(req.GetToLedgerAccountRef()),
|
||||||
|
Money: req.GetFromMoney(),
|
||||||
|
Params: structFromMap(params),
|
||||||
|
}
|
||||||
|
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetReceipt().GetError())
|
||||||
|
}
|
||||||
|
return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: ledgerv1.EntryType_ENTRY_FX}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
|
func (c *ledgerClient) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.GetBalance(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
|
||||||
|
}
|
||||||
|
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
balance := resp.GetBalance()
|
||||||
|
if balance == nil {
|
||||||
|
return nil, merrors.Internal("ledger: balance response missing")
|
||||||
|
}
|
||||||
|
return &ledgerv1.BalanceResponse{
|
||||||
|
LedgerAccountRef: strings.TrimSpace(req.GetLedgerAccountRef()),
|
||||||
|
Balance: balance.GetAvailable(),
|
||||||
|
LastUpdated: balance.GetCalculatedAt(),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) {
|
func (c *ledgerClient) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.GetJournalEntry(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetEntryRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: entry_ref is required")
|
||||||
|
}
|
||||||
|
resp, err := c.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetEntryRef())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return journalEntryFromOperation(resp.GetOperation()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) {
|
func (c *ledgerClient) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.GetStatement(ctx, req)
|
if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
|
||||||
|
}
|
||||||
|
resp, err := c.client.ListOperations(ctx, &connectorv1.ListOperationsRequest{
|
||||||
|
AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
|
||||||
|
Page: pageFromStatement(req),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := make([]*ledgerv1.JournalEntryResponse, 0, len(resp.GetOperations()))
|
||||||
|
for _, op := range resp.GetOperations() {
|
||||||
|
entries = append(entries, journalEntryFromOperation(op))
|
||||||
|
}
|
||||||
|
nextCursor := ""
|
||||||
|
if resp.GetPage() != nil {
|
||||||
|
nextCursor = resp.GetPage().GetNextCursor()
|
||||||
|
}
|
||||||
|
return &ledgerv1.StatementResponse{Entries: entries, NextCursor: nextCursor}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connectorv1.OperationType, fromRef, toRef string, money *moneyv1.Money, req interface{}) (*ledgerv1.PostResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
if money == nil {
|
||||||
|
return nil, merrors.InvalidArgument("ledger: money is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
idempotencyKey string
|
||||||
|
orgRef string
|
||||||
|
description string
|
||||||
|
metadata map[string]string
|
||||||
|
charges []*ledgerv1.PostingLine
|
||||||
|
eventTime *timestamppb.Timestamp
|
||||||
|
contraRef string
|
||||||
|
)
|
||||||
|
|
||||||
|
switch r := req.(type) {
|
||||||
|
case *ledgerv1.PostCreditRequest:
|
||||||
|
idempotencyKey = r.GetIdempotencyKey()
|
||||||
|
orgRef = r.GetOrganizationRef()
|
||||||
|
description = r.GetDescription()
|
||||||
|
metadata = r.GetMetadata()
|
||||||
|
charges = r.GetCharges()
|
||||||
|
eventTime = r.GetEventTime()
|
||||||
|
contraRef = r.GetContraLedgerAccountRef()
|
||||||
|
case *ledgerv1.PostDebitRequest:
|
||||||
|
idempotencyKey = r.GetIdempotencyKey()
|
||||||
|
orgRef = r.GetOrganizationRef()
|
||||||
|
description = r.GetDescription()
|
||||||
|
metadata = r.GetMetadata()
|
||||||
|
charges = r.GetCharges()
|
||||||
|
eventTime = r.GetEventTime()
|
||||||
|
contraRef = r.GetContraLedgerAccountRef()
|
||||||
|
case *ledgerv1.TransferRequest:
|
||||||
|
idempotencyKey = r.GetIdempotencyKey()
|
||||||
|
orgRef = r.GetOrganizationRef()
|
||||||
|
description = r.GetDescription()
|
||||||
|
metadata = r.GetMetadata()
|
||||||
|
charges = r.GetCharges()
|
||||||
|
eventTime = r.GetEventTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime)
|
||||||
|
if contraRef != "" {
|
||||||
|
params["contra_ledger_account_ref"] = strings.TrimSpace(contraRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
op := &connectorv1.Operation{
|
||||||
|
Type: opType,
|
||||||
|
IdempotencyKey: strings.TrimSpace(idempotencyKey),
|
||||||
|
Money: money,
|
||||||
|
Params: structFromMap(params),
|
||||||
|
}
|
||||||
|
if fromRef != "" {
|
||||||
|
op.From = accountParty(fromRef)
|
||||||
|
}
|
||||||
|
if toRef != "" {
|
||||||
|
op.To = accountParty(toRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: op})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||||
|
return nil, connectorError(resp.GetReceipt().GetError())
|
||||||
|
}
|
||||||
|
return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: entryTypeFromOperation(opType)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerOperationParams(orgRef, description string, metadata map[string]string, charges []*ledgerv1.PostingLine, eventTime *timestamppb.Timestamp) map[string]interface{} {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"organization_ref": strings.TrimSpace(orgRef),
|
||||||
|
"description": strings.TrimSpace(description),
|
||||||
|
}
|
||||||
|
if len(metadata) > 0 {
|
||||||
|
params["metadata"] = mapStringToInterface(metadata)
|
||||||
|
}
|
||||||
|
if len(charges) > 0 {
|
||||||
|
params["charges"] = chargesToInterface(charges)
|
||||||
|
}
|
||||||
|
if eventTime != nil {
|
||||||
|
params["event_time"] = eventTime.AsTime().UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountParty(accountRef string) *connectorv1.OperationParty {
|
||||||
|
if strings.TrimSpace(accountRef) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(accountRef)}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryTypeFromOperation(opType connectorv1.OperationType) ledgerv1.EntryType {
|
||||||
|
switch opType {
|
||||||
|
case connectorv1.OperationType_CREDIT:
|
||||||
|
return ledgerv1.EntryType_ENTRY_CREDIT
|
||||||
|
case connectorv1.OperationType_DEBIT:
|
||||||
|
return ledgerv1.EntryType_ENTRY_DEBIT
|
||||||
|
case connectorv1.OperationType_TRANSFER:
|
||||||
|
return ledgerv1.EntryType_ENTRY_TRANSFER
|
||||||
|
case connectorv1.OperationType_FX:
|
||||||
|
return ledgerv1.EntryType_ENTRY_FX
|
||||||
|
default:
|
||||||
|
return ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAccount {
|
||||||
|
if account == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
details := map[string]interface{}{}
|
||||||
|
if account.GetProviderDetails() != nil {
|
||||||
|
details = account.GetProviderDetails().AsMap()
|
||||||
|
}
|
||||||
|
accountType := ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
|
||||||
|
if v := strings.TrimSpace(fmt.Sprint(details["account_type"])); v != "" {
|
||||||
|
accountType = parseAccountType(v)
|
||||||
|
}
|
||||||
|
status := ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
|
||||||
|
if v := strings.TrimSpace(fmt.Sprint(details["status"])); v != "" {
|
||||||
|
status = parseAccountStatus(v)
|
||||||
|
}
|
||||||
|
allowNegative := false
|
||||||
|
if v, ok := details["allow_negative"].(bool); ok {
|
||||||
|
allowNegative = v
|
||||||
|
}
|
||||||
|
isSettlement := false
|
||||||
|
if v, ok := details["is_settlement"].(bool); ok {
|
||||||
|
isSettlement = v
|
||||||
|
}
|
||||||
|
accountCode := strings.TrimSpace(fmt.Sprint(details["account_code"]))
|
||||||
|
accountID := ""
|
||||||
|
if ref := account.GetRef(); ref != nil {
|
||||||
|
accountID = strings.TrimSpace(ref.GetAccountId())
|
||||||
|
}
|
||||||
|
describable := account.GetDescribable()
|
||||||
|
label := strings.TrimSpace(account.GetLabel())
|
||||||
|
if describable == nil && label != "" {
|
||||||
|
describable = &describablev1.Describable{Name: label}
|
||||||
|
} else if describable != nil && strings.TrimSpace(describable.GetName()) == "" && label != "" {
|
||||||
|
desc := strings.TrimSpace(describable.GetDescription())
|
||||||
|
if desc == "" {
|
||||||
|
describable = &describablev1.Describable{Name: label}
|
||||||
|
} else {
|
||||||
|
describable = &describablev1.Describable{Name: label, Description: &desc}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ledgerv1.LedgerAccount{
|
||||||
|
LedgerAccountRef: accountID,
|
||||||
|
OrganizationRef: strings.TrimSpace(account.GetOwnerRef()),
|
||||||
|
AccountCode: accountCode,
|
||||||
|
AccountType: accountType,
|
||||||
|
Currency: strings.TrimSpace(account.GetAsset()),
|
||||||
|
Status: status,
|
||||||
|
AllowNegative: allowNegative,
|
||||||
|
IsSettlement: isSettlement,
|
||||||
|
CreatedAt: account.GetCreatedAt(),
|
||||||
|
UpdatedAt: account.GetUpdatedAt(),
|
||||||
|
Describable: describable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccountType(value string) ledgerv1.AccountType {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||||
|
case "ACCOUNT_TYPE_ASSET", "ASSET":
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET
|
||||||
|
case "ACCOUNT_TYPE_LIABILITY", "LIABILITY":
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY
|
||||||
|
case "ACCOUNT_TYPE_REVENUE", "REVENUE":
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE
|
||||||
|
case "ACCOUNT_TYPE_EXPENSE", "EXPENSE":
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE
|
||||||
|
default:
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccountStatus(value string) ledgerv1.AccountStatus {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||||
|
case "ACCOUNT_STATUS_ACTIVE", "ACTIVE":
|
||||||
|
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
|
||||||
|
case "ACCOUNT_STATUS_FROZEN", "FROZEN":
|
||||||
|
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
|
||||||
|
default:
|
||||||
|
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalEntryFromOperation(op *connectorv1.Operation) *ledgerv1.JournalEntryResponse {
|
||||||
|
if op == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entry := &ledgerv1.JournalEntryResponse{
|
||||||
|
EntryRef: strings.TrimSpace(op.GetOperationId()),
|
||||||
|
EntryType: entryTypeFromOperation(op.GetType()),
|
||||||
|
Description: operationDescription(op),
|
||||||
|
EventTime: op.GetCreatedAt(),
|
||||||
|
Lines: postingLinesFromOperation(op),
|
||||||
|
LedgerAccountRefs: ledgerAccountRefsFromOperation(op),
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationDescription(op *connectorv1.Operation) string {
|
||||||
|
if op == nil || op.GetParams() == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value, ok := op.GetParams().AsMap()["description"]; ok {
|
||||||
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func postingLinesFromOperation(op *connectorv1.Operation) []*ledgerv1.PostingLine {
|
||||||
|
if op == nil || op.GetMoney() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lines := []*ledgerv1.PostingLine{}
|
||||||
|
if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
|
||||||
|
lines = append(lines, &ledgerv1.PostingLine{LedgerAccountRef: strings.TrimSpace(from.GetAccount().GetAccountId()), Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
|
||||||
|
}
|
||||||
|
if to := op.GetTo(); to != nil && to.GetAccount() != nil {
|
||||||
|
lines = append(lines, &ledgerv1.PostingLine{LedgerAccountRef: strings.TrimSpace(to.GetAccount().GetAccountId()), Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
lines = append(lines, &ledgerv1.PostingLine{Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerAccountRefsFromOperation(op *connectorv1.Operation) []string {
|
||||||
|
refs := []string{}
|
||||||
|
if op == nil {
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
|
||||||
|
refs = append(refs, strings.TrimSpace(from.GetAccount().GetAccountId()))
|
||||||
|
}
|
||||||
|
if to := op.GetTo(); to != nil && to.GetAccount() != nil {
|
||||||
|
refs = append(refs, strings.TrimSpace(to.GetAccount().GetAccountId()))
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageFromStatement(req *ledgerv1.GetStatementRequest) *paginationv1.CursorPageRequest {
|
||||||
|
if req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &paginationv1.CursorPageRequest{
|
||||||
|
Cursor: strings.TrimSpace(req.GetCursor()),
|
||||||
|
Limit: req.GetLimit(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chargesToInterface(charges []*ledgerv1.PostingLine) []interface{} {
|
||||||
|
if len(charges) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]interface{}, 0, len(charges))
|
||||||
|
for _, line := range charges {
|
||||||
|
if line == nil || line.GetMoney() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"ledger_account_ref": strings.TrimSpace(line.GetLedgerAccountRef()),
|
||||||
|
"amount": strings.TrimSpace(line.GetMoney().GetAmount()),
|
||||||
|
"currency": strings.TrimSpace(line.GetMoney().GetCurrency()),
|
||||||
|
"line_type": line.GetLineType().String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectorError(err *connectorv1.ConnectorError) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(err.GetMessage())
|
||||||
|
switch err.GetCode() {
|
||||||
|
case connectorv1.ErrorCode_INVALID_PARAMS:
|
||||||
|
return merrors.InvalidArgument(msg)
|
||||||
|
case connectorv1.ErrorCode_NOT_FOUND:
|
||||||
|
return merrors.NoData(msg)
|
||||||
|
case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
|
||||||
|
return merrors.NotImplemented(msg)
|
||||||
|
case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
|
||||||
|
return merrors.Internal(msg)
|
||||||
|
default:
|
||||||
|
return merrors.Internal(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func structFromMap(data map[string]interface{}) *structpb.Struct {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result, err := structpb.NewStruct(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStringToInterface(input map[string]string) map[string]interface{} {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{}, len(input))
|
||||||
|
for k, v := range input {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ledgerClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (c *ledgerClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
@@ -156,3 +670,57 @@ func (c *ledgerClient) callContext(ctx context.Context) (context.Context, contex
|
|||||||
}
|
}
|
||||||
return context.WithTimeout(ctx, timeout)
|
return context.WithTimeout(ctx, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isLedgerRail(value string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(value), "LEDGER")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMoney(input *moneyv1.Money) *moneyv1.Money {
|
||||||
|
if input == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Currency: input.GetCurrency(),
|
||||||
|
Amount: input.GetAmount(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMetadata(input map[string]string) map[string]string {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(input))
|
||||||
|
for k, v := range input {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerTxMetadata(base map[string]string, tx rail.LedgerTx) map[string]string {
|
||||||
|
meta := cloneMetadata(base)
|
||||||
|
if meta == nil {
|
||||||
|
meta = map[string]string{}
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(tx.PaymentPlanID); val != "" {
|
||||||
|
meta["payment_plan_id"] = val
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(tx.FromRail); val != "" {
|
||||||
|
meta["from_rail"] = val
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(tx.ToRail); val != "" {
|
||||||
|
meta["to_rail"] = val
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" {
|
||||||
|
meta["external_reference_id"] = val
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(tx.FXRateUsed); val != "" {
|
||||||
|
meta["fx_rate_used"] = val
|
||||||
|
}
|
||||||
|
if val := strings.TrimSpace(tx.FeeAmount); val != "" {
|
||||||
|
meta["fee_amount"] = val
|
||||||
|
}
|
||||||
|
if len(meta) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ package client
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/payments/rail"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fake implements Client for tests.
|
// Fake implements Client for tests.
|
||||||
type Fake struct {
|
type Fake struct {
|
||||||
CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
|
ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error)
|
||||||
ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
|
CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error)
|
||||||
|
HoldBalanceFn func(ctx context.Context, accountID string, amount string) error
|
||||||
|
CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
|
||||||
|
ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
|
||||||
PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
|
PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
|
||||||
PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
|
PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
|
||||||
TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
|
TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
|
||||||
@@ -20,6 +25,27 @@ type Fake struct {
|
|||||||
CloseFn func() error
|
CloseFn func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fake) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) {
|
||||||
|
if f.ReadBalanceFn != nil {
|
||||||
|
return f.ReadBalanceFn(ctx, accountID)
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) {
|
||||||
|
if f.CreateTransactionFn != nil {
|
||||||
|
return f.CreateTransactionFn(ctx, tx)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) HoldBalance(ctx context.Context, accountID string, amount string) error {
|
||||||
|
if f.HoldBalanceFn != nil {
|
||||||
|
return f.HoldBalanceFn(ctx, accountID, amount)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fake) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
|
func (f *Fake) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
|
||||||
if f.CreateAccountFn != nil {
|
if f.CreateAccountFn != nil {
|
||||||
return f.CreateAccountFn(ctx, req)
|
return f.CreateAccountFn(ctx, req)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ require (
|
|||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ package ledger
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/ledger/storage"
|
||||||
"github.com/tech/sendico/ledger/storage/model"
|
"github.com/tech/sendico/ledger/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
pmodel "github.com/tech/sendico/pkg/model"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,11 +62,19 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !req.GetIsSettlement() {
|
||||||
|
if _, err := s.ensureSettlementAccount(ctx, orgRef, currency); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
metadata := req.GetMetadata()
|
metadata := req.GetMetadata()
|
||||||
if len(metadata) == 0 {
|
if len(metadata) == 0 {
|
||||||
metadata = nil
|
metadata = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describable := describableFromProto(req.GetDescribable())
|
||||||
|
|
||||||
account := &model.Account{
|
account := &model.Account{
|
||||||
AccountCode: accountCode,
|
AccountCode: accountCode,
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
@@ -71,6 +84,9 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
|
|||||||
IsSettlement: req.GetIsSettlement(),
|
IsSettlement: req.GetIsSettlement(),
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
if describable != nil {
|
||||||
|
account.Describable = *describable
|
||||||
|
}
|
||||||
account.OrganizationRef = orgRef
|
account.OrganizationRef = orgRef
|
||||||
|
|
||||||
err = s.storage.Accounts().Create(ctx, account)
|
err = s.storage.Accounts().Create(ctx, account)
|
||||||
@@ -204,5 +220,115 @@ func toProtoAccount(account *model.Account) *ledgerv1.LedgerAccount {
|
|||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
UpdatedAt: updatedAt,
|
UpdatedAt: updatedAt,
|
||||||
|
Describable: describableToProto(account.Describable),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func describableFromProto(desc *describablev1.Describable) *pmodel.Describable {
|
||||||
|
if desc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(desc.GetName())
|
||||||
|
var description *string
|
||||||
|
if desc.Description != nil {
|
||||||
|
trimmed := strings.TrimSpace(desc.GetDescription())
|
||||||
|
if trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" && description == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pmodel.Describable{
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func describableToProto(desc pmodel.Describable) *describablev1.Describable {
|
||||||
|
name := strings.TrimSpace(desc.Name)
|
||||||
|
var description *string
|
||||||
|
if desc.Description != nil {
|
||||||
|
trimmed := strings.TrimSpace(*desc.Description)
|
||||||
|
if trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" && description == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &describablev1.Describable{
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureSettlementAccount(ctx context.Context, orgRef primitive.ObjectID, currency string) (*model.Account, error) {
|
||||||
|
if s.storage == nil || s.storage.Accounts() == nil {
|
||||||
|
return nil, errStorageNotInitialized
|
||||||
|
}
|
||||||
|
normalizedCurrency := strings.ToUpper(strings.TrimSpace(currency))
|
||||||
|
if normalizedCurrency == "" {
|
||||||
|
return nil, merrors.InvalidArgument("currency is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, normalizedCurrency)
|
||||||
|
if err == nil {
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, storage.ErrAccountNotFound) {
|
||||||
|
s.logger.Warn("failed to resolve default settlement account",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("organizationRef", orgRef.Hex()),
|
||||||
|
zap.String("currency", normalizedCurrency))
|
||||||
|
return nil, merrors.Internal("failed to resolve settlement account")
|
||||||
|
}
|
||||||
|
|
||||||
|
accountCode := defaultSettlementAccountCode(normalizedCurrency)
|
||||||
|
description := "Auto-created default settlement account"
|
||||||
|
account = &model.Account{
|
||||||
|
AccountCode: accountCode,
|
||||||
|
AccountType: model.AccountTypeAsset,
|
||||||
|
Currency: normalizedCurrency,
|
||||||
|
Status: model.AccountStatusActive,
|
||||||
|
AllowNegative: true,
|
||||||
|
IsSettlement: true,
|
||||||
|
}
|
||||||
|
account.OrganizationRef = orgRef
|
||||||
|
account.Name = fmt.Sprintf("Settlement %s", normalizedCurrency)
|
||||||
|
account.Description = &description
|
||||||
|
|
||||||
|
if err := s.storage.Accounts().Create(ctx, account); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
existing, lookupErr := s.storage.Accounts().GetDefaultSettlement(ctx, orgRef, normalizedCurrency)
|
||||||
|
if lookupErr == nil && existing != nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
s.logger.Warn("duplicate settlement account create but failed to load existing",
|
||||||
|
zap.Error(lookupErr),
|
||||||
|
zap.String("organizationRef", orgRef.Hex()),
|
||||||
|
zap.String("currency", normalizedCurrency))
|
||||||
|
return nil, merrors.Internal("failed to resolve settlement account after conflict")
|
||||||
|
}
|
||||||
|
s.logger.Warn("failed to create default settlement account",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("organizationRef", orgRef.Hex()),
|
||||||
|
zap.String("currency", normalizedCurrency),
|
||||||
|
zap.String("accountCode", accountCode))
|
||||||
|
return nil, merrors.Internal("failed to create settlement account")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("default settlement account created",
|
||||||
|
zap.String("organizationRef", orgRef.Hex()),
|
||||||
|
zap.String("currency", normalizedCurrency),
|
||||||
|
zap.String("accountCode", accountCode))
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSettlementAccountCode(currency string) string {
|
||||||
|
cleaned := strings.ToLower(strings.TrimSpace(currency))
|
||||||
|
if cleaned == "" {
|
||||||
|
return "asset:settlement"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("asset:settlement:%s", cleaned)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,14 +16,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type accountStoreStub struct {
|
type accountStoreStub struct {
|
||||||
createErr error
|
createErr error
|
||||||
created []*model.Account
|
createErrSettlement error
|
||||||
existing *model.Account
|
created []*model.Account
|
||||||
existingErr error
|
existing *model.Account
|
||||||
|
existingErr error
|
||||||
|
defaultSettlement *model.Account
|
||||||
|
defaultErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error {
|
func (s *accountStoreStub) Create(_ context.Context, account *model.Account) error {
|
||||||
if s.createErr != nil {
|
if account.IsSettlement {
|
||||||
|
if s.createErrSettlement != nil {
|
||||||
|
return s.createErrSettlement
|
||||||
|
}
|
||||||
|
} else if s.createErr != nil {
|
||||||
return s.createErr
|
return s.createErr
|
||||||
}
|
}
|
||||||
if account.GetID() == nil || account.GetID().IsZero() {
|
if account.GetID() == nil || account.GetID().IsZero() {
|
||||||
@@ -47,6 +54,12 @@ func (s *accountStoreStub) Get(context.Context, primitive.ObjectID) (*model.Acco
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
|
func (s *accountStoreStub) GetDefaultSettlement(context.Context, primitive.ObjectID, string) (*model.Account, error) {
|
||||||
|
if s.defaultErr != nil {
|
||||||
|
return nil, s.defaultErr
|
||||||
|
}
|
||||||
|
if s.defaultSettlement != nil {
|
||||||
|
return s.defaultSettlement, nil
|
||||||
|
}
|
||||||
return nil, storage.ErrAccountNotFound
|
return nil, storage.ErrAccountNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +117,47 @@ func TestCreateAccountResponder_Success(t *testing.T) {
|
|||||||
require.Len(t, accountStore.created, 1)
|
require.Len(t, accountStore.created, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
orgRef := primitive.NewObjectID()
|
||||||
|
|
||||||
|
accountStore := &accountStoreStub{}
|
||||||
|
svc := &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
storage: &repositoryStub{accounts: accountStore},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &ledgerv1.CreateAccountRequest{
|
||||||
|
OrganizationRef: orgRef.Hex(),
|
||||||
|
AccountCode: "liability:customer:1",
|
||||||
|
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY,
|
||||||
|
Currency: "usd",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
require.NotNil(t, resp.Account)
|
||||||
|
require.Len(t, accountStore.created, 2)
|
||||||
|
|
||||||
|
var settlement *model.Account
|
||||||
|
var created *model.Account
|
||||||
|
for _, acc := range accountStore.created {
|
||||||
|
if acc.IsSettlement {
|
||||||
|
settlement = acc
|
||||||
|
}
|
||||||
|
if acc.AccountCode == "liability:customer:1" {
|
||||||
|
created = acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, settlement)
|
||||||
|
require.NotNil(t, created)
|
||||||
|
require.Equal(t, defaultSettlementAccountCode("USD"), settlement.AccountCode)
|
||||||
|
require.Equal(t, model.AccountTypeAsset, settlement.AccountType)
|
||||||
|
require.Equal(t, "USD", settlement.Currency)
|
||||||
|
require.True(t, settlement.AllowNegative)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
|
func TestCreateAccountResponder_DuplicateReturnsExisting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
693
api/ledger/internal/service/ledger/connector.go
Normal file
693
api/ledger/internal/service/ledger/connector.go
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
package ledger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/ledger/internal/appversion"
|
||||||
|
"github.com/tech/sendico/pkg/connector/params"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ledgerConnectorID = "ledger"
|
||||||
|
|
||||||
|
type connectorAdapter struct {
|
||||||
|
connectorv1.UnimplementedConnectorServiceServer
|
||||||
|
svc *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConnectorAdapter(svc *Service) *connectorAdapter {
|
||||||
|
return &connectorAdapter{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorAdapter) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
|
||||||
|
return &connectorv1.GetCapabilitiesResponse{
|
||||||
|
Capabilities: &connectorv1.ConnectorCapabilities{
|
||||||
|
ConnectorType: ledgerConnectorID,
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
SupportedAccountKinds: []connectorv1.AccountKind{connectorv1.AccountKind_LEDGER_ACCOUNT},
|
||||||
|
SupportedOperationTypes: []connectorv1.OperationType{
|
||||||
|
connectorv1.OperationType_CREDIT,
|
||||||
|
connectorv1.OperationType_DEBIT,
|
||||||
|
connectorv1.OperationType_TRANSFER,
|
||||||
|
connectorv1.OperationType_FX,
|
||||||
|
},
|
||||||
|
OpenAccountParams: ledgerOpenAccountParams(),
|
||||||
|
OperationParams: ledgerOperationParams(),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: request is required", nil, "")}, nil
|
||||||
|
}
|
||||||
|
if req.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported account kind", nil, "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := params.New(req.GetParams())
|
||||||
|
orgRef := strings.TrimSpace(reader.String("organization_ref"))
|
||||||
|
accountCode := strings.TrimSpace(reader.String("account_code"))
|
||||||
|
if orgRef == "" || accountCode == "" {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref and account_code are required", nil, "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accountType, err := parseLedgerAccountType(reader, "account_type")
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), nil, "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := strings.TrimSpace(req.GetAsset())
|
||||||
|
if currency == "" {
|
||||||
|
currency = strings.TrimSpace(reader.String("currency"))
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: asset is required", nil, "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
status := parseLedgerAccountStatus(reader, "status")
|
||||||
|
metadata := mergeMetadata(reader.StringMap("metadata"), req.GetLabel(), req.GetOwnerRef(), req.GetCorrelationId(), req.GetParentIntentId())
|
||||||
|
describable := describableFromLabel(req.GetLabel(), reader.String("description"))
|
||||||
|
|
||||||
|
resp, err := c.svc.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
AccountCode: accountCode,
|
||||||
|
AccountType: accountType,
|
||||||
|
Currency: currency,
|
||||||
|
Status: status,
|
||||||
|
AllowNegative: reader.Bool("allow_negative"),
|
||||||
|
IsSettlement: reader.Bool("is_settlement"),
|
||||||
|
Metadata: metadata,
|
||||||
|
Describable: describable,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.OpenAccountResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, "")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &connectorv1.OpenAccountResponse{
|
||||||
|
Account: ledgerAccountToConnector(resp.GetAccount()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorAdapter) GetAccount(ctx context.Context, req *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
|
||||||
|
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_account: account_ref.account_id is required")
|
||||||
|
}
|
||||||
|
accountRef, err := parseObjectID(strings.TrimSpace(req.GetAccountRef().GetAccountId()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.svc.storage == nil || c.svc.storage.Accounts() == nil {
|
||||||
|
return nil, merrors.Internal("get_account: storage unavailable")
|
||||||
|
}
|
||||||
|
account, err := c.svc.storage.Accounts().Get(ctx, accountRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &connectorv1.GetAccountResponse{
|
||||||
|
Account: ledgerAccountToConnector(toProtoAccount(account)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorAdapter) ListAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||||
|
if req == nil || strings.TrimSpace(req.GetOwnerRef()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("list_accounts: owner_ref is required")
|
||||||
|
}
|
||||||
|
resp, err := c.svc.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{OrganizationRef: strings.TrimSpace(req.GetOwnerRef())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts := make([]*connectorv1.Account, 0, len(resp.GetAccounts()))
|
||||||
|
for _, account := range resp.GetAccounts() {
|
||||||
|
accounts = append(accounts, ledgerAccountToConnector(account))
|
||||||
|
}
|
||||||
|
return &connectorv1.ListAccountsResponse{Accounts: accounts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorAdapter) GetBalance(ctx context.Context, req *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
|
||||||
|
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_balance: account_ref.account_id is required")
|
||||||
|
}
|
||||||
|
resp, err := c.svc.GetBalance(ctx, &ledgerv1.GetBalanceRequest{
|
||||||
|
LedgerAccountRef: strings.TrimSpace(req.GetAccountRef().GetAccountId()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &connectorv1.GetBalanceResponse{
|
||||||
|
Balance: &connectorv1.Balance{
|
||||||
|
AccountRef: req.GetAccountRef(),
|
||||||
|
Available: resp.GetBalance(),
|
||||||
|
CalculatedAt: resp.GetLastUpdated(),
|
||||||
|
PendingInbound: nil,
|
||||||
|
PendingOutbound: nil,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
|
||||||
|
if req == nil || req.GetOperation() == nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
|
||||||
|
}
|
||||||
|
op := req.GetOperation()
|
||||||
|
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
reader := params.New(op.GetParams())
|
||||||
|
orgRef := strings.TrimSpace(reader.String("organization_ref"))
|
||||||
|
if orgRef == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: organization_ref is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := mergeMetadata(reader.StringMap("metadata"), "", "", op.GetCorrelationId(), op.GetParentIntentId())
|
||||||
|
description := strings.TrimSpace(reader.String("description"))
|
||||||
|
eventTime := parseEventTime(reader)
|
||||||
|
charges, err := parseLedgerCharges(reader)
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op.GetType() {
|
||||||
|
case connectorv1.OperationType_CREDIT:
|
||||||
|
accountID := operationAccountID(op.GetTo())
|
||||||
|
if accountID == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: to.account is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
resp, err := c.svc.PostCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
LedgerAccountRef: accountID,
|
||||||
|
Money: op.GetMoney(),
|
||||||
|
Description: description,
|
||||||
|
Charges: charges,
|
||||||
|
Metadata: metadata,
|
||||||
|
EventTime: eventTime,
|
||||||
|
ContraLedgerAccountRef: strings.TrimSpace(reader.String("contra_ledger_account_ref")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil
|
||||||
|
}
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil
|
||||||
|
case connectorv1.OperationType_DEBIT:
|
||||||
|
accountID := operationAccountID(op.GetFrom())
|
||||||
|
if accountID == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: from.account is required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
resp, err := c.svc.PostDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
LedgerAccountRef: accountID,
|
||||||
|
Money: op.GetMoney(),
|
||||||
|
Description: description,
|
||||||
|
Charges: charges,
|
||||||
|
Metadata: metadata,
|
||||||
|
EventTime: eventTime,
|
||||||
|
ContraLedgerAccountRef: strings.TrimSpace(reader.String("contra_ledger_account_ref")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil
|
||||||
|
}
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil
|
||||||
|
case connectorv1.OperationType_TRANSFER:
|
||||||
|
fromID := operationAccountID(op.GetFrom())
|
||||||
|
toID := operationAccountID(op.GetTo())
|
||||||
|
if fromID == "" || toID == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account and to.account are required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
resp, err := c.svc.TransferInternal(ctx, &ledgerv1.TransferRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
FromLedgerAccountRef: fromID,
|
||||||
|
ToLedgerAccountRef: toID,
|
||||||
|
Money: op.GetMoney(),
|
||||||
|
Description: description,
|
||||||
|
Charges: charges,
|
||||||
|
Metadata: metadata,
|
||||||
|
EventTime: eventTime,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil
|
||||||
|
case connectorv1.OperationType_FX:
|
||||||
|
fromID := operationAccountID(op.GetFrom())
|
||||||
|
toID := operationAccountID(op.GetTo())
|
||||||
|
if fromID == "" || toID == "" {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "fx: from.account and to.account are required", op, "")}}, nil
|
||||||
|
}
|
||||||
|
toMoney, err := parseMoneyFromMap(reader.Map("to_money"))
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
resp, err := c.svc.ApplyFXWithCharges(ctx, &ledgerv1.FXRequest{
|
||||||
|
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
FromLedgerAccountRef: fromID,
|
||||||
|
ToLedgerAccountRef: toID,
|
||||||
|
FromMoney: op.GetMoney(),
|
||||||
|
ToMoney: toMoney,
|
||||||
|
Rate: strings.TrimSpace(reader.String("rate")),
|
||||||
|
Description: description,
|
||||||
|
Charges: charges,
|
||||||
|
Metadata: metadata,
|
||||||
|
EventTime: eventTime,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||||
|
}
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil
|
||||||
|
default:
|
||||||
|
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorAdapter) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
|
||||||
|
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||||
|
}
|
||||||
|
entry, err := c.svc.GetJournalEntry(ctx, &ledgerv1.GetEntryRequest{EntryRef: strings.TrimSpace(req.GetOperationId())})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &connectorv1.GetOperationResponse{Operation: ledgerEntryToOperation(entry)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectorAdapter) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||||
|
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
|
||||||
|
return nil, merrors.InvalidArgument("list_operations: account_ref.account_id is required")
|
||||||
|
}
|
||||||
|
resp, err := c.svc.GetStatement(ctx, &ledgerv1.GetStatementRequest{
|
||||||
|
LedgerAccountRef: strings.TrimSpace(req.GetAccountRef().GetAccountId()),
|
||||||
|
Cursor: "",
|
||||||
|
Limit: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops := make([]*connectorv1.Operation, 0, len(resp.GetEntries()))
|
||||||
|
for _, entry := range resp.GetEntries() {
|
||||||
|
ops = append(ops, ledgerEntryToOperation(entry))
|
||||||
|
}
|
||||||
|
return &connectorv1.ListOperationsResponse{Operations: ops}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerOpenAccountParams() []*connectorv1.ParamSpec {
|
||||||
|
return []*connectorv1.ParamSpec{
|
||||||
|
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the ledger account."},
|
||||||
|
{Key: "account_code", Type: connectorv1.ParamType_STRING, Required: true, Description: "Ledger account code."},
|
||||||
|
{Key: "account_type", Type: connectorv1.ParamType_STRING, Required: true, Description: "ASSET | LIABILITY | REVENUE | EXPENSE."},
|
||||||
|
{Key: "status", Type: connectorv1.ParamType_STRING, Required: false, Description: "ACTIVE | FROZEN."},
|
||||||
|
{Key: "allow_negative", Type: connectorv1.ParamType_BOOL, Required: false, Description: "Allow negative balance."},
|
||||||
|
{Key: "is_settlement", Type: connectorv1.ParamType_BOOL, Required: false, Description: "Mark account as settlement."},
|
||||||
|
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Additional metadata map."},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerOperationParams() []*connectorv1.OperationParamSpec {
|
||||||
|
common := []*connectorv1.ParamSpec{
|
||||||
|
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference."},
|
||||||
|
{Key: "description", Type: connectorv1.ParamType_STRING, Required: false, Description: "Ledger entry description."},
|
||||||
|
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Entry metadata map."},
|
||||||
|
{Key: "charges", Type: connectorv1.ParamType_JSON, Required: false, Description: "Posting line charges."},
|
||||||
|
{Key: "event_time", Type: connectorv1.ParamType_STRING, Required: false, Description: "RFC3339 timestamp."},
|
||||||
|
}
|
||||||
|
return []*connectorv1.OperationParamSpec{
|
||||||
|
{OperationType: connectorv1.OperationType_CREDIT, Params: append(common, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})},
|
||||||
|
{OperationType: connectorv1.OperationType_DEBIT, Params: append(common, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})},
|
||||||
|
{OperationType: connectorv1.OperationType_TRANSFER, Params: common},
|
||||||
|
{OperationType: connectorv1.OperationType_FX, Params: append(common,
|
||||||
|
&connectorv1.ParamSpec{Key: "to_money", Type: connectorv1.ParamType_JSON, Required: true, Description: "Target amount {amount,currency}."},
|
||||||
|
&connectorv1.ParamSpec{Key: "rate", Type: connectorv1.ParamType_STRING, Required: false, Description: "FX rate snapshot."},
|
||||||
|
)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerAccountToConnector(account *ledgerv1.LedgerAccount) *connectorv1.Account {
|
||||||
|
if account == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
details, _ := structpb.NewStruct(map[string]interface{}{
|
||||||
|
"account_code": account.GetAccountCode(),
|
||||||
|
"account_type": account.GetAccountType().String(),
|
||||||
|
"status": account.GetStatus().String(),
|
||||||
|
"allow_negative": account.GetAllowNegative(),
|
||||||
|
"is_settlement": account.GetIsSettlement(),
|
||||||
|
})
|
||||||
|
describable := ledgerAccountDescribable(account)
|
||||||
|
return &connectorv1.Account{
|
||||||
|
Ref: &connectorv1.AccountRef{
|
||||||
|
ConnectorId: ledgerConnectorID,
|
||||||
|
AccountId: strings.TrimSpace(account.GetLedgerAccountRef()),
|
||||||
|
},
|
||||||
|
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
|
||||||
|
Asset: strings.TrimSpace(account.GetCurrency()),
|
||||||
|
State: ledgerAccountState(account.GetStatus()),
|
||||||
|
Label: strings.TrimSpace(account.GetAccountCode()),
|
||||||
|
OwnerRef: strings.TrimSpace(account.GetOrganizationRef()),
|
||||||
|
ProviderDetails: details,
|
||||||
|
CreatedAt: account.GetCreatedAt(),
|
||||||
|
UpdatedAt: account.GetUpdatedAt(),
|
||||||
|
Describable: describable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerAccountState(status ledgerv1.AccountStatus) connectorv1.AccountState {
|
||||||
|
switch status {
|
||||||
|
case ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE:
|
||||||
|
return connectorv1.AccountState_ACCOUNT_ACTIVE
|
||||||
|
case ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN:
|
||||||
|
return connectorv1.AccountState_ACCOUNT_SUSPENDED
|
||||||
|
default:
|
||||||
|
return connectorv1.AccountState_ACCOUNT_STATE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerAccountDescribable(account *ledgerv1.LedgerAccount) *describablev1.Describable {
|
||||||
|
if account == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if desc := cleanedDescribable(account.GetDescribable()); desc != nil {
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
metadata := account.GetMetadata()
|
||||||
|
name := ""
|
||||||
|
if metadata != nil {
|
||||||
|
if v := strings.TrimSpace(metadata["name"]); v != "" {
|
||||||
|
name = v
|
||||||
|
} else if v := strings.TrimSpace(metadata["label"]); v != "" {
|
||||||
|
name = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(account.GetAccountCode())
|
||||||
|
}
|
||||||
|
desc := ""
|
||||||
|
if metadata != nil {
|
||||||
|
desc = strings.TrimSpace(metadata["description"])
|
||||||
|
}
|
||||||
|
if name == "" && desc == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
return &describablev1.Describable{Name: name}
|
||||||
|
}
|
||||||
|
return &describablev1.Describable{Name: name, Description: &desc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func describableFromLabel(label, description string) *describablev1.Describable {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
if label == "" && description == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if description == "" {
|
||||||
|
return &describablev1.Describable{Name: label}
|
||||||
|
}
|
||||||
|
return &describablev1.Describable{Name: label, Description: &description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanedDescribable(desc *describablev1.Describable) *describablev1.Describable {
|
||||||
|
if desc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(desc.GetName())
|
||||||
|
var description *string
|
||||||
|
if desc.Description != nil {
|
||||||
|
trimmed := strings.TrimSpace(desc.GetDescription())
|
||||||
|
if trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" && description == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &describablev1.Describable{
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerReceipt(ref string, status connectorv1.OperationStatus) *connectorv1.OperationReceipt {
|
||||||
|
return &connectorv1.OperationReceipt{
|
||||||
|
OperationId: strings.TrimSpace(ref),
|
||||||
|
Status: status,
|
||||||
|
ProviderRef: strings.TrimSpace(ref),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerEntryToOperation(entry *ledgerv1.JournalEntryResponse) *connectorv1.Operation {
|
||||||
|
if entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
op := &connectorv1.Operation{
|
||||||
|
OperationId: strings.TrimSpace(entry.GetEntryRef()),
|
||||||
|
Type: ledgerEntryType(entry.GetEntryType()),
|
||||||
|
Status: connectorv1.OperationStatus_CONFIRMED,
|
||||||
|
CreatedAt: entry.GetEventTime(),
|
||||||
|
UpdatedAt: entry.GetEventTime(),
|
||||||
|
}
|
||||||
|
mainLines := ledgerMainLines(entry.GetLines())
|
||||||
|
if len(mainLines) > 0 {
|
||||||
|
op.Money = mainLines[0].GetMoney()
|
||||||
|
}
|
||||||
|
switch op.Type {
|
||||||
|
case connectorv1.OperationType_CREDIT:
|
||||||
|
if len(mainLines) > 0 {
|
||||||
|
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: mainLines[0].GetLedgerAccountRef()}}}
|
||||||
|
}
|
||||||
|
case connectorv1.OperationType_DEBIT:
|
||||||
|
if len(mainLines) > 0 {
|
||||||
|
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: mainLines[0].GetLedgerAccountRef()}}}
|
||||||
|
}
|
||||||
|
case connectorv1.OperationType_TRANSFER, connectorv1.OperationType_FX:
|
||||||
|
if len(mainLines) > 0 {
|
||||||
|
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: mainLines[0].GetLedgerAccountRef()}}}
|
||||||
|
}
|
||||||
|
if len(mainLines) > 1 {
|
||||||
|
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: mainLines[1].GetLedgerAccountRef()}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerMainLines(lines []*ledgerv1.PostingLine) []*ledgerv1.PostingLine {
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*ledgerv1.PostingLine, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
if line == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.GetLineType() == ledgerv1.LineType_LINE_MAIN {
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerEntryType(entryType ledgerv1.EntryType) connectorv1.OperationType {
|
||||||
|
switch entryType {
|
||||||
|
case ledgerv1.EntryType_ENTRY_CREDIT:
|
||||||
|
return connectorv1.OperationType_CREDIT
|
||||||
|
case ledgerv1.EntryType_ENTRY_DEBIT:
|
||||||
|
return connectorv1.OperationType_DEBIT
|
||||||
|
case ledgerv1.EntryType_ENTRY_TRANSFER:
|
||||||
|
return connectorv1.OperationType_TRANSFER
|
||||||
|
case ledgerv1.EntryType_ENTRY_FX:
|
||||||
|
return connectorv1.OperationType_FX
|
||||||
|
default:
|
||||||
|
return connectorv1.OperationType_OPERATION_TYPE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationAccountID(party *connectorv1.OperationParty) string {
|
||||||
|
if party == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if account := party.GetAccount(); account != nil {
|
||||||
|
return strings.TrimSpace(account.GetAccountId())
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLedgerAccountType(reader params.Reader, key string) (ledgerv1.AccountType, error) {
|
||||||
|
value, ok := reader.Value(key)
|
||||||
|
if !ok {
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: account_type is required")
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
return parseLedgerAccountTypeString(v)
|
||||||
|
case float64:
|
||||||
|
return ledgerv1.AccountType(int32(v)), nil
|
||||||
|
case int:
|
||||||
|
return ledgerv1.AccountType(v), nil
|
||||||
|
case int64:
|
||||||
|
return ledgerv1.AccountType(v), nil
|
||||||
|
default:
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: account_type is required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLedgerAccountTypeString(value string) (ledgerv1.AccountType, error) {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||||
|
case "ACCOUNT_TYPE_ASSET", "ASSET":
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, nil
|
||||||
|
case "ACCOUNT_TYPE_LIABILITY", "LIABILITY":
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, nil
|
||||||
|
case "ACCOUNT_TYPE_REVENUE", "REVENUE":
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, nil
|
||||||
|
case "ACCOUNT_TYPE_EXPENSE", "EXPENSE":
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil
|
||||||
|
default:
|
||||||
|
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("open_account: invalid account_type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLedgerAccountStatus(reader params.Reader, key string) ledgerv1.AccountStatus {
|
||||||
|
value := strings.ToUpper(strings.TrimSpace(reader.String(key)))
|
||||||
|
switch value {
|
||||||
|
case "ACCOUNT_STATUS_ACTIVE", "ACTIVE":
|
||||||
|
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE
|
||||||
|
case "ACCOUNT_STATUS_FROZEN", "FROZEN":
|
||||||
|
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN
|
||||||
|
default:
|
||||||
|
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEventTime(reader params.Reader) *timestamppb.Timestamp {
|
||||||
|
raw := strings.TrimSpace(reader.String("event_time"))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return timestamppb.New(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLedgerCharges(reader params.Reader) ([]*ledgerv1.PostingLine, error) {
|
||||||
|
items := reader.List("charges")
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
result := make([]*ledgerv1.PostingLine, 0, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
raw, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: invalid charge entry", i))
|
||||||
|
}
|
||||||
|
accountRef := strings.TrimSpace(fmt.Sprint(raw["ledger_account_ref"]))
|
||||||
|
if accountRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: ledger_account_ref is required", i))
|
||||||
|
}
|
||||||
|
money, err := parseMoneyFromMap(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgumentWrap(err, fmt.Sprintf("charges[%d]: invalid money", i))
|
||||||
|
}
|
||||||
|
lineType := parseLedgerLineType(fmt.Sprint(raw["line_type"]))
|
||||||
|
result = append(result, &ledgerv1.PostingLine{
|
||||||
|
LedgerAccountRef: accountRef,
|
||||||
|
Money: money,
|
||||||
|
LineType: lineType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLedgerLineType(value string) ledgerv1.LineType {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||||
|
case "LINE_TYPE_FEE", "FEE":
|
||||||
|
return ledgerv1.LineType_LINE_FEE
|
||||||
|
case "LINE_TYPE_SPREAD", "SPREAD":
|
||||||
|
return ledgerv1.LineType_LINE_SPREAD
|
||||||
|
case "LINE_TYPE_REVERSAL", "REVERSAL":
|
||||||
|
return ledgerv1.LineType_LINE_REVERSAL
|
||||||
|
default:
|
||||||
|
return ledgerv1.LineType_LINE_FEE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, merrors.InvalidArgument("money is required")
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
|
||||||
|
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
|
||||||
|
if amount == "" || currency == "" {
|
||||||
|
return nil, merrors.InvalidArgument("money is required")
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Amount: amount,
|
||||||
|
Currency: currency,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeMetadata(base map[string]string, label, ownerRef, correlationID, parentIntentID string) map[string]string {
|
||||||
|
metadata := map[string]string{}
|
||||||
|
for k, v := range base {
|
||||||
|
metadata[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
if label != "" {
|
||||||
|
if _, ok := metadata["label"]; !ok {
|
||||||
|
metadata["label"] = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ownerRef != "" {
|
||||||
|
if _, ok := metadata["owner_ref"]; !ok {
|
||||||
|
metadata["owner_ref"] = ownerRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if correlationID != "" {
|
||||||
|
metadata["correlation_id"] = correlationID
|
||||||
|
}
|
||||||
|
if parentIntentID != "" {
|
||||||
|
metadata["parent_intent_id"] = parentIntentID
|
||||||
|
}
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
||||||
|
err := &connectorv1.ConnectorError{
|
||||||
|
Code: code,
|
||||||
|
Message: strings.TrimSpace(message),
|
||||||
|
AccountId: strings.TrimSpace(accountID),
|
||||||
|
}
|
||||||
|
if op != nil {
|
||||||
|
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
|
||||||
|
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
|
||||||
|
err.OperationId = strings.TrimSpace(op.GetOperationId())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapErrorCode(err error) connectorv1.ErrorCode {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return connectorv1.ErrorCode_INVALID_PARAMS
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return connectorv1.ErrorCode_NOT_FOUND
|
||||||
|
case errors.Is(err, merrors.ErrNotImplemented):
|
||||||
|
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
|
||||||
|
case errors.Is(err, merrors.ErrInternal):
|
||||||
|
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
|
||||||
|
default:
|
||||||
|
return connectorv1.ErrorCode_PROVIDER_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,15 @@ import (
|
|||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/ledger/internal/appversion"
|
||||||
"github.com/tech/sendico/ledger/storage"
|
"github.com/tech/sendico/ledger/storage"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
pmessaging "github.com/tech/sendico/pkg/messaging"
|
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,17 +39,17 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
producer pmessaging.Producer
|
producer pmessaging.Producer
|
||||||
fees feesDependency
|
fees feesDependency
|
||||||
|
announcer *discovery.Announcer
|
||||||
|
|
||||||
outbox struct {
|
outbox struct {
|
||||||
once sync.Once
|
once sync.Once
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
publisher *outboxPublisher
|
publisher *outboxPublisher
|
||||||
}
|
}
|
||||||
ledgerv1.UnimplementedLedgerServiceServer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type feesDependency struct {
|
type feesDependency struct {
|
||||||
@@ -72,12 +76,13 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.
|
|||||||
}
|
}
|
||||||
|
|
||||||
service.startOutboxPublisher()
|
service.startOutboxPublisher()
|
||||||
|
service.startDiscoveryAnnouncer()
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Register(router routers.GRPC) error {
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
ledgerv1.RegisterLedgerServiceServer(reg, s)
|
connectorv1.RegisterConnectorServiceServer(reg, newConnectorAdapter(s))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,11 +189,28 @@ func (s *Service) Shutdown() {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.announcer != nil {
|
||||||
|
s.announcer.Stop()
|
||||||
|
}
|
||||||
if s.outbox.cancel != nil {
|
if s.outbox.cancel != nil {
|
||||||
s.outbox.cancel()
|
s.outbox.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "LEDGER",
|
||||||
|
Operations: []string{"balance.read", "ledger.debit", "ledger.credit"},
|
||||||
|
InvokeURI: discovery.DefaultInvokeURI(string(mservice.Ledger)),
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.Ledger), announce)
|
||||||
|
s.announcer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) startOutboxPublisher() {
|
func (s *Service) startOutboxPublisher() {
|
||||||
if s.storage == nil || s.producer == nil {
|
if s.storage == nil || s.producer == nil {
|
||||||
return
|
return
|
||||||
@@ -214,13 +236,6 @@ func (s *Service) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRe
|
|||||||
return responder(ctx)
|
return responder(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) pingStorage(ctx context.Context) error {
|
|
||||||
if s.storage == nil {
|
|
||||||
return errStorageNotInitialized
|
|
||||||
}
|
|
||||||
return s.storage.Ping(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) quoteFeesForCredit(ctx context.Context, req *ledgerv1.PostCreditRequest) ([]*ledgerv1.PostingLine, error) {
|
func (s *Service) quoteFeesForCredit(ctx context.Context, req *ledgerv1.PostCreditRequest) ([]*ledgerv1.PostingLine, error) {
|
||||||
if !s.fees.available() {
|
if !s.fees.available() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
type Account struct {
|
type Account struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
model.PermissionBound `bson:",inline" json:",inline"`
|
model.PermissionBound `bson:",inline" json:",inline"`
|
||||||
|
model.Describable `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
AccountCode string `bson:"accountCode" json:"accountCode"` // e.g., "asset:cash:usd"
|
AccountCode string `bson:"accountCode" json:"accountCode"` // e.g., "asset:cash:usd"
|
||||||
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
|
Currency string `bson:"currency" json:"currency"` // ISO 4217 currency code
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ api:
|
|||||||
message_broker:
|
message_broker:
|
||||||
driver: NATS
|
driver: NATS
|
||||||
settings:
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
host_env: NATS_HOST
|
host_env: NATS_HOST
|
||||||
port_env: NATS_PORT
|
port_env: NATS_PORT
|
||||||
username_env: NATS_USER
|
username_env: NATS_USER
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ go 1.25.3
|
|||||||
replace github.com/tech/sendico/pkg => ../pkg
|
replace github.com/tech/sendico/pkg => ../pkg
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/amplitude/analytics-go v1.2.0
|
github.com/amplitude/analytics-go v1.3.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||||
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
|
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||||
@@ -38,7 +38,7 @@ require (
|
|||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/sendgrid/rest v2.6.9+incompatible // indirect
|
github.com/sendgrid/rest v2.6.9+incompatible // indirect
|
||||||
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
|||||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
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 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/amplitude/analytics-go v1.2.0 h1:+WUKyAAKwlmSM8d03QWG+NjnrQIyc6VJRGPNkaa2ckI=
|
github.com/amplitude/analytics-go v1.3.0 h1:Lgj31fWThQ6hdDHO0RPxQfy/D7d8K+aqWsBa+IGTxQk=
|
||||||
github.com/amplitude/analytics-go v1.2.0/go.mod h1:kAQG8OQ6aPOxZrEZ3+/NFCfxdYSyjqXZhgkjWFD3/vo=
|
github.com/amplitude/analytics-go v1.3.0/go.mod h1:kAQG8OQ6aPOxZrEZ3+/NFCfxdYSyjqXZhgkjWFD3/vo=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
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/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.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
@@ -105,8 +105,8 @@ 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/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 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
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/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 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
@@ -121,8 +121,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
@@ -189,6 +189,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
|||||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
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 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
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.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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/tech/sendico/notification/interface/api/localizer"
|
"github.com/tech/sendico/notification/interface/api/localizer"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
"github.com/tech/sendico/pkg/domainprovider"
|
"github.com/tech/sendico/pkg/domainprovider"
|
||||||
@@ -16,6 +17,7 @@ type API interface {
|
|||||||
Register() messaging.Register
|
Register() messaging.Register
|
||||||
Localizer() localizer.Localizer
|
Localizer() localizer.Localizer
|
||||||
DomainProvider() domainprovider.DomainProvider
|
DomainProvider() domainprovider.DomainProvider
|
||||||
|
Router() *chi.Mux
|
||||||
}
|
}
|
||||||
|
|
||||||
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type APIImp struct {
|
|||||||
services Microservices
|
services Microservices
|
||||||
debug bool
|
debug bool
|
||||||
mw *Middleware
|
mw *Middleware
|
||||||
|
router *chi.Mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
||||||
@@ -69,6 +70,10 @@ func (a *APIImp) Register() messaging.Register {
|
|||||||
return a.mw
|
return a.mw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *APIImp) Router() *chi.Mux {
|
||||||
|
return a.router
|
||||||
|
}
|
||||||
|
|
||||||
func (a *APIImp) installServices() error {
|
func (a *APIImp) installServices() error {
|
||||||
srvf := make([]api.MicroServiceFactoryT, 0)
|
srvf := make([]api.MicroServiceFactoryT, 0)
|
||||||
|
|
||||||
@@ -117,6 +122,7 @@ func CreateAPI(logger mlogger.Logger, config *api.Config, l localizer.Localizer,
|
|||||||
p.config = config
|
p.config = config
|
||||||
p.db = db
|
p.db = db
|
||||||
p.localizer = l
|
p.localizer = l
|
||||||
|
p.router = router
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil {
|
if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil {
|
||||||
|
|||||||
403
api/notification/internal/server/notificationimp/confirmation.go
Normal file
403
api/notification/internal/server/notificationimp/confirmation.go
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
package notificationimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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 merrors.Internal("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", merrors.InvalidArgument("empty reply")
|
||||||
|
}
|
||||||
|
parts := strings.Fields(text)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
|
||||||
|
return nil, "missing_currency", merrors.InvalidArgument("currency is required")
|
||||||
|
}
|
||||||
|
return nil, "missing_amount", merrors.InvalidArgument("amount is required")
|
||||||
|
}
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return nil, "format", merrors.InvalidArgument("reply format is invalid")
|
||||||
|
}
|
||||||
|
amount := parts[0]
|
||||||
|
currency := parts[1]
|
||||||
|
if !amountPattern.MatchString(amount) {
|
||||||
|
return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
|
||||||
|
}
|
||||||
|
if !currencyPattern.MatchString(currency) {
|
||||||
|
return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid")
|
||||||
|
}
|
||||||
|
return &paymenttypes.Money{
|
||||||
|
Amount: amount,
|
||||||
|
Currency: strings.ToUpper(currency),
|
||||||
|
}, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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)."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/tech/sendico/notification/interface/api"
|
"github.com/tech/sendico/notification/interface/api"
|
||||||
|
"github.com/tech/sendico/notification/internal/appversion"
|
||||||
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail"
|
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail"
|
||||||
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
|
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/domainprovider"
|
"github.com/tech/sendico/pkg/domainprovider"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
na "github.com/tech/sendico/pkg/messaging/notifications/account"
|
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"
|
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
|
||||||
ni "github.com/tech/sendico/pkg/messaging/notifications/invitation"
|
ni "github.com/tech/sendico/pkg/messaging/notifications/invitation"
|
||||||
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
|
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
|
||||||
@@ -19,10 +22,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type NotificationAPI struct {
|
type NotificationAPI struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
client mmail.Client
|
client mmail.Client
|
||||||
dp domainprovider.DomainProvider
|
dp domainprovider.DomainProvider
|
||||||
tg telegram.Client
|
tg telegram.Client
|
||||||
|
announcer *discovery.Announcer
|
||||||
|
confirm *confirmationManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *NotificationAPI) Name() mservice.Type {
|
func (a *NotificationAPI) Name() mservice.Type {
|
||||||
@@ -30,6 +35,12 @@ func (a *NotificationAPI) Name() mservice.Type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *NotificationAPI) Finish(_ context.Context) error {
|
func (a *NotificationAPI) Finish(_ context.Context) error {
|
||||||
|
if a.announcer != nil {
|
||||||
|
a.announcer.Stop()
|
||||||
|
}
|
||||||
|
if a.confirm != nil {
|
||||||
|
a.confirm.Stop()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +66,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
|||||||
p.logger.Error("Failed to create telegram client", zap.Error(err))
|
p.logger.Error("Failed to create telegram client", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.confirm = newConfirmationManager(p.logger, p.tg, a.Register().Producer())
|
||||||
|
|
||||||
db, err := a.DBFactory().NewAccountDB()
|
db, err := a.DBFactory().NewAccountDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,6 +87,10 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
|||||||
p.logger.Error("Failed to create confirmation code handler", zap.Error(err))
|
p.logger.Error("Failed to create confirmation code handler", zap.Error(err))
|
||||||
return nil, 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()
|
idb, err := a.DBFactory().NewInvitationsDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,6 +107,18 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if router := a.Router(); router != nil {
|
||||||
|
router.Post("/telegram/webhook", p.handleTelegramWebhook)
|
||||||
|
}
|
||||||
|
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "NOTIFICATIONS",
|
||||||
|
Operations: []string{"notify.send"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
p.announcer = discovery.NewAnnouncer(p.logger, a.Register().Producer(), string(mservice.Notifications), announce)
|
||||||
|
p.announcer.Start()
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,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))
|
a.logger.Info("Call request sent via Telegram", zap.String("phone", request.Phone))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *NotificationAPI) onConfirmationRequest(ctx context.Context, request *model.ConfirmationRequest) error {
|
||||||
|
if a.confirm == nil {
|
||||||
|
return merrors.Internal("confirmation manager is not configured")
|
||||||
|
}
|
||||||
|
return a.confirm.HandleRequest(ctx, request)
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Client interface {
|
|||||||
SendDemoRequest(ctx context.Context, request *model.DemoRequest) error
|
SendDemoRequest(ctx context.Context, request *model.DemoRequest) error
|
||||||
SendContactRequest(ctx context.Context, request *model.ContactRequest) error
|
SendContactRequest(ctx context.Context, request *model.ContactRequest) error
|
||||||
SendCallRequest(ctx context.Context, request *model.CallRequest) 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 {
|
type client struct {
|
||||||
@@ -38,13 +39,14 @@ type client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sendMessagePayload struct {
|
type sendMessagePayload struct {
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
ParseMode string `json:"parse_mode,omitempty"`
|
ParseMode string `json:"parse_mode,omitempty"`
|
||||||
ThreadID *int64 `json:"message_thread_id,omitempty"`
|
ThreadID *int64 `json:"message_thread_id,omitempty"`
|
||||||
DisablePreview bool `json:"disable_web_page_preview,omitempty"`
|
ReplyToMessageID *int64 `json:"reply_to_message_id,omitempty"`
|
||||||
DisableNotify bool `json:"disable_notification,omitempty"`
|
DisablePreview bool `json:"disable_web_page_preview,omitempty"`
|
||||||
ProtectContent bool `json:"protect_content,omitempty"`
|
DisableNotify bool `json:"disable_notification,omitempty"`
|
||||||
|
ProtectContent bool `json:"protect_content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(logger mlogger.Logger, cfg *notconfig.TelegramConfig) (Client, error) {
|
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))
|
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)
|
body, err := json.Marshal(&payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Warn("Failed to marshal telegram payload", zap.Error(err))
|
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))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Warn("Failed to create telegram request", zap.Error(err))
|
c.logger.Warn("Failed to create telegram request", zap.Error(err))
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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 {
|
if payload.ThreadID != nil {
|
||||||
fields = append(fields, zap.Int64("thread_id", *payload.ThreadID))
|
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...)
|
c.logger.Debug("Sending Telegram message", fields...)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Warn("Telegram request failed", zap.Error(err))
|
c.logger.Warn("Telegram request failed", zap.Error(err))
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
|
||||||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
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)))
|
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",
|
c.logger.Warn("Telegram API returned non-success status",
|
||||||
zap.Int("status_code", resp.StatusCode),
|
zap.Int("status_code", resp.StatusCode),
|
||||||
zap.ByteString("response_body", respBody),
|
zap.ByteString("response_body", respBody),
|
||||||
zap.String("chat_id", c.chatID))
|
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 {
|
func (c *client) endpoint() string {
|
||||||
@@ -178,5 +219,51 @@ func (c *client) sendForm(ctx context.Context, template messageTemplate) error {
|
|||||||
ThreadID: c.threadID,
|
ThreadID: c.threadID,
|
||||||
DisablePreview: true,
|
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)
|
return c.sendMessage(ctx, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toTelegramMessage(msg *messageResponse) *model.TelegramMessage {
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &model.TelegramMessage{
|
||||||
|
ChatID: strconv.FormatInt(msg.Chat.ID, 10),
|
||||||
|
MessageID: strconv.FormatInt(msg.MessageID, 10),
|
||||||
|
Text: msg.Text,
|
||||||
|
SentAt: msg.Date,
|
||||||
|
}
|
||||||
|
if msg.From != nil {
|
||||||
|
result.FromUserID = strconv.FormatInt(msg.From.ID, 10)
|
||||||
|
result.FromUsername = msg.From.Username
|
||||||
|
}
|
||||||
|
if msg.ReplyToMessage != nil {
|
||||||
|
result.ReplyToMessageID = strconv.FormatInt(msg.ReplyToMessage.MessageID, 10)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Update struct {
|
||||||
|
UpdateID int64 `json:"update_id"`
|
||||||
|
Message *Message `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
Date int64 `json:"date,omitempty"`
|
||||||
|
Chat Chat `json:"chat"`
|
||||||
|
From *User `json:"from,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ReplyToMessage *Message `json:"reply_to_message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Chat struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) ToModel() *model.TelegramMessage {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &model.TelegramMessage{
|
||||||
|
ChatID: strconv.FormatInt(m.Chat.ID, 10),
|
||||||
|
MessageID: strconv.FormatInt(m.MessageID, 10),
|
||||||
|
Text: m.Text,
|
||||||
|
SentAt: m.Date,
|
||||||
|
}
|
||||||
|
if m.From != nil {
|
||||||
|
result.FromUserID = strconv.FormatInt(m.From.ID, 10)
|
||||||
|
result.FromUsername = m.From.Username
|
||||||
|
}
|
||||||
|
if m.ReplyToMessage != nil {
|
||||||
|
result.ReplyToMessageID = strconv.FormatInt(m.ReplyToMessage.MessageID, 10)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
30
api/notification/internal/server/notificationimp/webhook.go
Normal file
30
api/notification/internal/server/notificationimp/webhook.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package notificationimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/notification/internal/server/notificationimp/telegram"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const telegramWebhookMaxBody = 1 << 20
|
||||||
|
|
||||||
|
func (a *NotificationAPI) handleTelegramWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a == nil || a.confirm == nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var update telegram.Update
|
||||||
|
dec := json.NewDecoder(io.LimitReader(r.Body, telegramWebhookMaxBody))
|
||||||
|
if err := dec.Decode(&update); err != nil {
|
||||||
|
if a.logger != nil {
|
||||||
|
a.logger.Warn("Failed to decode telegram webhook update", zap.Error(err))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.confirm.HandleUpdate(r.Context(), &update)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -51,6 +51,12 @@ gateway:
|
|||||||
call_timeout_seconds: 3
|
call_timeout_seconds: 3
|
||||||
insecure: true
|
insecure: true
|
||||||
|
|
||||||
|
payment_gateway:
|
||||||
|
address: "sendico_tgsettle_gateway:50080"
|
||||||
|
dial_timeout_seconds: 5
|
||||||
|
call_timeout_seconds: 3
|
||||||
|
insecure: true
|
||||||
|
|
||||||
mntx:
|
mntx:
|
||||||
address: "sendico_mntx_gateway:50075"
|
address: "sendico_mntx_gateway:50075"
|
||||||
dial_timeout_seconds: 5
|
dial_timeout_seconds: 5
|
||||||
@@ -70,3 +76,15 @@ card_gateways:
|
|||||||
|
|
||||||
fee_ledger_accounts:
|
fee_ledger_accounts:
|
||||||
monetix: "ledger:fees:monetix"
|
monetix: "ledger:fees:monetix"
|
||||||
|
|
||||||
|
# gateway_instances:
|
||||||
|
# - id: "crypto-tron"
|
||||||
|
# rail: "CRYPTO"
|
||||||
|
# network: "TRON"
|
||||||
|
# currencies: ["USDT"]
|
||||||
|
# capabilities:
|
||||||
|
# can_pay_out: true
|
||||||
|
# can_send_fee: true
|
||||||
|
# limits:
|
||||||
|
# min_amount: "0"
|
||||||
|
# max_amount: "100000"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ require (
|
|||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
|
|||||||
231
api/payments/orchestrator/internal/server/internal/builders.go
Normal file
231
api/payments/orchestrator/internal/server/internal/builders.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/payments/rail"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
|
||||||
|
for key, route := range src {
|
||||||
|
trimmedKey := strings.TrimSpace(key)
|
||||||
|
if trimmedKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[trimmedKey] = orchestrator.CardGatewayRoute{
|
||||||
|
FundingAddress: strings.TrimSpace(route.FundingAddress),
|
||||||
|
FeeAddress: strings.TrimSpace(route.FeeAddress),
|
||||||
|
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]string, len(src))
|
||||||
|
for key, account := range src {
|
||||||
|
k := strings.ToLower(strings.TrimSpace(key))
|
||||||
|
v := strings.TrimSpace(account)
|
||||||
|
if k == "" || v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGatewayRegistry(logger mlogger.Logger, src []gatewayInstanceConfig, registry *discovery.Registry) orchestrator.GatewayRegistry {
|
||||||
|
static := buildGatewayInstances(logger, src)
|
||||||
|
staticRegistry := orchestrator.NewGatewayRegistry(logger, static)
|
||||||
|
discoveryRegistry := orchestrator.NewDiscoveryGatewayRegistry(logger, registry)
|
||||||
|
return orchestrator.NewCompositeGatewayRegistry(logger, staticRegistry, discoveryRegistry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRailGateways(chainClient chainclient.Client, paymentGatewayClient chainclient.Client, src []gatewayInstanceConfig) map[string]rail.RailGateway {
|
||||||
|
if len(src) == 0 || (chainClient == nil && paymentGatewayClient == nil) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
instances := buildGatewayInstances(nil, src)
|
||||||
|
if len(instances) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := map[string]rail.RailGateway{}
|
||||||
|
for _, inst := range instances {
|
||||||
|
if inst == nil || !inst.IsEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg := chainclient.RailGatewayConfig{
|
||||||
|
Rail: string(inst.Rail),
|
||||||
|
Network: inst.Network,
|
||||||
|
Capabilities: rail.RailCapabilities{
|
||||||
|
CanPayIn: inst.Capabilities.CanPayIn,
|
||||||
|
CanPayOut: inst.Capabilities.CanPayOut,
|
||||||
|
CanReadBalance: inst.Capabilities.CanReadBalance,
|
||||||
|
CanSendFee: inst.Capabilities.CanSendFee,
|
||||||
|
RequiresObserveConfirm: inst.Capabilities.RequiresObserveConfirm,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
switch inst.Rail {
|
||||||
|
case model.RailCrypto:
|
||||||
|
if chainClient == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[inst.ID] = chainclient.NewRailGateway(chainClient, cfg)
|
||||||
|
case model.RailProviderSettlement:
|
||||||
|
if paymentGatewayClient == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[inst.ID] = orchestrator.NewProviderSettlementGateway(paymentGatewayClient, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGatewayInstances(logger mlogger.Logger, src []gatewayInstanceConfig) []*model.GatewayInstanceDescriptor {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger = logger.Named("gateway_instances")
|
||||||
|
}
|
||||||
|
result := make([]*model.GatewayInstanceDescriptor, 0, len(src))
|
||||||
|
for _, cfg := range src {
|
||||||
|
id := strings.TrimSpace(cfg.ID)
|
||||||
|
if id == "" {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("Gateway instance skipped: missing id")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rail := parseRail(cfg.Rail)
|
||||||
|
if rail == model.RailUnspecified {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("Gateway instance skipped: invalid rail", zap.String("id", id), zap.String("rail", cfg.Rail))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
if cfg.IsEnabled != nil {
|
||||||
|
enabled = *cfg.IsEnabled
|
||||||
|
}
|
||||||
|
result = append(result, &model.GatewayInstanceDescriptor{
|
||||||
|
ID: id,
|
||||||
|
Rail: rail,
|
||||||
|
Network: strings.ToUpper(strings.TrimSpace(cfg.Network)),
|
||||||
|
Currencies: normalizeCurrencies(cfg.Currencies),
|
||||||
|
Capabilities: model.RailCapabilities{
|
||||||
|
CanPayIn: cfg.Capabilities.CanPayIn,
|
||||||
|
CanPayOut: cfg.Capabilities.CanPayOut,
|
||||||
|
CanReadBalance: cfg.Capabilities.CanReadBalance,
|
||||||
|
CanSendFee: cfg.Capabilities.CanSendFee,
|
||||||
|
RequiresObserveConfirm: cfg.Capabilities.RequiresObserveConfirm,
|
||||||
|
},
|
||||||
|
Limits: buildGatewayLimits(cfg.Limits),
|
||||||
|
Version: strings.TrimSpace(cfg.Version),
|
||||||
|
IsEnabled: enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRail(value string) model.Rail {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||||
|
case string(model.RailCrypto):
|
||||||
|
return model.RailCrypto
|
||||||
|
case string(model.RailProviderSettlement):
|
||||||
|
return model.RailProviderSettlement
|
||||||
|
case string(model.RailLedger):
|
||||||
|
return model.RailLedger
|
||||||
|
case string(model.RailCardPayout):
|
||||||
|
return model.RailCardPayout
|
||||||
|
case string(model.RailFiatOnRamp):
|
||||||
|
return model.RailFiatOnRamp
|
||||||
|
default:
|
||||||
|
return model.RailUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCurrencies(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
clean := strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if clean == "" || seen[clean] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[clean] = true
|
||||||
|
result = append(result, clean)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGatewayLimits(cfg limitsConfig) model.Limits {
|
||||||
|
limits := model.Limits{
|
||||||
|
MinAmount: strings.TrimSpace(cfg.MinAmount),
|
||||||
|
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
|
||||||
|
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
|
||||||
|
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
|
||||||
|
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.VolumeLimit) > 0 {
|
||||||
|
limits.VolumeLimit = map[string]string{}
|
||||||
|
for key, value := range cfg.VolumeLimit {
|
||||||
|
bucket := strings.TrimSpace(key)
|
||||||
|
amount := strings.TrimSpace(value)
|
||||||
|
if bucket == "" || amount == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.VolumeLimit[bucket] = amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.VelocityLimit) > 0 {
|
||||||
|
limits.VelocityLimit = map[string]int{}
|
||||||
|
for key, value := range cfg.VelocityLimit {
|
||||||
|
bucket := strings.TrimSpace(key)
|
||||||
|
if bucket == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.VelocityLimit[bucket] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.CurrencyLimits) > 0 {
|
||||||
|
limits.CurrencyLimits = map[string]model.LimitsOverride{}
|
||||||
|
for key, override := range cfg.CurrencyLimits {
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(key))
|
||||||
|
if currency == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
limits.CurrencyLimits[currency] = model.LimitsOverride{
|
||||||
|
MaxVolume: strings.TrimSpace(override.MaxVolume),
|
||||||
|
MinAmount: strings.TrimSpace(override.MinAmount),
|
||||||
|
MaxAmount: strings.TrimSpace(override.MaxAmount),
|
||||||
|
MaxFee: strings.TrimSpace(override.MaxFee),
|
||||||
|
MaxOps: override.MaxOps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return limits
|
||||||
|
}
|
||||||
176
api/payments/orchestrator/internal/server/internal/clients.go
Normal file
176
api/payments/orchestrator/internal/server/internal/clients.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||||
|
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||||
|
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.ClientConn) {
|
||||||
|
addr := cfg.address()
|
||||||
|
if addr == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
creds := credentials.NewTLS(&tls.Config{})
|
||||||
|
if cfg.InsecureTransport {
|
||||||
|
creds = insecure.NewCredentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
i.logger.Info("Connected to fees service", zap.String("address", addr))
|
||||||
|
return feesv1.NewFeeEngineClient(conn), conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
|
||||||
|
addr := cfg.address()
|
||||||
|
if addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := ledgerclient.New(ctx, ledgerclient.Config{
|
||||||
|
Address: addr,
|
||||||
|
DialTimeout: cfg.dialTimeout(),
|
||||||
|
CallTimeout: cfg.callTimeout(),
|
||||||
|
Insecure: cfg.InsecureTransport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i.logger.Info("Connected to ledger service", zap.String("address", addr))
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
|
||||||
|
addr := cfg.address()
|
||||||
|
if addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := chainclient.New(ctx, chainclient.Config{
|
||||||
|
Address: addr,
|
||||||
|
DialTimeout: cfg.dialTimeout(),
|
||||||
|
CallTimeout: cfg.callTimeout(),
|
||||||
|
Insecure: cfg.InsecureTransport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("failed to connect to chain gateway service", zap.String("address", addr), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i.logger.Info("connected to chain gateway service", zap.String("address", addr))
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initPaymentGatewayClient(cfg clientConfig) chainclient.Client {
|
||||||
|
addr := cfg.address()
|
||||||
|
if addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := chainclient.New(ctx, chainclient.Config{
|
||||||
|
Address: addr,
|
||||||
|
DialTimeout: cfg.dialTimeout(),
|
||||||
|
CallTimeout: cfg.callTimeout(),
|
||||||
|
Insecure: cfg.InsecureTransport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("failed to connect to payment gateway service", zap.String("address", addr), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i.logger.Info("connected to payment gateway service", zap.String("address", addr))
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
|
||||||
|
addr := cfg.address()
|
||||||
|
if addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := mntxclient.New(ctx, mntxclient.Config{
|
||||||
|
Address: addr,
|
||||||
|
DialTimeout: cfg.dialTimeout(),
|
||||||
|
CallTimeout: cfg.callTimeout(),
|
||||||
|
Logger: i.logger.Named("client.mntx"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i.logger.Info("Connected to mntx gateway service", zap.String("address", addr))
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
|
||||||
|
addr := cfg.address()
|
||||||
|
if addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := oracleclient.New(ctx, oracleclient.Config{
|
||||||
|
Address: addr,
|
||||||
|
DialTimeout: cfg.dialTimeout(),
|
||||||
|
CallTimeout: cfg.callTimeout(),
|
||||||
|
Insecure: cfg.InsecureTransport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i.logger.Info("Connected to oracle service", zap.String("address", addr))
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) closeClients() {
|
||||||
|
if i.ledgerClient != nil {
|
||||||
|
_ = i.ledgerClient.Close()
|
||||||
|
}
|
||||||
|
if i.gatewayClient != nil {
|
||||||
|
_ = i.gatewayClient.Close()
|
||||||
|
}
|
||||||
|
if i.paymentGatewayClient != nil {
|
||||||
|
_ = i.paymentGatewayClient.Close()
|
||||||
|
}
|
||||||
|
if i.mntxClient != nil {
|
||||||
|
_ = i.mntxClient.Close()
|
||||||
|
}
|
||||||
|
if i.oracleClient != nil {
|
||||||
|
_ = i.oracleClient.Close()
|
||||||
|
}
|
||||||
|
if i.feesConn != nil {
|
||||||
|
_ = i.feesConn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
136
api/payments/orchestrator/internal/server/internal/config.go
Normal file
136
api/payments/orchestrator/internal/server/internal/config.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
Fees clientConfig `yaml:"fees"`
|
||||||
|
Ledger clientConfig `yaml:"ledger"`
|
||||||
|
Gateway clientConfig `yaml:"gateway"`
|
||||||
|
PaymentGateway clientConfig `yaml:"payment_gateway"`
|
||||||
|
Mntx clientConfig `yaml:"mntx"`
|
||||||
|
Oracle clientConfig `yaml:"oracle"`
|
||||||
|
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
||||||
|
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
||||||
|
GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientConfig struct {
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
DialTimeoutSecs int `yaml:"dial_timeout_seconds"`
|
||||||
|
CallTimeoutSecs int `yaml:"call_timeout_seconds"`
|
||||||
|
InsecureTransport bool `yaml:"insecure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cardGatewayRouteConfig struct {
|
||||||
|
FundingAddress string `yaml:"funding_address"`
|
||||||
|
FeeAddress string `yaml:"fee_address"`
|
||||||
|
FeeWalletRef string `yaml:"fee_wallet_ref"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gatewayInstanceConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Rail string `yaml:"rail"`
|
||||||
|
Network string `yaml:"network"`
|
||||||
|
Currencies []string `yaml:"currencies"`
|
||||||
|
Capabilities gatewayCapabilitiesConfig `yaml:"capabilities"`
|
||||||
|
Limits limitsConfig `yaml:"limits"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
IsEnabled *bool `yaml:"is_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gatewayCapabilitiesConfig struct {
|
||||||
|
CanPayIn bool `yaml:"can_pay_in"`
|
||||||
|
CanPayOut bool `yaml:"can_pay_out"`
|
||||||
|
CanReadBalance bool `yaml:"can_read_balance"`
|
||||||
|
CanSendFee bool `yaml:"can_send_fee"`
|
||||||
|
RequiresObserveConfirm bool `yaml:"requires_observe_confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitsConfig struct {
|
||||||
|
MinAmount string `yaml:"min_amount"`
|
||||||
|
MaxAmount string `yaml:"max_amount"`
|
||||||
|
PerTxMaxFee string `yaml:"per_tx_max_fee"`
|
||||||
|
PerTxMinAmount string `yaml:"per_tx_min_amount"`
|
||||||
|
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
|
||||||
|
VolumeLimit map[string]string `yaml:"volume_limit"`
|
||||||
|
VelocityLimit map[string]int `yaml:"velocity_limit"`
|
||||||
|
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitsOverrideCfg struct {
|
||||||
|
MaxVolume string `yaml:"max_volume"`
|
||||||
|
MinAmount string `yaml:"min_amount"`
|
||||||
|
MaxAmount string `yaml:"max_amount"`
|
||||||
|
MaxFee string `yaml:"max_fee"`
|
||||||
|
MaxOps int `yaml:"max_ops"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c clientConfig) address() string {
|
||||||
|
return strings.TrimSpace(c.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c clientConfig) dialTimeout() time.Duration {
|
||||||
|
if c.DialTimeoutSecs <= 0 {
|
||||||
|
return 5 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(c.DialTimeoutSecs) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c clientConfig) callTimeout() time.Duration {
|
||||||
|
if c.CallTimeoutSecs <= 0 {
|
||||||
|
return 3 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(c.CallTimeoutSecs) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
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: ":50062",
|
||||||
|
EnableReflection: true,
|
||||||
|
EnableHealth: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if strings.TrimSpace(cfg.GRPC.Address) == "" {
|
||||||
|
cfg.GRPC.Address = ":50062"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.GRPC.Network) == "" {
|
||||||
|
cfg.GRPC.Network = "tcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Metrics == nil {
|
||||||
|
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9403"}
|
||||||
|
} else if strings.TrimSpace(cfg.Metrics.Address) == "" {
|
||||||
|
cfg.Metrics.Address = ":9403"
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
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"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type orchestratorDeps struct {
|
||||||
|
feesClient feesv1.FeeEngineClient
|
||||||
|
ledgerClient ledgerclient.Client
|
||||||
|
gatewayClient chainclient.Client
|
||||||
|
paymentGatewayClient chainclient.Client
|
||||||
|
mntxClient mntxclient.Client
|
||||||
|
oracleClient oracleclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initDependencies(cfg *config) *orchestratorDeps {
|
||||||
|
deps := &orchestratorDeps{}
|
||||||
|
if cfg == nil {
|
||||||
|
return deps
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.feesClient, i.feesConn = i.initFeesClient(cfg.Fees)
|
||||||
|
|
||||||
|
deps.ledgerClient = i.initLedgerClient(cfg.Ledger)
|
||||||
|
if deps.ledgerClient != nil {
|
||||||
|
i.ledgerClient = deps.ledgerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.gatewayClient = i.initGatewayClient(cfg.Gateway)
|
||||||
|
if deps.gatewayClient != nil {
|
||||||
|
i.gatewayClient = deps.gatewayClient
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.paymentGatewayClient = i.initPaymentGatewayClient(cfg.PaymentGateway)
|
||||||
|
if deps.paymentGatewayClient != nil {
|
||||||
|
i.paymentGatewayClient = deps.paymentGatewayClient
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.mntxClient = i.initMntxClient(cfg.Mntx)
|
||||||
|
if deps.mntxClient != nil {
|
||||||
|
i.mntxClient = deps.mntxClient
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.oracleClient = i.initOracleClient(cfg.Oracle)
|
||||||
|
if deps.oracleClient != nil {
|
||||||
|
i.oracleClient = deps.oracleClient
|
||||||
|
}
|
||||||
|
|
||||||
|
return deps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchestrator.Option {
|
||||||
|
if cfg == nil || deps == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
opts := []orchestrator.Option{}
|
||||||
|
if deps.feesClient != nil {
|
||||||
|
opts = append(opts, orchestrator.WithFeeEngine(deps.feesClient, cfg.Fees.callTimeout()))
|
||||||
|
}
|
||||||
|
if deps.ledgerClient != nil {
|
||||||
|
opts = append(opts, orchestrator.WithLedgerClient(deps.ledgerClient))
|
||||||
|
}
|
||||||
|
if deps.gatewayClient != nil {
|
||||||
|
opts = append(opts, orchestrator.WithChainGatewayClient(deps.gatewayClient))
|
||||||
|
}
|
||||||
|
if deps.paymentGatewayClient != nil {
|
||||||
|
opts = append(opts, orchestrator.WithProviderSettlementGatewayClient(deps.paymentGatewayClient))
|
||||||
|
}
|
||||||
|
if railGateways := buildRailGateways(deps.gatewayClient, deps.paymentGatewayClient, cfg.GatewayInstances); len(railGateways) > 0 {
|
||||||
|
opts = append(opts, orchestrator.WithRailGateways(railGateways))
|
||||||
|
}
|
||||||
|
if deps.mntxClient != nil {
|
||||||
|
opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient))
|
||||||
|
}
|
||||||
|
if deps.oracleClient != nil {
|
||||||
|
opts = append(opts, orchestrator.WithOracleClient(deps.oracleClient))
|
||||||
|
}
|
||||||
|
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
|
||||||
|
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
|
||||||
|
}
|
||||||
|
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
|
||||||
|
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
|
||||||
|
}
|
||||||
|
if registry := buildGatewayRegistry(i.logger, cfg.GatewayInstances, i.discoveryReg); registry != nil {
|
||||||
|
opts = append(opts, orchestrator.WithGatewayRegistry(registry))
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/appversion"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *Imp) initDiscovery(cfg *config) {
|
||||||
|
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger := i.logger.Named("discovery")
|
||||||
|
broker, err := msg.CreateMessagingBroker(logger.Named("bus"), cfg.Messaging)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to initialise discovery broker", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
producer := msgproducer.NewProducer(logger.Named("producer"), broker)
|
||||||
|
registry := discovery.NewRegistry()
|
||||||
|
watcher, err := discovery.NewRegistryWatcher(i.logger, broker, registry)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(err))
|
||||||
|
} else if err := watcher.Start(); err != nil {
|
||||||
|
i.logger.Warn("Failed to start discovery registry watcher", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
i.discoveryWatcher = watcher
|
||||||
|
i.discoveryReg = registry
|
||||||
|
i.logger.Info("Discovery registry watcher started")
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "PAYMENTS_ORCHESTRATOR",
|
||||||
|
Operations: []string{"payment.quote", "payment.initiate"},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
i.discoveryAnnouncer = discovery.NewAnnouncer(i.logger, producer, string(mservice.PaymentOrchestrator), announce)
|
||||||
|
i.discoveryAnnouncer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) stopDiscovery() {
|
||||||
|
if i.discoveryAnnouncer != nil {
|
||||||
|
i.discoveryAnnouncer.Stop()
|
||||||
|
}
|
||||||
|
if i.discoveryWatcher != nil {
|
||||||
|
i.discoveryWatcher.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *Imp) shutdownApp() {
|
||||||
|
if i.app != nil {
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
i.app.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,88 +1,17 @@
|
|||||||
package serverimp
|
package serverimp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
|
||||||
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/internal/service/orchestrator"
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo"
|
mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
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/mlogger"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
|
||||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Imp struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
file string
|
|
||||||
debug bool
|
|
||||||
|
|
||||||
config *config
|
|
||||||
app *grpcapp.App[storage.Repository]
|
|
||||||
feesConn *grpc.ClientConn
|
|
||||||
ledgerClient ledgerclient.Client
|
|
||||||
gatewayClient chainclient.Client
|
|
||||||
mntxClient mntxclient.Client
|
|
||||||
oracleClient oracleclient.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
type config struct {
|
|
||||||
*grpcapp.Config `yaml:",inline"`
|
|
||||||
Fees clientConfig `yaml:"fees"`
|
|
||||||
Ledger clientConfig `yaml:"ledger"`
|
|
||||||
Gateway clientConfig `yaml:"gateway"`
|
|
||||||
Mntx clientConfig `yaml:"mntx"`
|
|
||||||
Oracle clientConfig `yaml:"oracle"`
|
|
||||||
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
|
||||||
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type clientConfig struct {
|
|
||||||
Address string `yaml:"address"`
|
|
||||||
DialTimeoutSecs int `yaml:"dial_timeout_seconds"`
|
|
||||||
CallTimeoutSecs int `yaml:"call_timeout_seconds"`
|
|
||||||
InsecureTransport bool `yaml:"insecure"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type cardGatewayRouteConfig struct {
|
|
||||||
FundingAddress string `yaml:"funding_address"`
|
|
||||||
FeeAddress string `yaml:"fee_address"`
|
|
||||||
FeeWalletRef string `yaml:"fee_wallet_ref"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c clientConfig) address() string {
|
|
||||||
return strings.TrimSpace(c.Address)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c clientConfig) dialTimeout() time.Duration {
|
|
||||||
if c.DialTimeoutSecs <= 0 {
|
|
||||||
return 5 * time.Second
|
|
||||||
}
|
|
||||||
return time.Duration(c.DialTimeoutSecs) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c clientConfig) callTimeout() time.Duration {
|
|
||||||
if c.CallTimeoutSecs <= 0 {
|
|
||||||
return 3 * time.Second
|
|
||||||
}
|
|
||||||
return time.Duration(c.CallTimeoutSecs) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
return &Imp{
|
return &Imp{
|
||||||
logger: logger.Named("server"),
|
logger: logger.Named("server"),
|
||||||
@@ -92,31 +21,12 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) Shutdown() {
|
func (i *Imp) Shutdown() {
|
||||||
if i.app != nil {
|
i.stopDiscovery()
|
||||||
timeout := 15 * time.Second
|
if i.service != nil {
|
||||||
if i.config != nil && i.config.Runtime != nil {
|
i.service.Shutdown()
|
||||||
timeout = i.config.Runtime.ShutdownTimeout()
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
i.app.Shutdown(ctx)
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
if i.ledgerClient != nil {
|
|
||||||
_ = i.ledgerClient.Close()
|
|
||||||
}
|
|
||||||
if i.gatewayClient != nil {
|
|
||||||
_ = i.gatewayClient.Close()
|
|
||||||
}
|
|
||||||
if i.mntxClient != nil {
|
|
||||||
_ = i.mntxClient.Close()
|
|
||||||
}
|
|
||||||
if i.oracleClient != nil {
|
|
||||||
_ = i.oracleClient.Close()
|
|
||||||
}
|
|
||||||
if i.feesConn != nil {
|
|
||||||
_ = i.feesConn.Close()
|
|
||||||
}
|
}
|
||||||
|
i.shutdownApp()
|
||||||
|
i.closeClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
@@ -126,59 +36,30 @@ func (i *Imp) Start() error {
|
|||||||
}
|
}
|
||||||
i.config = cfg
|
i.config = cfg
|
||||||
|
|
||||||
|
i.initDiscovery(cfg)
|
||||||
|
|
||||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
return mongostorage.New(logger, conn)
|
return mongostorage.New(logger, conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
feesClient, feesConn := i.initFeesClient(cfg.Fees)
|
var broker mb.Broker
|
||||||
if feesConn != nil {
|
if cfg.Messaging != nil && cfg.Messaging.Driver != "" {
|
||||||
i.feesConn = feesConn
|
broker, err = msg.CreateMessagingBroker(i.logger, cfg.Messaging)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to create messaging broker", zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ledgerClient := i.initLedgerClient(cfg.Ledger)
|
deps := i.initDependencies(cfg)
|
||||||
if ledgerClient != nil {
|
|
||||||
i.ledgerClient = ledgerClient
|
|
||||||
}
|
|
||||||
|
|
||||||
gatewayClient := i.initGatewayClient(cfg.Gateway)
|
|
||||||
if gatewayClient != nil {
|
|
||||||
i.gatewayClient = gatewayClient
|
|
||||||
}
|
|
||||||
|
|
||||||
mntxClient := i.initMntxClient(cfg.Mntx)
|
|
||||||
if mntxClient != nil {
|
|
||||||
i.mntxClient = mntxClient
|
|
||||||
}
|
|
||||||
|
|
||||||
oracleClient := i.initOracleClient(cfg.Oracle)
|
|
||||||
if oracleClient != nil {
|
|
||||||
i.oracleClient = oracleClient
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
opts := []orchestrator.Option{}
|
opts := i.buildServiceOptions(cfg, deps)
|
||||||
if feesClient != nil {
|
if broker != nil {
|
||||||
opts = append(opts, orchestrator.WithFeeEngine(feesClient, cfg.Fees.callTimeout()))
|
opts = append(opts, orchestrator.WithPaymentGatewayBroker(broker))
|
||||||
}
|
}
|
||||||
if ledgerClient != nil {
|
svc := orchestrator.NewService(logger, repo, opts...)
|
||||||
opts = append(opts, orchestrator.WithLedgerClient(ledgerClient))
|
i.service = svc
|
||||||
}
|
return svc, nil
|
||||||
if gatewayClient != nil {
|
|
||||||
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
|
|
||||||
}
|
|
||||||
if mntxClient != nil {
|
|
||||||
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
|
|
||||||
}
|
|
||||||
if oracleClient != nil {
|
|
||||||
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
|
|
||||||
}
|
|
||||||
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
|
|
||||||
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
|
|
||||||
}
|
|
||||||
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
|
|
||||||
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
|
|
||||||
}
|
|
||||||
return orchestrator.NewService(logger, repo, opts...), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "payments_orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "payments_orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
@@ -189,196 +70,3 @@ func (i *Imp) Start() error {
|
|||||||
|
|
||||||
return i.app.Start()
|
return i.app.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.ClientConn) {
|
|
||||||
addr := cfg.address()
|
|
||||||
if addr == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
creds := credentials.NewTLS(&tls.Config{})
|
|
||||||
if cfg.InsecureTransport {
|
|
||||||
creds = insecure.NewCredentials()
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
i.logger.Info("Connected to fees service", zap.String("address", addr))
|
|
||||||
return feesv1.NewFeeEngineClient(conn), conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
|
|
||||||
addr := cfg.address()
|
|
||||||
if addr == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := ledgerclient.New(ctx, ledgerclient.Config{
|
|
||||||
Address: addr,
|
|
||||||
DialTimeout: cfg.dialTimeout(),
|
|
||||||
CallTimeout: cfg.callTimeout(),
|
|
||||||
Insecure: cfg.InsecureTransport,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
i.logger.Info("Connected to ledger service", zap.String("address", addr))
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
|
|
||||||
addr := cfg.address()
|
|
||||||
if addr == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := chainclient.New(ctx, chainclient.Config{
|
|
||||||
Address: addr,
|
|
||||||
DialTimeout: cfg.dialTimeout(),
|
|
||||||
CallTimeout: cfg.callTimeout(),
|
|
||||||
Insecure: cfg.InsecureTransport,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Warn("failed to connect to chain gateway service", zap.String("address", addr), zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
i.logger.Info("connected to chain gateway service", zap.String("address", addr))
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
|
|
||||||
addr := cfg.address()
|
|
||||||
if addr == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := mntxclient.New(ctx, mntxclient.Config{
|
|
||||||
Address: addr,
|
|
||||||
DialTimeout: cfg.dialTimeout(),
|
|
||||||
CallTimeout: cfg.callTimeout(),
|
|
||||||
Logger: i.logger.Named("client.mntx"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
i.logger.Info("Connected to mntx gateway service", zap.String("address", addr))
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
|
|
||||||
addr := cfg.address()
|
|
||||||
if addr == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := oracleclient.New(ctx, oracleclient.Config{
|
|
||||||
Address: addr,
|
|
||||||
DialTimeout: cfg.dialTimeout(),
|
|
||||||
CallTimeout: cfg.callTimeout(),
|
|
||||||
Insecure: cfg.InsecureTransport,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
i.logger.Info("Connected to oracle service", zap.String("address", addr))
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
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: ":50062",
|
|
||||||
EnableReflection: true,
|
|
||||||
EnableHealth: true,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if strings.TrimSpace(cfg.GRPC.Address) == "" {
|
|
||||||
cfg.GRPC.Address = ":50062"
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.GRPC.Network) == "" {
|
|
||||||
cfg.GRPC.Network = "tcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Metrics == nil {
|
|
||||||
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9403"}
|
|
||||||
} else if strings.TrimSpace(cfg.Metrics.Address) == "" {
|
|
||||||
cfg.Metrics.Address = ":9403"
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
|
|
||||||
for key, route := range src {
|
|
||||||
trimmedKey := strings.TrimSpace(key)
|
|
||||||
if trimmedKey == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[trimmedKey] = orchestrator.CardGatewayRoute{
|
|
||||||
FundingAddress: strings.TrimSpace(route.FundingAddress),
|
|
||||||
FeeAddress: strings.TrimSpace(route.FeeAddress),
|
|
||||||
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make(map[string]string, len(src))
|
|
||||||
for key, account := range src {
|
|
||||||
k := strings.ToLower(strings.TrimSpace(key))
|
|
||||||
v := strings.TrimSpace(account)
|
|
||||||
if k == "" || v == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[k] = v
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user