26 Commits

Author SHA1 Message Date
dedde76dd7 Merge pull request 'Added ownership reference + wallet creation methods' (#243) from owner-242 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #243
2026-01-07 12:14:59 +00:00
Stephan D
9e747e7251 Added ownership reference + wallet creation methods 2026-01-07 13:12:31 +01:00
33647a0f3d Merge pull request 'ledger settlement account autocreation' (#241) from ledger-237 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/gateway_chain Pipeline failed
ci/woodpecker/push/gateway_mntx Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #241
2026-01-06 18:08:18 +00:00
Stephan D
890f78a42e ledger settlement account autocreation 2026-01-06 19:06:15 +01:00
c0ba167f69 Merge pull request 'compilation fix' (#239) from ledger-237 into main
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline failed
ci/woodpecker/push/gateway_mntx Pipeline failed
ci/woodpecker/push/gateway_tgsettle Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #239
2026-01-06 17:05:26 +00:00
Stephan D
3aa5d56cc3 compilation fiz 2026-01-06 18:04:56 +01:00
326fc5a885 Merge pull request 'ledger account describibale support' (#238) from ledger-237 into main
Some checks failed
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
Reviewed-on: #238
2026-01-06 16:52:09 +00:00
Stephan D
43edbc109d ledger account describibale support 2026-01-06 17:51:35 +01:00
12700c5595 Merge pull request 'fixed excessive logging non-nil checks)' (#236) from logging-235 into main
Reviewed-on: #236
2026-01-06 15:10:05 +00:00
Stephan D
4da9e0b522 fixed excessive logging non-nil checks) 2026-01-06 16:05:20 +01:00
5d443230f4 Merge pull request 'TTL for discovery values' (#234) from discovery-233 into main
Some checks failed
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #234
2026-01-06 13:20:27 +00:00
Stephan D
3e83cc51d7 TTL for discovery values 2026-01-06 14:20:08 +01:00
9c2ef52d07 Merge pull request '+ ledger account open endpoint' (#232) from ledger-231 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
Reviewed-on: #232
2026-01-05 12:03:10 +00:00
Stephan D
e84854d875 fixed mntx compilation 2026-01-05 13:02:47 +01:00
Stephan D
2f34b5a827 + ledger account open endpoint 2026-01-05 12:57:17 +01:00
9a5c087940 Merge pull request 'compilation fixed' (#230) from mntx-229 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline failed
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #230
2026-01-05 01:52:23 +00:00
Stephan D
4fb2e0433c compilation fixed 2026-01-05 02:51:39 +01:00
cd89171cf0 Merge pull request 'interface refactoring' (#228) from gateway-227 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline failed
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #228
2026-01-05 00:23:43 +00:00
Stephan D
7424ef751c interface refactoring 2026-01-05 01:22:47 +01:00
fcd831902a Merge pull request 'nats-225' (#226) from nats-225 into main
All checks were successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #226
2026-01-04 13:50:07 +00:00
Stephan D
03f4988a99 fixed mntx build 2026-01-04 14:46:20 +01:00
Stephan D
5684a959f5 isolated NATS logic 2026-01-04 14:44:46 +01:00
94406f65cb Merge pull request 'fixed discovery grpc port' (#224) from tgsettle-223 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline failed
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #224
2026-01-04 13:00:08 +00:00
Stephan D
49ba144d8c fixed discovery grpc port 2026-01-04 13:59:39 +01:00
2c5f2b8cb1 Merge pull request 'tgsettle gateway build added' (#222) from tgsettle-221 into main
Some checks failed
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/discovery Pipeline failed
Reviewed-on: #222
2026-01-04 12:13:36 +00:00
Stephan D
ee28c13558 tgsettle gateway build added 2026-01-04 13:12:49 +01:00
106 changed files with 4920 additions and 395 deletions

View 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

View File

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

View File

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

View File

@@ -15,3 +15,6 @@ messaging:
broker_name: Discovery Service broker_name: Discovery Service
max_reconnects: 10 max_reconnects: 10
reconnect_wait: 5 reconnect_wait: 5
registry:
kv_ttl_seconds: 3600

View File

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

View File

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

View File

@@ -16,12 +16,17 @@ type config struct {
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"` Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
Messaging *msg.Config `yaml:"messaging"` Messaging *msg.Config `yaml:"messaging"`
Metrics *metricsConfig `yaml:"metrics"` Metrics *metricsConfig `yaml:"metrics"`
Registry *registryConfig `yaml:"registry"`
} }
type metricsConfig struct { type metricsConfig struct {
Address string `yaml:"address"` Address string `yaml:"address"`
} }
type registryConfig struct {
KVTTLSeconds *int `yaml:"kv_ttl_seconds"`
}
func (i *Imp) loadConfig() (*config, error) { func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file) data, err := os.ReadFile(i.file)
if err != nil { if err != nil {

View File

@@ -1,6 +1,8 @@
package serverimp package serverimp
import ( import (
"time"
"github.com/tech/sendico/discovery/internal/appversion" "github.com/tech/sendico/discovery/internal/appversion"
"github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
@@ -23,7 +25,16 @@ func (i *Imp) startDiscovery(cfg *config) error {
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker) producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
registry := discovery.NewRegistry() registry := discovery.NewRegistry()
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery)) 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 { if err != nil {
return err return err
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,13 +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"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/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)
@@ -30,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.
@@ -76,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: unifiedv1.NewUnifiedGatewayServiceClient(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,
@@ -99,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) {
@@ -163,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
}

View File

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

View File

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

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

View File

@@ -19,8 +19,8 @@ import (
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"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
"google.golang.org/grpc" "google.golang.org/grpc"
) )
@@ -34,7 +34,7 @@ var (
errStorageUnavailable = serviceError("chain_gateway: storage not initialised") errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
) )
// Service implements the UnifiedGatewayService RPC contract for chain operations. // 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
@@ -52,7 +52,7 @@ type Service struct {
commands commands.Registry commands commands.Registry
announcers []*discovery.Announcer announcers []*discovery.Announcer
unifiedv1.UnimplementedUnifiedGatewayServiceServer connectorv1.UnimplementedConnectorServiceServer
} }
// NewService constructs the chain gateway service skeleton. // NewService constructs the chain gateway service skeleton.
@@ -95,7 +95,7 @@ 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) {
unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) connectorv1.RegisterConnectorServiceServer(reg, s)
}) })
} }

View File

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

View File

@@ -5,12 +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"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/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.
@@ -22,9 +25,14 @@ type Client interface {
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 unifiedv1.UnifiedGatewayServiceClient client grpcConnectorClient
cfg Config cfg Config
logger *zap.Logger logger *zap.Logger
} }
@@ -49,7 +57,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
return &gatewayClient{ return &gatewayClient{
conn: conn, conn: conn,
client: unifiedv1.NewUnifiedGatewayServiceClient(conn), client: connectorv1.NewConnectorServiceClient(conn),
cfg: cfg, cfg: cfg,
logger: cfg.Logger, logger: cfg.Logger,
}, nil }, nil
@@ -70,6 +78,7 @@ func (g *gatewayClient) callContext(ctx context.Context, method string) (context
if timeout <= 0 { if timeout <= 0 {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
if g.logger != nil {
fields := []zap.Field{ fields := []zap.Field{
zap.String("method", method), zap.String("method", method),
zap.Duration("timeout", timeout), zap.Duration("timeout", timeout),
@@ -78,29 +87,244 @@ func (g *gatewayClient) callContext(ctx context.Context, method string) (context
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline))) 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...) 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) { func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
ctx, cancel := g.callContext(ctx, "ListGatewayInstances") return nil, merrors.NotImplemented("mntx: ListGatewayInstances not supported via connector")
defer cancel() }
return g.client.ListGatewayInstances(ctx, req)
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
} }

View File

@@ -7,6 +7,7 @@ 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/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.78.0
@@ -33,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

View File

@@ -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=
@@ -125,6 +125,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= 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 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=

View File

@@ -458,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")

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

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/proto"
) )
// ListGatewayInstances exposes the Monetix gateway instance descriptors. // ListGatewayInstances exposes the Monetix gateway instance descriptors.
@@ -25,16 +26,15 @@ func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1
if src == nil { if src == nil {
return nil return nil
} }
cp := *src cp := proto.Clone(src).(*gatewayv1.GatewayInstanceDescriptor)
if src.Currencies != nil { if src.Currencies != nil {
cp.Currencies = append([]string(nil), src.Currencies...) cp.Currencies = append([]string(nil), src.Currencies...)
} }
if src.Capabilities != nil { if src.Capabilities != nil {
cap := *src.Capabilities cp.Capabilities = proto.Clone(src.Capabilities).(*gatewayv1.RailCapabilities)
cp.Capabilities = &cap
} }
if src.Limits != nil { if src.Limits != nil {
limits := *src.Limits limits := &gatewayv1.Limits{}
if src.Limits.VolumeLimit != nil { if src.Limits.VolumeLimit != nil {
limits.VolumeLimit = map[string]string{} limits.VolumeLimit = map[string]string{}
for key, value := range src.Limits.VolumeLimit { for key, value := range src.Limits.VolumeLimit {
@@ -53,11 +53,10 @@ func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1
if value == nil { if value == nil {
continue continue
} }
clone := *value limits.CurrencyLimits[key] = proto.Clone(value).(*gatewayv1.LimitsOverride)
limits.CurrencyLimits[key] = &clone
} }
} }
cp.Limits = &limits cp.Limits = limits
} }
return &cp return cp
} }

View File

@@ -15,8 +15,7 @@ import (
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc" "google.golang.org/grpc"
) )
@@ -32,7 +31,7 @@ type Service struct {
gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor
announcer *discovery.Announcer announcer *discovery.Announcer
unifiedv1.UnimplementedUnifiedGatewayServiceServer connectorv1.UnimplementedConnectorServiceServer
} }
type payoutFailure interface { type payoutFailure interface {
@@ -97,7 +96,7 @@ 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) {
unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) connectorv1.RegisterConnectorServiceServer(reg, s)
}) })
} }

View File

@@ -34,7 +34,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/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

View File

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

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

View File

@@ -20,10 +20,10 @@ import (
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/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"
@@ -65,7 +65,7 @@ type Service struct {
pending map[string]*model.PaymentGatewayIntent pending map[string]*model.PaymentGatewayIntent
consumers []msg.Consumer consumers []msg.Consumer
unifiedv1.UnimplementedUnifiedGatewayServiceServer connectorv1.UnimplementedConnectorServiceServer
} }
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service { func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service {
@@ -89,7 +89,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
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) {
unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) connectorv1.RegisterConnectorServiceServer(reg, s)
}) })
} }

View File

@@ -2,12 +2,12 @@ package storage
import ( import (
"context" "context"
"errors"
"github.com/tech/sendico/gateway/tgsettle/storage/model" "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
) )
var ErrDuplicate = errors.New("payment gateway storage: duplicate record") var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate record")
type Repository interface { type Repository interface {
Payments() PaymentsStore Payments() PaymentsStore

View File

@@ -9,14 +9,20 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/payments/rail" "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" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/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) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error)
@@ -37,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.
@@ -82,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: unifiedv1.NewUnifiedGatewayServiceClient(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,
@@ -179,55 +183,484 @@ func (c *ledgerClient) HoldBalance(ctx context.Context, accountID string, amount
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) {

View File

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

View File

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

View File

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

View File

@@ -17,13 +17,20 @@ import (
type accountStoreStub struct { type accountStoreStub struct {
createErr error createErr error
createErrSettlement error
created []*model.Account created []*model.Account
existing *model.Account existing *model.Account
existingErr error 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()

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

View File

@@ -24,7 +24,7 @@ import (
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" "github.com/tech/sendico/pkg/mservice"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" 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"
) )
@@ -50,7 +50,6 @@ type Service struct {
cancel context.CancelFunc cancel context.CancelFunc
publisher *outboxPublisher publisher *outboxPublisher
} }
unifiedv1.UnimplementedUnifiedGatewayServiceServer
} }
type feesDependency struct { type feesDependency struct {
@@ -83,7 +82,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.
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) {
unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) connectorv1.RegisterConnectorServiceServer(reg, newConnectorAdapter(s))
}) })
} }
@@ -237,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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ package notificationimp
import ( import (
"context" "context"
"errors"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -75,7 +74,7 @@ func (m *confirmationManager) Stop() {
func (m *confirmationManager) HandleRequest(ctx context.Context, request *model.ConfirmationRequest) error { func (m *confirmationManager) HandleRequest(ctx context.Context, request *model.ConfirmationRequest) error {
if m == nil { if m == nil {
return errors.New("confirmation manager is nil") return merrors.Internal("confirmation manager is nil")
} }
if request == nil { if request == nil {
return merrors.InvalidArgument("confirmation request is nil", "request") return merrors.InvalidArgument("confirmation request is nil", "request")
@@ -338,25 +337,25 @@ var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`)
func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) { func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if text == "" { if text == "" {
return nil, "empty", errors.New("empty reply") return nil, "empty", merrors.InvalidArgument("empty reply")
} }
parts := strings.Fields(text) parts := strings.Fields(text)
if len(parts) < 2 { if len(parts) < 2 {
if len(parts) == 1 && amountPattern.MatchString(parts[0]) { if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
return nil, "missing_currency", errors.New("currency is required") return nil, "missing_currency", merrors.InvalidArgument("currency is required")
} }
return nil, "missing_amount", errors.New("amount is required") return nil, "missing_amount", merrors.InvalidArgument("amount is required")
} }
if len(parts) > 2 { if len(parts) > 2 {
return nil, "format", errors.New("reply format is invalid") return nil, "format", merrors.InvalidArgument("reply format is invalid")
} }
amount := parts[0] amount := parts[0]
currency := parts[1] currency := parts[1]
if !amountPattern.MatchString(amount) { if !amountPattern.MatchString(amount) {
return nil, "invalid_amount", errors.New("amount format is invalid") return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
} }
if !currencyPattern.MatchString(currency) { if !currencyPattern.MatchString(currency) {
return nil, "invalid_currency", errors.New("currency format is invalid") return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid")
} }
return &paymenttypes.Money{ return &paymenttypes.Money{
Amount: amount, Amount: amount,

View File

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

View File

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

View File

@@ -0,0 +1,138 @@
package params
import (
"fmt"
"google.golang.org/protobuf/types/known/structpb"
)
// Reader provides typed helpers around a Struct param payload.
type Reader struct {
raw map[string]interface{}
}
// New builds a Reader for the provided struct, tolerating nil inputs.
func New(params *structpb.Struct) Reader {
if params == nil {
return Reader{}
}
return Reader{raw: params.AsMap()}
}
// Value returns the raw value and whether it was present.
func (r Reader) Value(key string) (interface{}, bool) {
if r.raw == nil {
return nil, false
}
value, ok := r.raw[key]
return value, ok
}
// String returns the string value for a key, or "" if missing/not a string.
func (r Reader) String(key string) string {
value, ok := r.Value(key)
if !ok {
return ""
}
switch v := value.(type) {
case string:
return v
case fmt.Stringer:
return v.String()
default:
return ""
}
}
// Bool returns the bool value for a key, or false when missing/not a bool.
func (r Reader) Bool(key string) bool {
value, ok := r.Value(key)
if !ok {
return false
}
switch v := value.(type) {
case bool:
return v
default:
return false
}
}
// Int64 returns the int64 value for a key with a presence flag.
func (r Reader) Int64(key string) (int64, bool) {
value, ok := r.Value(key)
if !ok {
return 0, false
}
switch v := value.(type) {
case int64:
return v, true
case int32:
return int64(v), true
case int:
return int64(v), true
case float64:
return int64(v), true
case float32:
return int64(v), true
default:
return 0, false
}
}
// Float64 returns the float64 value for a key with a presence flag.
func (r Reader) Float64(key string) (float64, bool) {
value, ok := r.Value(key)
if !ok {
return 0, false
}
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
default:
return 0, false
}
}
// Map returns a nested object value as a map.
func (r Reader) Map(key string) map[string]interface{} {
value, ok := r.Value(key)
if !ok {
return nil
}
if m, ok := value.(map[string]interface{}); ok {
return m
}
return nil
}
// List returns a list value as a slice.
func (r Reader) List(key string) []interface{} {
value, ok := r.Value(key)
if !ok {
return nil
}
if list, ok := value.([]interface{}); ok {
return list
}
return nil
}
// StringMap converts a nested map into a string map.
func (r Reader) StringMap(key string) map[string]string {
raw := r.Map(key)
if len(raw) == 0 {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
out[k] = fmt.Sprint(v)
}
return out
}

View File

@@ -0,0 +1,11 @@
package chainassets
import (
"context"
"github.com/tech/sendico/pkg/model"
)
type DB interface {
Resolve(ctx context.Context, chainAsset model.ChainAssetKey) (*model.ChainAssetDescription, error)
}

View File

@@ -3,6 +3,7 @@ package db
import ( import (
"github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account" "github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/confirmation" "github.com/tech/sendico/pkg/db/confirmation"
mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo" mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo"
"github.com/tech/sendico/pkg/db/invitation" "github.com/tech/sendico/pkg/db/invitation"
@@ -22,6 +23,8 @@ type Factory interface {
NewRefreshTokensDB() (refreshtokens.DB, error) NewRefreshTokensDB() (refreshtokens.DB, error)
NewConfirmationsDB() (confirmation.DB, error) NewConfirmationsDB() (confirmation.DB, error)
NewChainAsstesDB() (chainassets.DB, error)
NewAccountDB() (account.DB, error) NewAccountDB() (account.DB, error)
NewOrganizationDB() (organization.DB, error) NewOrganizationDB() (organization.DB, error)
NewInvitationsDB() (invitation.DB, error) NewInvitationsDB() (invitation.DB, error)

View File

@@ -0,0 +1,73 @@
package chainassetsdb
import (
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/template"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type ChainAssetsDB struct {
template.DBImp[*model.ChainAssetDescription]
}
func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) {
p := &ChainAssetsDB{
DBImp: *template.Create[*model.ChainAssetDescription](logger, mservice.ChainAssets, db),
}
// 1) Canonical lookup: enforce single (chain, tokenSymbol)
if err := p.Repository.CreateIndex(&ri.Definition{
Name: "idx_chain_symbol",
Unique: true,
Keys: []ri.Key{
{Field: "asset.chain", Sort: ri.Asc},
{Field: "asset.tokenSymbol", Sort: ri.Asc},
},
}); err != nil {
p.Logger.Error("failed index (chain, symbol) unique", zap.Error(err))
return nil, err
}
// 2) Prevent duplicate contracts inside the same chain, but only when contract exists
if err := p.Repository.CreateIndex(&ri.Definition{
Name: "idx_chain_contract_unique",
Unique: true,
Sparse: true,
Keys: []ri.Key{
{Field: "asset.chain", Sort: ri.Asc},
{Field: "asset.contractAddress", Sort: ri.Asc},
},
}); err != nil {
p.Logger.Error("failed index (chain, contract) unique", zap.Error(err))
return nil, err
}
// 3) Fast contract lookup, skip docs without contractAddress (native assets)
if err := p.Repository.CreateIndex(&ri.Definition{
Name: "idx_contract_lookup",
Sparse: true,
Keys: []ri.Key{
{Field: "asset.contractAddress", Sort: ri.Asc},
},
}); err != nil {
p.Logger.Error("failed index contract lookup", zap.Error(err))
return nil, err
}
// 4) List assets per chain
if err := p.Repository.CreateIndex(&ri.Definition{
Name: "idx_chain_list",
Keys: []ri.Key{
{Field: "asset.chain", Sort: ri.Asc},
},
}); err != nil {
p.Logger.Error("failed index chain list", zap.Error(err))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,18 @@
package chainassetsdb
import (
"context"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/model"
)
func (db *ChainAssetsDB) Resolve(ctx context.Context, chainAsset model.ChainAssetKey) (*model.ChainAssetDescription, error) {
var assetDescription model.ChainAssetDescription
assetField := repository.Field("asset")
q := repository.Query().And(
repository.Query().Filter(assetField.Dot("chain"), chainAsset.Chain),
repository.Query().Filter(assetField.Dot("tokenSymbol"), chainAsset.TokenSymbol),
)
return &assetDescription, db.DBImp.FindOne(ctx, q, &assetDescription)
}

View File

@@ -10,8 +10,10 @@ import (
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
"github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account" "github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/confirmation" "github.com/tech/sendico/pkg/db/confirmation"
"github.com/tech/sendico/pkg/db/internal/mongo/accountdb" "github.com/tech/sendico/pkg/db/internal/mongo/accountdb"
"github.com/tech/sendico/pkg/db/internal/mongo/chainassetsdb"
"github.com/tech/sendico/pkg/db/internal/mongo/confirmationdb" "github.com/tech/sendico/pkg/db/internal/mongo/confirmationdb"
"github.com/tech/sendico/pkg/db/internal/mongo/invitationdb" "github.com/tech/sendico/pkg/db/internal/mongo/invitationdb"
"github.com/tech/sendico/pkg/db/internal/mongo/organizationdb" "github.com/tech/sendico/pkg/db/internal/mongo/organizationdb"
@@ -312,6 +314,10 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host
return hosts return hosts
} }
func (db *DB) NewChainAsstesDB() (chainassets.DB, error) {
return chainassetsdb.Create(db.logger, db.db())
}
func (db *DB) Permissions() auth.Provider { func (db *DB) Permissions() auth.Provider {
return db return db
} }

View File

@@ -44,6 +44,9 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if def.PartialFilter != nil { if def.PartialFilter != nil {
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery()) opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
} }
if def.Sparse {
opts.SetSparse(def.Sparse)
}
_, err := r.collection.Indexes().CreateOne( _, err := r.collection.Indexes().CreateOne(
context.Background(), context.Background(),

View File

@@ -18,6 +18,7 @@ type Key struct {
type Definition struct { type Definition struct {
Keys []Key // mandatory, at least one element Keys []Key // mandatory, at least one element
Unique bool // unique constraint? Unique bool // unique constraint?
Sparse bool // sparse?
TTL *int32 // seconds; nil means “no TTL” TTL *int32 // seconds; nil means “no TTL”
Name string // optional explicit name Name string // optional explicit name
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes

View File

@@ -25,9 +25,10 @@ type Announcer struct {
} }
func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, announce Announcement) *Announcer { func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, announce Announcement) *Announcer {
if logger != nil { if logger == nil {
logger = logger.Named("discovery") logger = zap.NewNop()
} }
logger = logger.Named("discovery")
announce = normalizeAnnouncement(announce) announce = normalizeAnnouncement(announce)
if announce.Service == "" { if announce.Service == "" {
announce.Service = strings.TrimSpace(sender) announce.Service = strings.TrimSpace(sender)
@@ -132,14 +133,14 @@ func (a *Announcer) sendHeartbeat() {
} }
func (a *Announcer) logInfo(message string, fields ...zap.Field) { func (a *Announcer) logInfo(message string, fields ...zap.Field) {
if a.logger == nil { if a == nil {
return return
} }
a.logger.Info(message, fields...) a.logger.Info(message, fields...)
} }
func (a *Announcer) logWarn(message string, fields ...zap.Field) { func (a *Announcer) logWarn(message string, fields ...zap.Field) {
if a.logger == nil { if a == nil {
return return
} }
a.logger.Warn(message, fields...) a.logger.Warn(message, fields...)

View File

@@ -3,7 +3,6 @@ package discovery
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"sync" "sync"
@@ -13,6 +12,7 @@ import (
cons "github.com/tech/sendico/pkg/messaging/consumer" cons "github.com/tech/sendico/pkg/messaging/consumer"
me "github.com/tech/sendico/pkg/messaging/envelope" me "github.com/tech/sendico/pkg/messaging/envelope"
msgproducer "github.com/tech/sendico/pkg/messaging/producer" msgproducer "github.com/tech/sendico/pkg/messaging/producer"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -29,11 +29,12 @@ type Client struct {
func NewClient(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, sender string) (*Client, error) { func NewClient(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, sender string) (*Client, error) {
if msgBroker == nil { if msgBroker == nil {
return nil, errors.New("discovery client: broker is nil") return nil, merrors.InvalidArgument("discovery client: broker is nil")
}
if logger == nil {
logger = zap.NewNop()
} }
if logger != nil {
logger = logger.Named("discovery_client") logger = logger.Named("discovery_client")
}
if producer == nil { if producer == nil {
producer = msgproducer.NewProducer(logger, msgBroker) producer = msgproducer.NewProducer(logger, msgBroker)
} }
@@ -56,7 +57,7 @@ func NewClient(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer
} }
go func() { go func() {
if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil && client.logger != nil { if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil {
client.logger.Warn("Discovery lookup consumer stopped", zap.String("event", LookupResponseEvent().ToString()), zap.Error(err)) client.logger.Warn("Discovery lookup consumer stopped", zap.String("event", LookupResponseEvent().ToString()), zap.Error(err))
} }
}() }()
@@ -81,7 +82,7 @@ func (c *Client) Close() {
func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) { func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) {
if c == nil || c.producer == nil { if c == nil || c.producer == nil {
return LookupResponse{}, errors.New("discovery client: producer not configured") return LookupResponse{}, merrors.Internal("discovery client: producer not configured")
} }
requestID := uuid.NewString() requestID := uuid.NewString()
ch := make(chan LookupResponse, 1) ch := make(chan LookupResponse, 1)
@@ -131,7 +132,7 @@ func (c *Client) handleLookupResponse(_ context.Context, env me.Envelope) error
} }
func (c *Client) logWarn(message string, fields ...zap.Field) { func (c *Client) logWarn(message string, fields ...zap.Field) {
if c == nil || c.logger == nil { if c == nil {
return return
} }
c.logger.Warn(message, fields...) c.logger.Warn(message, fields...)

View File

@@ -4,31 +4,63 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"strings" "strings"
"time"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap" "go.uber.org/zap"
) )
const DefaultKVBucket = "discovery_registry" const DefaultKVBucket = "discovery_registry"
type kvStoreOptions struct {
ttl time.Duration
ttlSet bool
}
type KVStoreOption func(*kvStoreOptions)
func WithKVTTL(ttl time.Duration) KVStoreOption {
return func(opts *kvStoreOptions) {
if opts == nil {
return
}
opts.ttl = ttl
opts.ttlSet = true
}
}
func newKVStoreOptions(opts ...KVStoreOption) kvStoreOptions {
var options kvStoreOptions
for _, opt := range opts {
if opt != nil {
opt(&options)
}
}
return options
}
type KVStore struct { type KVStore struct {
logger mlogger.Logger logger mlogger.Logger
kv nats.KeyValue kv nats.KeyValue
bucket string bucket string
} }
func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string) (*KVStore, error) { func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string, opts ...KVStoreOption) (*KVStore, error) {
if js == nil { if js == nil {
return nil, errors.New("discovery kv: jetstream is nil") return nil, merrors.InvalidArgument("discovery kv: jetstream is nil")
}
if logger == nil {
logger = zap.NewNop()
} }
if logger != nil {
logger = logger.Named("discovery_kv") logger = logger.Named("discovery_kv")
}
bucket = strings.TrimSpace(bucket) bucket = strings.TrimSpace(bucket)
if bucket == "" { if bucket == "" {
bucket = DefaultKVBucket bucket = DefaultKVBucket
} }
options := newKVStoreOptions(opts...)
ttl := options.ttl
kv, err := js.KeyValue(bucket) kv, err := js.KeyValue(bucket)
if err != nil { if err != nil {
if errors.Is(err, nats.ErrBucketNotFound) { if errors.Is(err, nats.ErrBucketNotFound) {
@@ -36,14 +68,21 @@ func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string)
Bucket: bucket, Bucket: bucket,
Description: "service discovery registry", Description: "service discovery registry",
History: 1, History: 1,
TTL: ttl,
}) })
if err == nil && logger != nil { if err == nil {
logger.Info("Discovery KV bucket created", zap.String("bucket", bucket)) fields := []zap.Field{zap.String("bucket", bucket)}
if options.ttlSet {
fields = append(fields, zap.Duration("ttl", ttl))
}
logger.Info("Discovery KV bucket created", fields...)
} }
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
} else if options.ttlSet {
ensureKVTTL(logger, js, kv, bucket, ttl)
} }
return &KVStore{ return &KVStore{
@@ -53,20 +92,47 @@ func NewKVStore(logger mlogger.Logger, js nats.JetStreamContext, bucket string)
}, nil }, nil
} }
func ensureKVTTL(logger mlogger.Logger, js nats.JetStreamContext, kv nats.KeyValue, bucket string, ttl time.Duration) {
if kv == nil || js == nil {
return
}
status, err := kv.Status()
if err != nil {
logger.Warn("Failed to read discovery KV status", zap.String("bucket", bucket), zap.Error(err))
return
}
if status.TTL() == ttl {
return
}
stream := "KV_" + bucket
info, err := js.StreamInfo(stream)
if err != nil {
logger.Warn("Failed to read discovery KV stream info", zap.String("bucket", bucket), zap.String("stream", stream), zap.Error(err))
return
}
cfg := info.Config
cfg.MaxAge = ttl
if _, err := js.UpdateStream(&cfg); err != nil {
logger.Warn("Failed to update discovery KV TTL", zap.String("bucket", bucket), zap.Duration("ttl", ttl), zap.Error(err))
return
}
logger.Info("Discovery KV TTL updated", zap.String("bucket", bucket), zap.Duration("ttl", ttl))
}
func (s *KVStore) Put(entry RegistryEntry) error { func (s *KVStore) Put(entry RegistryEntry) error {
if s == nil || s.kv == nil { if s == nil || s.kv == nil {
return errors.New("discovery kv: not configured") return merrors.Internal("discovery kv: not configured")
} }
key := registryEntryKey(normalizeEntry(entry)) key := registryEntryKey(normalizeEntry(entry))
if key == "" { if key == "" {
return errors.New("discovery kv: entry key is empty") return merrors.InvalidArgument("discovery kv: entry key is empty")
} }
payload, err := json.Marshal(entry) payload, err := json.Marshal(entry)
if err != nil { if err != nil {
return err return err
} }
_, err = s.kv.Put(kvKeyFromRegistryKey(key), payload) _, err = s.kv.Put(kvKeyFromRegistryKey(key), payload)
if err != nil && s.logger != nil { if err != nil {
fields := append(entryFields(entry), zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err)) fields := append(entryFields(entry), zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err))
s.logger.Warn("Failed to persist discovery entry", fields...) s.logger.Warn("Failed to persist discovery entry", fields...)
} }
@@ -75,13 +141,13 @@ func (s *KVStore) Put(entry RegistryEntry) error {
func (s *KVStore) Delete(id string) error { func (s *KVStore) Delete(id string) error {
if s == nil || s.kv == nil { if s == nil || s.kv == nil {
return errors.New("discovery kv: not configured") return merrors.Internal("discovery kv: not configured")
} }
key := kvKeyFromRegistryKey(id) key := kvKeyFromRegistryKey(id)
if key == "" { if key == "" {
return nil return nil
} }
if err := s.kv.Delete(key); err != nil && s.logger != nil { if err := s.kv.Delete(key); err != nil {
s.logger.Warn("Failed to delete discovery entry", zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err)) s.logger.Warn("Failed to delete discovery entry", zap.String("bucket", s.bucket), zap.String("key", key), zap.Error(err))
return err return err
} }
@@ -90,7 +156,7 @@ func (s *KVStore) Delete(id string) error {
func (s *KVStore) WatchAll() (nats.KeyWatcher, error) { func (s *KVStore) WatchAll() (nats.KeyWatcher, error) {
if s == nil || s.kv == nil { if s == nil || s.kv == nil {
return nil, errors.New("discovery kv: not configured") return nil, merrors.Internal("discovery kv: not configured")
} }
return s.kv.WatchAll() return s.kv.WatchAll()
} }

View File

@@ -2,9 +2,9 @@ package discovery
import ( import (
"encoding/json" "encoding/json"
"errors"
messaging "github.com/tech/sendico/pkg/messaging/envelope" messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
) )
@@ -15,7 +15,7 @@ type jsonEnvelope struct {
func (e *jsonEnvelope) Serialize() ([]byte, error) { func (e *jsonEnvelope) Serialize() ([]byte, error) {
if e.payload == nil { if e.payload == nil {
return nil, errors.New("discovery envelope payload is nil") return nil, merrors.InvalidArgument("discovery envelope payload is nil")
} }
data, err := json.Marshal(e.payload) data, err := json.Marshal(e.payload)
if err != nil { if err != nil {

View File

@@ -3,12 +3,12 @@ package discovery
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging" msg "github.com/tech/sendico/pkg/messaging"
mb "github.com/tech/sendico/pkg/messaging/broker" mb "github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer" cons "github.com/tech/sendico/pkg/messaging/consumer"
@@ -17,6 +17,17 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
type RegistryOption func(*RegistryService)
func WithRegistryKVTTL(ttl time.Duration) RegistryOption {
return func(s *RegistryService) {
if s == nil {
return
}
s.kvOptions = append(s.kvOptions, WithKVTTL(ttl))
}
}
type RegistryService struct { type RegistryService struct {
logger mlogger.Logger logger mlogger.Logger
registry *Registry registry *Registry
@@ -25,6 +36,7 @@ type RegistryService struct {
consumers []consumerHandler consumers []consumerHandler
kv *KVStore kv *KVStore
kvWatcher nats.KeyWatcher kvWatcher nats.KeyWatcher
kvOptions []KVStoreOption
startOnce sync.Once startOnce sync.Once
stopOnce sync.Once stopOnce sync.Once
@@ -36,16 +48,17 @@ type consumerHandler struct {
event string event string
} }
func NewRegistryService(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) { func NewRegistryService(logger mlogger.Logger, msgBroker mb.Broker, producer msg.Producer, registry *Registry, sender string, opts ...RegistryOption) (*RegistryService, error) {
if msgBroker == nil { if msgBroker == nil {
return nil, errors.New("discovery registry: broker is nil") return nil, merrors.InvalidArgument("discovery registry: broker is nil", "broker")
} }
if registry == nil { if registry == nil {
registry = NewRegistry() registry = NewRegistry()
} }
if logger != nil { if logger == nil {
logger = logger.Named("discovery_registry") return nil, merrors.InvalidArgument("discovery registry: no logger provided", "logger")
} }
logger = logger.Named("discovery_registry")
sender = strings.TrimSpace(sender) sender = strings.TrimSpace(sender)
if sender == "" { if sender == "" {
sender = "discovery" sender = "discovery"
@@ -74,6 +87,11 @@ func NewRegistryService(logger mlogger.Logger, msgBroker mb.Broker, producer msg
producer: producer, producer: producer,
sender: sender, sender: sender,
} }
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
svc.consumers = []consumerHandler{ svc.consumers = []consumerHandler{
{consumer: serviceConsumer, event: ServiceAnnounceEvent().ToString(), handler: func(ctx context.Context, env me.Envelope) error { {consumer: serviceConsumer, event: ServiceAnnounceEvent().ToString(), handler: func(ctx context.Context, env me.Envelope) error {
return svc.handleAnnounce(ctx, env) return svc.handleAnnounce(ctx, env)
@@ -103,7 +121,7 @@ func (s *RegistryService) Start() {
for _, ch := range s.consumers { for _, ch := range s.consumers {
ch := ch ch := ch
go func() { go func() {
if err := ch.consumer.ConsumeMessages(ch.handler); err != nil && s.logger != nil { if err := ch.consumer.ConsumeMessages(ch.handler); err != nil {
s.logger.Warn("Discovery consumer stopped with error", zap.String("event", ch.event), zap.Error(err)) s.logger.Warn("Discovery consumer stopped with error", zap.String("event", ch.event), zap.Error(err))
} }
}() }()
@@ -247,7 +265,7 @@ func (s *RegistryService) initKV(msgBroker mb.Broker) {
s.logWarn("Discovery KV disabled: JetStream not configured") s.logWarn("Discovery KV disabled: JetStream not configured")
return return
} }
store, err := NewKVStore(s.logger, js, "") store, err := NewKVStore(s.logger, js, "", s.kvOptions...)
if err != nil { if err != nil {
s.logWarn("Failed to initialise discovery KV store", zap.Error(err)) s.logWarn("Failed to initialise discovery KV store", zap.Error(err))
return return
@@ -331,21 +349,21 @@ func (s *RegistryService) persistEntry(entry RegistryEntry) {
} }
func (s *RegistryService) logWarn(message string, fields ...zap.Field) { func (s *RegistryService) logWarn(message string, fields ...zap.Field) {
if s.logger == nil { if s == nil {
return return
} }
s.logger.Warn(message, fields...) s.logger.Warn(message, fields...)
} }
func (s *RegistryService) logDebug(message string, fields ...zap.Field) { func (s *RegistryService) logDebug(message string, fields ...zap.Field) {
if s.logger == nil { if s == nil {
return return
} }
s.logger.Debug(message, fields...) s.logger.Debug(message, fields...)
} }
func (s *RegistryService) logInfo(message string, fields ...zap.Field) { func (s *RegistryService) logInfo(message string, fields ...zap.Field) {
if s.logger == nil { if s == nil {
return return
} }
s.logger.Info(message, fields...) s.logger.Info(message, fields...)

View File

@@ -2,12 +2,12 @@ package discovery
import ( import (
"encoding/json" "encoding/json"
"errors"
"sync" "sync"
"time" "time"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
mb "github.com/tech/sendico/pkg/messaging/broker" mb "github.com/tech/sendico/pkg/messaging/broker"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -23,21 +23,22 @@ type RegistryWatcher struct {
func NewRegistryWatcher(logger mlogger.Logger, msgBroker mb.Broker, registry *Registry) (*RegistryWatcher, error) { func NewRegistryWatcher(logger mlogger.Logger, msgBroker mb.Broker, registry *Registry) (*RegistryWatcher, error) {
if msgBroker == nil { if msgBroker == nil {
return nil, errors.New("discovery watcher: broker is nil") return nil, merrors.InvalidArgument("discovery watcher: broker is nil")
} }
if registry == nil { if registry == nil {
registry = NewRegistry() registry = NewRegistry()
} }
if logger != nil { if logger == nil {
logger = logger.Named("discovery_watcher") return nil, merrors.InvalidArgument("discovery logger: logger must be provided")
} }
logger = logger.Named("discovery_watcher")
provider, ok := msgBroker.(jetStreamProvider) provider, ok := msgBroker.(jetStreamProvider)
if !ok { if !ok {
return nil, errors.New("discovery watcher: jetstream not available") return nil, merrors.Internal("discovery watcher: jetstream not available")
} }
js := provider.JetStream() js := provider.JetStream()
if js == nil { if js == nil {
return nil, errors.New("discovery watcher: jetstream not configured") return nil, merrors.Internal("discovery watcher: jetstream not configured")
} }
store, err := NewKVStore(logger, js, "") store, err := NewKVStore(logger, js, "")
if err != nil { if err != nil {
@@ -53,16 +54,14 @@ func NewRegistryWatcher(logger mlogger.Logger, msgBroker mb.Broker, registry *Re
func (w *RegistryWatcher) Start() error { func (w *RegistryWatcher) Start() error {
if w == nil || w.kv == nil { if w == nil || w.kv == nil {
return errors.New("discovery watcher: not configured") return merrors.Internal("discovery watcher: not configured")
} }
watcher, err := w.kv.WatchAll() watcher, err := w.kv.WatchAll()
if err != nil { if err != nil {
return err return err
} }
w.watcher = watcher w.watcher = watcher
if w.logger != nil {
w.logger.Info("Discovery registry watcher started", zap.String("bucket", w.kv.Bucket())) w.logger.Info("Discovery registry watcher started", zap.String("bucket", w.kv.Bucket()))
}
go w.consume(watcher) go w.consume(watcher)
return nil return nil
} }
@@ -75,9 +74,7 @@ func (w *RegistryWatcher) Stop() {
if w.watcher != nil { if w.watcher != nil {
_ = w.watcher.Stop() _ = w.watcher.Stop()
} }
if w.logger != nil {
w.logger.Info("Discovery registry watcher stopped") w.logger.Info("Discovery registry watcher stopped")
}
}) })
} }
@@ -96,7 +93,7 @@ func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) {
initialCount := 0 initialCount := 0
for entry := range watcher.Updates() { for entry := range watcher.Updates() {
if entry == nil { if entry == nil {
if initial && w.logger != nil { if initial {
fields := []zap.Field{zap.Int("entries", initialCount)} fields := []zap.Field{zap.Int("entries", initialCount)}
if w.kv != nil { if w.kv != nil {
fields = append(fields, zap.String("bucket", w.kv.Bucket())) fields = append(fields, zap.String("bucket", w.kv.Bucket()))
@@ -113,7 +110,7 @@ func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) {
case nats.KeyValueDelete, nats.KeyValuePurge: case nats.KeyValueDelete, nats.KeyValuePurge:
key := registryKeyFromKVKey(entry.Key()) key := registryKeyFromKVKey(entry.Key())
if key != "" { if key != "" {
if w.registry.Delete(key) && w.logger != nil { if w.registry.Delete(key) {
w.logger.Info("Discovery registry entry removed", zap.String("key", key)) w.logger.Info("Discovery registry entry removed", zap.String("key", key))
} }
} }
@@ -125,13 +122,11 @@ func (w *RegistryWatcher) consume(watcher nats.KeyWatcher) {
var payload RegistryEntry var payload RegistryEntry
if err := json.Unmarshal(entry.Value(), &payload); err != nil { if err := json.Unmarshal(entry.Value(), &payload); err != nil {
if w.logger != nil {
w.logger.Warn("Failed to decode discovery KV entry", zap.String("key", entry.Key()), zap.Error(err)) w.logger.Warn("Failed to decode discovery KV entry", zap.String("key", entry.Key()), zap.Error(err))
}
continue continue
} }
result := w.registry.UpsertEntry(payload, time.Now()) result := w.registry.UpsertEntry(payload, time.Now())
if w.logger != nil && (result.IsNew || result.BecameHealthy) { if result.IsNew || result.BecameHealthy {
fields := append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy)) fields := append(entryFields(result.Entry), zap.Bool("is_new", result.IsNew), zap.Bool("became_healthy", result.BecameHealthy))
w.logger.Info("Discovery registry entry updated from KV", fields...) w.logger.Info("Discovery registry entry updated from KV", fields...)
} }

View File

@@ -67,7 +67,7 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect

View File

@@ -126,8 +126,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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=

View File

@@ -0,0 +1,26 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
)
type ChainAssetKey struct {
Chain ChainNetwork `bson:"chain" json:"chain" yaml:"chain" mapstructure:"chain"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol" yaml:"tokenSymbol" mapstructure:"tokenSymbol"`
}
type ChainAsset struct {
ChainAssetKey `bson:",inline" json:",inline"`
ContractAddress *string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"`
}
type ChainAssetDescription struct {
storable.Storable `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Asset ChainAsset `bson:"asset" json:"asset"`
}
func Collection(*ChainAssetDescription) mservice.Type {
return mservice.ChainAssets
}

11
api/pkg/model/chains.go Normal file
View File

@@ -0,0 +1,11 @@
package model
type ChainNetwork string
const (
ChainNetworkARB ChainNetwork = "arbitrum_one"
ChainNetworkEthMain ChainNetwork = "ethereum_mainnet"
ChainNetworkTronMain ChainNetwork = "tron_mainnet"
ChainNetworkTronNile ChainNetwork = "tron_nile"
ChainNetworkUnspecified ChainNetwork = "unspecified"
)

View File

@@ -29,6 +29,7 @@ const (
LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice
LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice
PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice
ChainAssets Type = "chain_assets" // Represents managed chain assets
ChainWallets Type = "chain_wallets" // Represents managed chain wallets ChainWallets Type = "chain_wallets" // Represents managed chain wallets
ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances
ChainTransfers Type = "chain_transfers" // Represents chain transfers ChainTransfers Type = "chain_transfers" // Represents chain transfers

View File

@@ -0,0 +1,259 @@
syntax = "proto3";
package connector.v1;
option go_package = "github.com/tech/sendico/pkg/proto/connector/v1;connectorv1";
import "google/protobuf/struct.proto";
import "google/protobuf/timestamp.proto";
import "common/describable/v1/describable.proto";
import "common/money/v1/money.proto";
import "common/pagination/v1/cursor.proto";
// ConnectorService exposes capability-driven account and operation primitives.
service ConnectorService {
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
rpc OpenAccount(OpenAccountRequest) returns (OpenAccountResponse);
rpc GetAccount(GetAccountRequest) returns (GetAccountResponse);
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse);
rpc SubmitOperation(SubmitOperationRequest) returns (SubmitOperationResponse);
rpc GetOperation(GetOperationRequest) returns (GetOperationResponse);
rpc ListOperations(ListOperationsRequest) returns (ListOperationsResponse);
}
enum AccountKind {
ACCOUNT_KIND_UNSPECIFIED = 0;
LEDGER_ACCOUNT = 1;
CHAIN_MANAGED_WALLET = 2;
EXTERNAL_REF = 3;
}
enum AccountState {
ACCOUNT_STATE_UNSPECIFIED = 0;
ACCOUNT_ACTIVE = 1;
ACCOUNT_SUSPENDED = 2;
ACCOUNT_CLOSED = 3;
}
enum OperationType {
OPERATION_TYPE_UNSPECIFIED = 0;
CREDIT = 1;
DEBIT = 2;
TRANSFER = 3;
PAYOUT = 4;
FEE_ESTIMATE = 5;
FX = 6;
GAS_TOPUP = 7;
}
enum OperationStatus {
OPERATION_STATUS_UNSPECIFIED = 0;
SUBMITTED = 1;
PENDING = 2;
CONFIRMED = 3;
FAILED = 4;
CANCELED = 5;
}
enum ParamType {
PARAM_TYPE_UNSPECIFIED = 0;
STRING = 1;
INT = 2;
BOOL = 3;
DECIMAL_STRING = 4;
JSON = 5;
}
enum ErrorCode {
ERROR_CODE_UNSPECIFIED = 0;
UNSUPPORTED_OPERATION = 1;
UNSUPPORTED_ACCOUNT_KIND = 2;
INVALID_PARAMS = 3;
INSUFFICIENT_FUNDS = 4;
NOT_FOUND = 5;
TEMPORARY_UNAVAILABLE = 6;
RATE_LIMITED = 7;
PROVIDER_ERROR = 8;
}
message ParamSpec {
string key = 1;
ParamType type = 2;
bool required = 3;
string description = 4;
repeated string allowed_values = 5;
google.protobuf.Struct example = 6;
}
message OperationParamSpec {
OperationType operation_type = 1;
repeated ParamSpec params = 2;
}
message ConnectorCapabilities {
string connector_type = 1;
string version = 2;
repeated AccountKind supported_account_kinds = 3;
repeated OperationType supported_operation_types = 4;
repeated string supported_assets = 5; // canonical asset string (USD, ETH, USDT-TRC20)
repeated string supported_networks = 6; // optional, connector-defined names
repeated ParamSpec open_account_params = 7;
repeated OperationParamSpec operation_params = 8;
map<string, string> metadata = 9;
}
message AccountRef {
string connector_id = 1;
string account_id = 2;
}
message ExternalRef {
string external_ref = 1;
google.protobuf.Struct details = 2;
}
message OperationParty {
oneof ref {
AccountRef account = 1;
ExternalRef external = 2;
}
}
message Account {
AccountRef ref = 1;
AccountKind kind = 2;
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
AccountState state = 4;
string label = 5;
string owner_ref = 6; // optional account_ref; empty means organization-owned
google.protobuf.Struct provider_details = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
common.describable.v1.Describable describable = 10;
}
message Balance {
AccountRef account_ref = 1;
common.money.v1.Money available = 2;
common.money.v1.Money pending_inbound = 3;
common.money.v1.Money pending_outbound = 4;
google.protobuf.Timestamp calculated_at = 5;
}
message ConnectorError {
ErrorCode code = 1;
string message = 2;
google.protobuf.Struct details = 3;
string correlation_id = 4;
string parent_intent_id = 5;
string operation_id = 6;
string account_id = 7;
}
message Operation {
string operation_id = 1;
OperationType type = 2;
OperationParty from = 3;
OperationParty to = 4;
common.money.v1.Money money = 5;
string idempotency_key = 6;
google.protobuf.Struct params = 7;
string correlation_id = 8;
string parent_intent_id = 9;
OperationStatus status = 10;
string provider_ref = 11;
google.protobuf.Timestamp created_at = 12;
google.protobuf.Timestamp updated_at = 13;
}
message OperationReceipt {
string operation_id = 1;
OperationStatus status = 2;
string provider_ref = 3;
ConnectorError error = 4;
google.protobuf.Struct result = 5; // connector-specific output payload
}
message GetCapabilitiesRequest {}
message GetCapabilitiesResponse {
ConnectorCapabilities capabilities = 1;
}
message OpenAccountRequest {
string idempotency_key = 1;
AccountKind kind = 2;
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
string label = 4;
string owner_ref = 5; // optional account_ref; empty means organization-owned
google.protobuf.Struct params = 6;
string correlation_id = 7;
string parent_intent_id = 8;
}
message OpenAccountResponse {
Account account = 1;
ConnectorError error = 2;
}
message GetAccountRequest {
AccountRef account_ref = 1;
}
message GetAccountResponse {
Account account = 1;
}
message ListAccountsRequest {
string owner_ref = 1;
AccountKind kind = 2;
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
common.pagination.v1.CursorPageRequest page = 4;
string organization_ref = 5; // optional org scope (preferred over owner_ref)
}
message ListAccountsResponse {
repeated Account accounts = 1;
common.pagination.v1.CursorPageResponse page = 2;
}
message GetBalanceRequest {
AccountRef account_ref = 1;
}
message GetBalanceResponse {
Balance balance = 1;
}
message SubmitOperationRequest {
Operation operation = 1;
}
message SubmitOperationResponse {
OperationReceipt receipt = 1;
}
message GetOperationRequest {
string operation_id = 1;
}
message GetOperationResponse {
Operation operation = 1;
}
message ListOperationsRequest {
AccountRef account_ref = 1;
OperationType type = 2;
OperationStatus status = 3;
string correlation_id = 4;
string parent_intent_id = 5;
common.pagination.v1.CursorPageRequest page = 6;
}
message ListOperationsResponse {
repeated Operation operations = 1;
common.pagination.v1.CursorPageResponse page = 2;
}

View File

@@ -1,45 +0,0 @@
syntax = "proto3";
package gateway.unified.v1;
option go_package = "github.com/tech/sendico/pkg/proto/gateway/unified/v1;unifiedv1";
import "gateway/chain/v1/chain.proto";
import "gateway/mntx/v1/mntx.proto";
import "ledger/v1/ledger.proto";
// UnifiedGatewayService exposes gateway and ledger operations via a single interface.
service UnifiedGatewayService {
// Chain gateway operations.
rpc CreateManagedWallet(chain.gateway.v1.CreateManagedWalletRequest) returns (chain.gateway.v1.CreateManagedWalletResponse);
rpc GetManagedWallet(chain.gateway.v1.GetManagedWalletRequest) returns (chain.gateway.v1.GetManagedWalletResponse);
rpc ListManagedWallets(chain.gateway.v1.ListManagedWalletsRequest) returns (chain.gateway.v1.ListManagedWalletsResponse);
rpc GetWalletBalance(chain.gateway.v1.GetWalletBalanceRequest) returns (chain.gateway.v1.GetWalletBalanceResponse);
rpc SubmitTransfer(chain.gateway.v1.SubmitTransferRequest) returns (chain.gateway.v1.SubmitTransferResponse);
rpc GetTransfer(chain.gateway.v1.GetTransferRequest) returns (chain.gateway.v1.GetTransferResponse);
rpc ListTransfers(chain.gateway.v1.ListTransfersRequest) returns (chain.gateway.v1.ListTransfersResponse);
rpc EstimateTransferFee(chain.gateway.v1.EstimateTransferFeeRequest) returns (chain.gateway.v1.EstimateTransferFeeResponse);
rpc ComputeGasTopUp(chain.gateway.v1.ComputeGasTopUpRequest) returns (chain.gateway.v1.ComputeGasTopUpResponse);
rpc EnsureGasTopUp(chain.gateway.v1.EnsureGasTopUpRequest) returns (chain.gateway.v1.EnsureGasTopUpResponse);
// Card payout gateway operations.
rpc CreateCardPayout(mntx.gateway.v1.CardPayoutRequest) returns (mntx.gateway.v1.CardPayoutResponse);
rpc GetCardPayoutStatus(mntx.gateway.v1.GetCardPayoutStatusRequest) returns (mntx.gateway.v1.GetCardPayoutStatusResponse);
rpc CreateCardTokenPayout(mntx.gateway.v1.CardTokenPayoutRequest) returns (mntx.gateway.v1.CardTokenPayoutResponse);
rpc CreateCardToken(mntx.gateway.v1.CardTokenizeRequest) returns (mntx.gateway.v1.CardTokenizeResponse);
rpc ListGatewayInstances(mntx.gateway.v1.ListGatewayInstancesRequest) returns (mntx.gateway.v1.ListGatewayInstancesResponse);
// Ledger operations.
rpc CreateAccount(ledger.v1.CreateAccountRequest) returns (ledger.v1.CreateAccountResponse);
rpc ListAccounts(ledger.v1.ListAccountsRequest) returns (ledger.v1.ListAccountsResponse);
rpc PostCreditWithCharges(ledger.v1.PostCreditRequest) returns (ledger.v1.PostResponse);
rpc PostDebitWithCharges(ledger.v1.PostDebitRequest) returns (ledger.v1.PostResponse);
rpc TransferInternal(ledger.v1.TransferRequest) returns (ledger.v1.PostResponse);
rpc ApplyFXWithCharges(ledger.v1.FXRequest) returns (ledger.v1.PostResponse);
rpc GetBalance(ledger.v1.GetBalanceRequest) returns (ledger.v1.BalanceResponse);
rpc GetJournalEntry(ledger.v1.GetEntryRequest) returns (ledger.v1.JournalEntryResponse);
rpc GetStatement(ledger.v1.GetStatementRequest) returns (ledger.v1.StatementResponse);
}

View File

@@ -5,6 +5,7 @@ package ledger.v1;
option go_package = "github.com/tech/sendico/pkg/proto/ledger/v1;ledgerv1"; option go_package = "github.com/tech/sendico/pkg/proto/ledger/v1;ledgerv1";
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "common/describable/v1/describable.proto";
import "common/money/v1/money.proto"; import "common/money/v1/money.proto";
// ===== Enums ===== // ===== Enums =====
@@ -55,6 +56,7 @@ message LedgerAccount {
map<string, string> metadata = 9; map<string, string> metadata = 9;
google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11; google.protobuf.Timestamp updated_at = 11;
common.describable.v1.Describable describable = 12;
} }
// A single posting line (mirrors your PostingLine model) // A single posting line (mirrors your PostingLine model)
@@ -68,13 +70,15 @@ message PostingLine {
message CreateAccountRequest { message CreateAccountRequest {
string organization_ref = 1; string organization_ref = 1;
string account_code = 2; string owner_ref = 2;
AccountType account_type = 3; string account_code = 3;
string currency = 4; AccountType account_type = 4;
AccountStatus status = 5; string currency = 5;
bool allow_negative = 6; AccountStatus status = 6;
bool is_settlement = 7; bool allow_negative = 7;
map<string, string> metadata = 8; bool is_settlement = 8;
map<string, string> metadata = 9;
common.describable.v1.Describable describable = 10;
} }
message CreateAccountResponse { message CreateAccountResponse {

View File

@@ -113,7 +113,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect

View File

@@ -198,8 +198,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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=

View File

@@ -0,0 +1,51 @@
package srequest
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
type LedgerAccountType string
const (
LedgerAccountTypeUnspecified LedgerAccountType = "unspecified"
LedgerAccountTypeAsset LedgerAccountType = "asset"
LedgerAccountTypeLiability LedgerAccountType = "liability"
LedgerAccountTypeRevenue LedgerAccountType = "revenue"
LedgerAccountTypeExpense LedgerAccountType = "expense"
)
type LedgerAccountStatus string
const (
LedgerAccountStatusUnspecified LedgerAccountStatus = "unspecified"
LedgerAccountStatusActive LedgerAccountStatus = "active"
LedgerAccountStatusFrozen LedgerAccountStatus = "frozen"
)
type CreateLedgerAccount struct {
AccountCode string `json:"accountCode"`
AccountType LedgerAccountType `json:"accountType"`
Currency string `json:"currency"`
Status LedgerAccountStatus `json:"status,omitempty"`
AllowNegative bool `json:"allowNegative,omitempty"`
IsSettlement bool `json:"isSettlement,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Describable model.Describable `json:"describable"`
IsOrgWallet bool `json:"isOrgWallet"`
}
func (r *CreateLedgerAccount) Validate() error {
if strings.TrimSpace(r.AccountCode) == "" {
return merrors.InvalidArgument("accountCode is required", "accountCode")
}
if strings.TrimSpace(r.Currency) == "" {
return merrors.InvalidArgument("currency is required", "currency")
}
if strings.TrimSpace(string(r.AccountType)) == "" || strings.EqualFold(string(r.AccountType), string(LedgerAccountTypeUnspecified)) {
return merrors.InvalidArgument("accountType is required", "accountType")
}
return nil
}

View File

@@ -12,6 +12,8 @@ type Signup struct {
Organization model.Describable `json:"organization"` Organization model.Describable `json:"organization"`
OrganizationTimeZone string `json:"organizationTimeZone"` OrganizationTimeZone string `json:"organizationTimeZone"`
OwnerRole model.Describable `json:"ownerRole"` OwnerRole model.Describable `json:"ownerRole"`
CryptoWallet model.Describable `json:"cryptoWallet"`
LedgerWallet model.Describable `json:"ledgerWallet"`
} }
// UnmarshalJSON enforces strict parsing to catch malformed or unexpected fields. // UnmarshalJSON enforces strict parsing to catch malformed or unexpected fields.

View File

@@ -0,0 +1,9 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type CreateWallet struct {
Description model.Describable `json:"description"`
IsOrgWallet bool `json:"isOrgWallet"`
Asset model.ChainAssetKey `json:"asset"`
}

View File

@@ -29,6 +29,11 @@ type ledgerAccountsResponse struct {
Accounts []ledgerAccount `json:"accounts"` Accounts []ledgerAccount `json:"accounts"`
} }
type ledgerAccountResponse struct {
authResponse `json:",inline"`
Account ledgerAccount `json:"account"`
}
type ledgerMoney struct { type ledgerMoney struct {
Amount string `json:"amount"` Amount string `json:"amount"`
Currency string `json:"currency"` Currency string `json:"currency"`
@@ -57,6 +62,13 @@ func LedgerAccounts(logger mlogger.Logger, accounts []*ledgerv1.LedgerAccount, a
}) })
} }
func LedgerAccountCreated(logger mlogger.Logger, account *ledgerv1.LedgerAccount, accessToken *TokenData) http.HandlerFunc {
return response.Created(logger, ledgerAccountResponse{
Account: toLedgerAccount(account),
authResponse: authResponse{AccessToken: *accessToken},
})
}
func LedgerBalance(logger mlogger.Logger, resp *ledgerv1.BalanceResponse, accessToken *TokenData) http.HandlerFunc { func LedgerBalance(logger mlogger.Logger, resp *ledgerv1.BalanceResponse, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, ledgerBalanceResponse{ return response.Ok(logger, ledgerBalanceResponse{
Balance: toLedgerBalance(resp), Balance: toLedgerBalance(resp),

View File

@@ -0,0 +1,45 @@
package proto
import (
"fmt"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
func Network2Proto(network model.ChainNetwork) (chainv1.ChainNetwork, error) {
switch network {
case model.ChainNetworkARB:
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case model.ChainNetworkEthMain:
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
case model.ChainNetworkTronMain:
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
case model.ChainNetworkTronNile:
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
case model.ChainNetworkUnspecified:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("Unkwnown chain network value '%s'", network), "network")
}
}
func Asset2Proto(asset *model.ChainAsset) (*chainv1.Asset, error) {
if asset == nil {
return nil, merrors.InvalidArgument("Asset must be provided", "asset")
}
netw, err := Network2Proto(asset.Chain)
if err != nil {
return nil, err
}
var contract string
if asset.ContractAddress != nil {
contract = *asset.ContractAddress
}
return &chainv1.Asset{
Chain: netw,
TokenSymbol: asset.TokenSymbol,
ContractAddress: contract,
}, nil
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap" "github.com/tech/sendico/pkg/mutil/mzap"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse" "github.com/tech/sendico/server/interface/api/sresponse"
@@ -69,7 +70,7 @@ func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
var sr srequest.Signup var sr srequest.Signup
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil { if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
a.logger.Warn("Failed to decode signup request", zap.Error(err)) a.logger.Warn("Failed to decode signup request", zap.Error(err))
return response.BadRequest(a.logger, a.Name(), "", err.Error()) return response.BadPayload(a.logger, a.Name(), err)
} }
sr.Account.Login = strings.ToLower(strings.TrimSpace(sr.Account.Login)) sr.Account.Login = strings.ToLower(strings.TrimSpace(sr.Account.Login))
@@ -251,7 +252,10 @@ func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization,
req := &chainv1.CreateManagedWalletRequest{ req := &chainv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(), IdempotencyKey: uuid.NewString(),
OrganizationRef: org.ID.Hex(), OrganizationRef: org.ID.Hex(),
OwnerRef: org.ID.Hex(), Describable: &describablev1.Describable{
Name: sr.CryptoWallet.Name,
Description: sr.CryptoWallet.Description,
},
Asset: a.chainAsset, Asset: a.chainAsset,
Metadata: map[string]string{ Metadata: map[string]string{
"source": "signup", "source": "signup",

View File

@@ -15,14 +15,20 @@ import (
) )
var ( var (
errConfirmationNotFound = errors.New("confirmation not found or expired") errConfirmationNotFound confirmationError = "confirmation not found or expired"
errConfirmationUsed = errors.New("confirmation already used") errConfirmationUsed confirmationError = "confirmation already used"
errConfirmationMismatch = errors.New("confirmation code mismatch") errConfirmationMismatch confirmationError = "confirmation code mismatch"
errConfirmationAttemptsExceeded = errors.New("confirmation attempts exceeded") errConfirmationAttemptsExceeded confirmationError = "confirmation attempts exceeded"
errConfirmationCooldown = errors.New("confirmation cooldown active") errConfirmationCooldown confirmationError = "confirmation cooldown active"
errConfirmationResendLimit = errors.New("confirmation resend limit reached") errConfirmationResendLimit confirmationError = "confirmation resend limit reached"
) )
type confirmationError string
func (e confirmationError) Error() string {
return string(e)
}
type ConfirmationStore struct { type ConfirmationStore struct {
db confirmation.DB db confirmation.DB
} }

View File

@@ -1,11 +1,11 @@
package ledgerapiimp package ledgerapiimp
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
@@ -24,7 +24,7 @@ func (a *LedgerAPI) getBalance(r *http.Request, account *model.Account, token *s
accountRef := strings.TrimSpace(a.aph.GetID(r)) accountRef := strings.TrimSpace(a.aph.GetID(r))
if accountRef == "" { if accountRef == "" {
return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), errors.New("ledger account reference is required")) return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), merrors.InvalidArgument("ledger account reference is required"))
} }
ctx := r.Context() ctx := r.Context()
@@ -38,7 +38,7 @@ func (a *LedgerAPI) getBalance(r *http.Request, account *model.Account, token *s
return response.AccessDenied(a.logger, a.Name(), "ledger balance read permission denied") return response.AccessDenied(a.logger, a.Name(), "ledger balance read permission denied")
} }
if a.client == nil { if a.client == nil {
return response.Internal(a.logger, mservice.Ledger, errors.New("ledger client is not configured")) return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
} }
resp, err := a.client.GetBalance(ctx, &ledgerv1.GetBalanceRequest{ resp, err := a.client.GetBalance(ctx, &ledgerv1.GetBalanceRequest{

View File

@@ -0,0 +1,153 @@
package ledgerapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for ledger account create", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check ledger accounts access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when creating ledger account", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "ledger accounts write permission denied")
}
payload, err := decodeLedgerAccountCreatePayload(r)
if err != nil {
a.logger.Warn("Failed to decode ledger account create payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
accountType, err := mapLedgerAccountType(payload.AccountType)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
status, err := mapLedgerAccountStatus(payload.Status)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
if a.client == nil {
return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
}
var describable *describablev1.Describable
name := strings.TrimSpace(payload.Describable.Name)
var description *string
if payload.Describable.Description != nil {
trimmed := strings.TrimSpace(*payload.Describable.Description)
if trimmed != "" {
description = &trimmed
}
}
if name != "" || description != nil {
describable = &describablev1.Describable{
Name: name,
Description: description,
}
}
var ownerRef string
if !payload.IsOrgWallet {
ownerRef = account.ID.Hex()
}
resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
OwnerRef: ownerRef,
AccountCode: payload.AccountCode,
AccountType: accountType,
Currency: payload.Currency,
Status: status,
AllowNegative: payload.AllowNegative,
IsSettlement: payload.IsSettlement,
Metadata: payload.Metadata,
Describable: describable,
})
if err != nil {
a.logger.Warn("Failed to create ledger account", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return response.Auto(a.logger, mservice.Ledger, err)
}
return sresponse.LedgerAccountCreated(a.logger, resp.GetAccount(), token)
}
func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAccount, error) {
defer r.Body.Close()
payload := srequest.CreateLedgerAccount{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.AccountCode = strings.TrimSpace(payload.AccountCode)
payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency))
payload.Describable.Name = strings.TrimSpace(payload.Describable.Name)
if payload.Describable.Description != nil {
trimmed := strings.TrimSpace(*payload.Describable.Description)
if trimmed == "" {
payload.Describable.Description = nil
} else {
payload.Describable.Description = &trimmed
}
}
if len(payload.Metadata) == 0 {
payload.Metadata = nil
}
if err := payload.Validate(); err != nil {
return nil, err
}
return &payload, nil
}
func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) {
switch strings.ToUpper(strings.TrimSpace(string(accountType))) {
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
case "", "ACCOUNT_TYPE_UNSPECIFIED", "UNSPECIFIED":
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("accountType is required", "accountType")
default:
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("unsupported accountType: "+string(accountType), "accountType")
}
}
func mapLedgerAccountStatus(status srequest.LedgerAccountStatus) (ledgerv1.AccountStatus, error) {
switch strings.ToUpper(strings.TrimSpace(string(status))) {
case "", "ACCOUNT_STATUS_UNSPECIFIED", "UNSPECIFIED":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, nil
case "ACCOUNT_STATUS_ACTIVE", "ACTIVE":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, nil
case "ACCOUNT_STATUS_FROZEN", "FROZEN":
return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN, nil
default:
return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED, merrors.InvalidArgument("unsupported status: "+string(status), "status")
}
}

View File

@@ -1,10 +1,10 @@
package ledgerapiimp package ledgerapiimp
import ( import (
"errors"
"net/http" "net/http"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
@@ -32,7 +32,7 @@ func (a *LedgerAPI) listAccounts(r *http.Request, account *model.Account, token
return response.AccessDenied(a.logger, a.Name(), "ledger accounts read permission denied") return response.AccessDenied(a.logger, a.Name(), "ledger accounts read permission denied")
} }
if a.client == nil { if a.client == nil {
return response.Internal(a.logger, mservice.Ledger, errors.New("ledger client is not configured")) return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
} }
resp, err := a.client.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{ resp, err := a.client.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{

View File

@@ -21,6 +21,7 @@ import (
) )
type ledgerClient interface { type ledgerClient interface {
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)
GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error)
Close() error Close() error
@@ -75,6 +76,7 @@ func CreateAPI(apiCtx eapi.API) (*LedgerAPI, error) {
} }
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listAccounts) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listAccounts)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Post, p.createAccount)
apiCtx.Register().AccountHandler(p.Name(), p.aph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getBalance) apiCtx.Register().AccountHandler(p.Name(), p.aph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getBalance)
return p, nil return p, nil

View File

@@ -3,13 +3,13 @@ package paymentapiimp
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"time" "time"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/discovery"
me "github.com/tech/sendico/pkg/messaging/envelope" me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse" "github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param" mutil "github.com/tech/sendico/server/internal/mutil/param"
@@ -21,7 +21,7 @@ const discoveryLookupTimeout = 3 * time.Second
func (a *PaymentAPI) listDiscoveryRegistry(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc { func (a *PaymentAPI) listDiscoveryRegistry(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
if a.discovery == nil { if a.discovery == nil {
return response.Internal(a.logger, a.Name(), errors.New("discovery client is not configured")) return response.Internal(a.logger, a.Name(), merrors.Internal("discovery client is not configured"))
} }
orgRef, err := a.oph.GetRef(r) orgRef, err := a.oph.GetRef(r)
@@ -55,7 +55,7 @@ func (a *PaymentAPI) listDiscoveryRegistry(r *http.Request, account *model.Accou
func (a *PaymentAPI) getDiscoveryRefresh(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc { func (a *PaymentAPI) getDiscoveryRefresh(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
if a.refreshConsumer == nil { if a.refreshConsumer == nil {
return response.Internal(a.logger, a.Name(), errors.New("discovery refresh consumer is not configured")) return response.Internal(a.logger, a.Name(), merrors.Internal("discovery refresh consumer is not configured"))
} }
orgRef, err := a.oph.GetRef(r) orgRef, err := a.oph.GetRef(r)

View File

@@ -1,11 +1,11 @@
package walletapiimp package walletapiimp
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -23,7 +23,7 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
} }
walletRef := strings.TrimSpace(a.wph.GetID(r)) walletRef := strings.TrimSpace(a.wph.GetID(r))
if walletRef == "" { if walletRef == "" {
return response.BadReference(a.logger, a.Name(), a.wph.Name(), a.wph.GetID(r), errors.New("wallet reference is required")) return response.BadReference(a.logger, a.Name(), a.wph.Name(), a.wph.GetID(r), merrors.InvalidArgument("wallet reference is required"))
} }
ctx := r.Context() ctx := r.Context()
@@ -37,7 +37,7 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
return response.AccessDenied(a.logger, a.Name(), "wallet balance read permission denied") return response.AccessDenied(a.logger, a.Name(), "wallet balance read permission denied")
} }
if a.chainGateway == nil { if a.chainGateway == nil {
return response.Internal(a.logger, mservice.ChainGateway, errors.New("chain gateway client is not configured")) return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured"))
} }
resp, err := a.chainGateway.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: walletRef}) resp, err := a.chainGateway.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: walletRef})
@@ -49,7 +49,7 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
bal := resp.GetBalance() bal := resp.GetBalance()
if bal == nil { if bal == nil {
a.logger.Warn("Wallet balance missing in response", zap.String("wallet_ref", walletRef)) a.logger.Warn("Wallet balance missing in response", zap.String("wallet_ref", walletRef))
return response.Auto(a.logger, mservice.ChainGateway, errors.New("wallet balance not available")) return response.Auto(a.logger, mservice.ChainGateway, merrors.Internal("wallet balance not available"))
} }
return sresponse.WalletBalance(a.logger, bal, token) return sresponse.WalletBalance(a.logger, bal, token)

View File

@@ -0,0 +1,98 @@
package walletapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
ast "github.com/tech/sendico/server/internal/mutil/proto"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for wallet list", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
var sr srequest.CreateWallet
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
a.logger.Warn("Failed to decode wallet creation request request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.logger, a.Name(), err)
}
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), mutil.PLog(a.oph, r), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when listing organization wallets", mutil.PLog(a.oph, r), mzap.StorableRef(account))
return response.AccessDenied(a.logger, a.Name(), "wallets creation permission denied")
}
asset, err := a.assets.Resolve(ctx, sr.Asset)
if err != nil {
a.logger.Warn("Failed to resolve asset", zap.Error(err), mzap.StorableRef(account),
zap.String("chain", string(sr.Asset.Chain)), zap.String("token", sr.Asset.TokenSymbol))
return response.Auto(a.logger, a.Name(), err)
}
if a.chainGateway == nil {
return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured"))
}
var ownerRef string
if !sr.IsOrgWallet {
ownerRef = account.ID.Hex()
}
passet, err := ast.Asset2Proto(&asset.Asset)
if err != nil {
a.logger.Warn("Failed to convert asset to proto asset", zap.Error(err),
mzap.StorableRef(asset), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
req := &chainv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(),
OrganizationRef: orgRef.Hex(),
OwnerRef: ownerRef,
Describable: &describablev1.Describable{
Name: sr.Description.Name,
Description: sr.Description.Description,
},
Asset: passet,
Metadata: map[string]string{
"source": "create",
"login": account.Login,
},
}
resp, err := a.chainGateway.CreateManagedWallet(ctx, req)
if err != nil {
a.logger.Warn("Failed to create managed wallet", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
if resp == nil || resp.Wallet == nil || strings.TrimSpace(resp.Wallet.WalletRef) == "" {
return response.Auto(a.logger, a.Name(), merrors.Internal("chain gateway returned empty wallet reference"))
}
a.logger.Info("Managed wallet created for organization", mzap.ObjRef("organization_ref", orgRef),
zap.String("wallet_ref", resp.Wallet.WalletRef), mzap.StorableRef(account))
return sresponse.Success(a.logger, token)
}

View File

@@ -1,11 +1,11 @@
package walletapiimp package walletapiimp
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -33,7 +33,7 @@ func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token *
return response.AccessDenied(a.logger, a.Name(), "wallets read permission denied") return response.AccessDenied(a.logger, a.Name(), "wallets read permission denied")
} }
if a.chainGateway == nil { if a.chainGateway == nil {
return response.Internal(a.logger, mservice.ChainGateway, errors.New("chain gateway client is not configured")) return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured"))
} }
req := &chainv1.ListManagedWalletsRequest{ req := &chainv1.ListManagedWalletsRequest{

View File

@@ -10,6 +10,7 @@ import (
chaingatewayclient "github.com/tech/sendico/gateway/chain/client" chaingatewayclient "github.com/tech/sendico/gateway/chain/client"
api "github.com/tech/sendico/pkg/api/http" api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
@@ -28,9 +29,11 @@ type WalletAPI struct {
wph mutil.ParamHelper wph mutil.ParamHelper
walletsPermissionRef primitive.ObjectID walletsPermissionRef primitive.ObjectID
balancesPermissionRef primitive.ObjectID balancesPermissionRef primitive.ObjectID
assets chainassets.DB
} }
type chainWalletClient interface { type chainWalletClient interface {
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error)
Close() error Close() error
@@ -55,6 +58,12 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) {
wph: mutil.CreatePH(mservice.Wallets), wph: mutil.CreatePH(mservice.Wallets),
} }
var err error
if p.assets, err = apiCtx.DBFactory().NewChainAsstesDB(); err != nil {
p.logger.Warn("Failed to create asstes db", zap.Error(err))
return nil, err
}
walletsPolicy, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.ChainWallets) walletsPolicy, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.ChainWallets)
if err != nil { if err != nil {
p.logger.Warn("Failed to fetch chain wallets permission policy description", zap.Error(err)) p.logger.Warn("Failed to fetch chain wallets permission policy description", zap.Error(err))
@@ -81,6 +90,7 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) {
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listWallets) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listWallets)
apiCtx.Register().AccountHandler(p.Name(), p.wph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getWalletBalance) apiCtx.Register().AccountHandler(p.Name(), p.wph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getWalletBalance)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Post, p.create)
return p, nil return p, nil
} }

View File

@@ -51,7 +51,7 @@ NATS_COMPOSE_PROJECT=sendico-nats
DISCOVERY_DIR=discovery DISCOVERY_DIR=discovery
DISCOVERY_COMPOSE_PROJECT=sendico-discovery DISCOVERY_COMPOSE_PROJECT=sendico-discovery
DISCOVERY_SERVICE_NAME=sendico_discovery DISCOVERY_SERVICE_NAME=sendico_discovery
DISCOVERY_METRICS_PORT=9405 DISCOVERY_METRICS_PORT=9407
# Shared Mongo settings for FX services # Shared Mongo settings for FX services

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
BFF_ENV_NAME="${BFF_ENV:-prod}" BFF_ENV_NAME="${BFF_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${BFF_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${BFF_ENV_NAME}/.env.runtime"
@@ -48,17 +50,13 @@ load_env_file ./.env.version
BFF_MONGO_SECRET_PATH="${BFF_MONGO_SECRET_PATH:?missing BFF_MONGO_SECRET_PATH}" BFF_MONGO_SECRET_PATH="${BFF_MONGO_SECRET_PATH:?missing BFF_MONGO_SECRET_PATH}"
BFF_API_SECRET_PATH="${BFF_API_SECRET_PATH:?missing BFF_API_SECRET_PATH}" BFF_API_SECRET_PATH="${BFF_API_SECRET_PATH:?missing BFF_API_SECRET_PATH}"
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export MONGO_USER="$(./ci/vlt kv_get kv "${BFF_MONGO_SECRET_PATH}" user)" export MONGO_USER="$(./ci/vlt kv_get kv "${BFF_MONGO_SECRET_PATH}" user)"
export MONGO_PASSWORD="$(./ci/vlt kv_get kv "${BFF_MONGO_SECRET_PATH}" password)" export MONGO_PASSWORD="$(./ci/vlt kv_get kv "${BFF_MONGO_SECRET_PATH}" password)"
export API_ENDPOINT_SECRET="$(./ci/vlt kv_get kv "${BFF_API_SECRET_PATH}" secret)" export API_ENDPOINT_SECRET="$(./ci/vlt kv_get kv "${BFF_API_SECRET_PATH}" secret)"
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/bff.sh bash ci/prod/scripts/deploy/bff.sh

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
FEES_ENV_NAME="${FEES_ENV:-prod}" FEES_ENV_NAME="${FEES_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${FEES_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${FEES_ENV_NAME}/.env.runtime"
@@ -47,15 +49,11 @@ load_env_file "${RUNTIME_ENV_FILE}"
load_env_file ./.env.version load_env_file ./.env.version
FEES_MONGO_SECRET_PATH="${FEES_MONGO_SECRET_PATH:?missing FEES_MONGO_SECRET_PATH}" FEES_MONGO_SECRET_PATH="${FEES_MONGO_SECRET_PATH:?missing FEES_MONGO_SECRET_PATH}"
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export FEES_MONGO_USER="$(./ci/vlt kv_get kv "${FEES_MONGO_SECRET_PATH}" user)" export FEES_MONGO_USER="$(./ci/vlt kv_get kv "${FEES_MONGO_SECRET_PATH}" user)"
export FEES_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${FEES_MONGO_SECRET_PATH}" password)" export FEES_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${FEES_MONGO_SECRET_PATH}" password)"
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/billing_fees.sh bash ci/prod/scripts/deploy/billing_fees.sh

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
CHAIN_GATEWAY_ENV_NAME="${CHAIN_GATEWAY_ENV:-prod}" CHAIN_GATEWAY_ENV_NAME="${CHAIN_GATEWAY_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${CHAIN_GATEWAY_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${CHAIN_GATEWAY_ENV_NAME}/.env.runtime"
@@ -50,8 +52,6 @@ CHAIN_GATEWAY_MONGO_SECRET_PATH="${CHAIN_GATEWAY_MONGO_SECRET_PATH:?missing CHAI
CHAIN_GATEWAY_RPC_SECRET_PATH="${CHAIN_GATEWAY_RPC_SECRET_PATH:?missing CHAIN_GATEWAY_RPC_SECRET_PATH}" CHAIN_GATEWAY_RPC_SECRET_PATH="${CHAIN_GATEWAY_RPC_SECRET_PATH:?missing CHAIN_GATEWAY_RPC_SECRET_PATH}"
CHAIN_GATEWAY_WALLET_SECRET_PATH="${CHAIN_GATEWAY_WALLET_SECRET_PATH:?missing CHAIN_GATEWAY_WALLET_SECRET_PATH}" CHAIN_GATEWAY_WALLET_SECRET_PATH="${CHAIN_GATEWAY_WALLET_SECRET_PATH:?missing CHAIN_GATEWAY_WALLET_SECRET_PATH}"
CHAIN_GATEWAY_VAULT_SECRET_PATH="${CHAIN_GATEWAY_VAULT_SECRET_PATH:?missing CHAIN_GATEWAY_VAULT_SECRET_PATH}" CHAIN_GATEWAY_VAULT_SECRET_PATH="${CHAIN_GATEWAY_VAULT_SECRET_PATH:?missing CHAIN_GATEWAY_VAULT_SECRET_PATH}"
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export CHAIN_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" user)" export CHAIN_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" user)"
export CHAIN_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" password)" export CHAIN_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${CHAIN_GATEWAY_MONGO_SECRET_PATH}" password)"
@@ -68,9 +68,7 @@ if [ -z "${CHAIN_GATEWAY_VAULT_ROLE_ID}" ] || [ -z "${CHAIN_GATEWAY_VAULT_SECRET
exit 1 exit 1
fi fi
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/chain_gateway.sh bash ci/prod/scripts/deploy/chain_gateway.sh

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
echo "[fx-pipeline] rewriting .env.version" >&2 echo "[build pipeline] rewriting .env.version" >&2
if [ -f ./.env.version ]; then if [ -f ./.env.version ]; then
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do

View File

@@ -0,0 +1,20 @@
# Helper for loading NATS credentials and URL in deploy scripts.
load_nats_env() {
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
nats_secret_path="${NATS_SECRET_PATH:-sendico/nats}"
export NATS_USER="$(./ci/vlt kv_get kv "${nats_secret_path}" user)"
export NATS_PASSWORD="$(./ci/vlt kv_get kv "${nats_secret_path}" password)"
nats_url_var="${NATS_URL_VAR:-NATS_URL}"
nats_url_scheme="${NATS_URL_SCHEME:-nats}"
case "${nats_url_var}" in
''|[!A-Za-z_]*|*[!A-Za-z0-9_]*)
echo "[nats-env] invalid NATS_URL_VAR: ${nats_url_var}" >&2
exit 1
;;
esac
export "${nats_url_var}=${nats_url_scheme}://${NATS_HOST}:${NATS_PORT}"
}

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
DISCOVERY_ENV_NAME="${DISCOVERY_ENV:-prod}" DISCOVERY_ENV_NAME="${DISCOVERY_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${DISCOVERY_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${DISCOVERY_ENV_NAME}/.env.runtime"
@@ -46,12 +48,7 @@ normalize_env_file ./.env.version
load_env_file "${RUNTIME_ENV_FILE}" load_env_file "${RUNTIME_ENV_FILE}"
load_env_file ./.env.version load_env_file ./.env.version
: "${NATS_HOST:?missing NATS_HOST}" load_nats_env
: "${NATS_PORT:?missing NATS_PORT}"
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)"
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/discovery.sh bash ci/prod/scripts/deploy/discovery.sh

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
FX_ENV_NAME="${FX_ENV:-prod}" FX_ENV_NAME="${FX_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${FX_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${FX_ENV_NAME}/.env.runtime"
@@ -54,9 +56,7 @@ export FX_MONGO_USER="$(./ci/vlt kv_get kv "${FX_MONGO_SECRET_PATH}" user)"
export FX_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${FX_MONGO_SECRET_PATH}" password)" export FX_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${FX_MONGO_SECRET_PATH}" password)"
if [ "${FX_NEEDS_NATS}" = "true" ]; then if [ "${FX_NEEDS_NATS}" = "true" ]; then
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" NATS_URL_VAR=FX_NATS_URL load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export FX_NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
fi fi
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
LEDGER_ENV_NAME="${LEDGER_ENV:-prod}" LEDGER_ENV_NAME="${LEDGER_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${LEDGER_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${LEDGER_ENV_NAME}/.env.runtime"
@@ -47,15 +49,11 @@ load_env_file "${RUNTIME_ENV_FILE}"
load_env_file ./.env.version load_env_file ./.env.version
LEDGER_MONGO_SECRET_PATH="${LEDGER_MONGO_SECRET_PATH:?missing LEDGER_MONGO_SECRET_PATH}" LEDGER_MONGO_SECRET_PATH="${LEDGER_MONGO_SECRET_PATH:?missing LEDGER_MONGO_SECRET_PATH}"
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export LEDGER_MONGO_USER="$(./ci/vlt kv_get kv "${LEDGER_MONGO_SECRET_PATH}" user)" export LEDGER_MONGO_USER="$(./ci/vlt kv_get kv "${LEDGER_MONGO_SECRET_PATH}" user)"
export LEDGER_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${LEDGER_MONGO_SECRET_PATH}" password)" export LEDGER_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${LEDGER_MONGO_SECRET_PATH}" password)"
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/ledger.sh bash ci/prod/scripts/deploy/ledger.sh

View File

@@ -30,6 +30,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
MNTX_GATEWAY_ENV_NAME="${MNTX_GATEWAY_ENV:-prod}" MNTX_GATEWAY_ENV_NAME="${MNTX_GATEWAY_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${MNTX_GATEWAY_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${MNTX_GATEWAY_ENV_NAME}/.env.runtime"
@@ -50,15 +52,11 @@ load_env_file ./.env.version
MNTX_GATEWAY_MONETIX_SECRET_PATH="${MNTX_GATEWAY_MONETIX_SECRET_PATH:-sendico/gateway/monetix}" MNTX_GATEWAY_MONETIX_SECRET_PATH="${MNTX_GATEWAY_MONETIX_SECRET_PATH:-sendico/gateway/monetix}"
MNTX_GATEWAY_NATS_SECRET_PATH="${MNTX_GATEWAY_NATS_SECRET_PATH:-sendico/nats}" MNTX_GATEWAY_NATS_SECRET_PATH="${MNTX_GATEWAY_NATS_SECRET_PATH:-sendico/nats}"
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export MONETIX_PROJECT_ID="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_MONETIX_SECRET_PATH}" project_id)" export MONETIX_PROJECT_ID="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_MONETIX_SECRET_PATH}" project_id)"
export MONETIX_SECRET_KEY="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_MONETIX_SECRET_PATH}" secret_key)" export MONETIX_SECRET_KEY="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_MONETIX_SECRET_PATH}" secret_key)"
export NATS_USER="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_NATS_SECRET_PATH}" user)" NATS_SECRET_PATH="${MNTX_GATEWAY_NATS_SECRET_PATH}" load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_NATS_SECRET_PATH}" password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/mntx_gateway.sh bash ci/prod/scripts/deploy/mntx_gateway.sh

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
NOTIFICATION_ENV_NAME="${NOTIFICATION_ENV:-prod}" NOTIFICATION_ENV_NAME="${NOTIFICATION_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${NOTIFICATION_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${NOTIFICATION_ENV_NAME}/.env.runtime"
@@ -50,8 +52,6 @@ NOTIFICATION_MONGO_SECRET_PATH="${NOTIFICATION_MONGO_SECRET_PATH:?missing NOTIFI
NOTIFICATION_MAIL_SECRET_PATH="${NOTIFICATION_MAIL_SECRET_PATH:?missing NOTIFICATION_MAIL_SECRET_PATH}" NOTIFICATION_MAIL_SECRET_PATH="${NOTIFICATION_MAIL_SECRET_PATH:?missing NOTIFICATION_MAIL_SECRET_PATH}"
NOTIFICATION_API_SECRET_PATH="${NOTIFICATION_API_SECRET_PATH:?missing NOTIFICATION_API_SECRET_PATH}" NOTIFICATION_API_SECRET_PATH="${NOTIFICATION_API_SECRET_PATH:?missing NOTIFICATION_API_SECRET_PATH}"
NOTIFICATION_TELEGRAM_SECRET_PATH="${NOTIFICATION_TELEGRAM_SECRET_PATH:?missing NOTIFICATION_TELEGRAM_SECRET_PATH}" NOTIFICATION_TELEGRAM_SECRET_PATH="${NOTIFICATION_TELEGRAM_SECRET_PATH:?missing NOTIFICATION_TELEGRAM_SECRET_PATH}"
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export MONGO_USER="$(./ci/vlt kv_get kv "${NOTIFICATION_MONGO_SECRET_PATH}" user)" export MONGO_USER="$(./ci/vlt kv_get kv "${NOTIFICATION_MONGO_SECRET_PATH}" user)"
export MONGO_PASSWORD="$(./ci/vlt kv_get kv "${NOTIFICATION_MONGO_SECRET_PATH}" password)" export MONGO_PASSWORD="$(./ci/vlt kv_get kv "${NOTIFICATION_MONGO_SECRET_PATH}" password)"
@@ -69,9 +69,7 @@ if TELEGRAM_THREAD_ID_VALUE="$(./ci/vlt kv_get kv "${NOTIFICATION_TELEGRAM_SECRE
fi fi
export TELEGRAM_THREAD_ID export TELEGRAM_THREAD_ID
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/notification.sh bash ci/prod/scripts/deploy/notification.sh

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
PAYMENTS_ENV_NAME="${PAYMENTS_ENV:-prod}" PAYMENTS_ENV_NAME="${PAYMENTS_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${PAYMENTS_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${PAYMENTS_ENV_NAME}/.env.runtime"
@@ -47,15 +49,11 @@ load_env_file "${RUNTIME_ENV_FILE}"
load_env_file ./.env.version load_env_file ./.env.version
PAYMENTS_MONGO_SECRET_PATH="${PAYMENTS_MONGO_SECRET_PATH:?missing PAYMENTS_MONGO_SECRET_PATH}" PAYMENTS_MONGO_SECRET_PATH="${PAYMENTS_MONGO_SECRET_PATH:?missing PAYMENTS_MONGO_SECRET_PATH}"
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export PAYMENTS_MONGO_USER="$(./ci/vlt kv_get kv "${PAYMENTS_MONGO_SECRET_PATH}" user)" export PAYMENTS_MONGO_USER="$(./ci/vlt kv_get kv "${PAYMENTS_MONGO_SECRET_PATH}" user)"
export PAYMENTS_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${PAYMENTS_MONGO_SECRET_PATH}" password)" export PAYMENTS_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${PAYMENTS_MONGO_SECRET_PATH}" password)"
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/payments_orchestrator.sh bash ci/prod/scripts/deploy/payments_orchestrator.sh

View File

@@ -116,10 +116,10 @@ if [ -f "${PROTO_DIR}/gateway/mntx/v1/mntx.proto" ]; then
generate_go_with_grpc "${PROTO_DIR}/gateway/mntx/v1/mntx.proto" generate_go_with_grpc "${PROTO_DIR}/gateway/mntx/v1/mntx.proto"
fi fi
if [ -f "${PROTO_DIR}/gateway/unified/v1/gateway.proto" ]; then if [ -f "${PROTO_DIR}/connector/v1/connector.proto" ]; then
info "Compiling unified gateway protos" info "Compiling connector protos"
clean_pb_files "./pkg/proto/gateway/unified" clean_pb_files "./pkg/proto/connector"
generate_go_with_grpc "${PROTO_DIR}/gateway/unified/v1/gateway.proto" generate_go_with_grpc "${PROTO_DIR}/connector/v1/connector.proto"
fi fi
if [ -f "${PROTO_DIR}/payments/orchestrator/v1/orchestrator.proto" ]; then if [ -f "${PROTO_DIR}/payments/orchestrator/v1/orchestrator.proto" ]; then

View File

@@ -32,6 +32,8 @@ load_env_file() {
done <"$file" done <"$file"
} }
. ci/scripts/common/nats_env.sh
TGSETTLE_GATEWAY_ENV_NAME="${TGSETTLE_GATEWAY_ENV:-prod}" TGSETTLE_GATEWAY_ENV_NAME="${TGSETTLE_GATEWAY_ENV:-prod}"
RUNTIME_ENV_FILE="./ci/${TGSETTLE_GATEWAY_ENV_NAME}/.env.runtime" RUNTIME_ENV_FILE="./ci/${TGSETTLE_GATEWAY_ENV_NAME}/.env.runtime"
@@ -47,15 +49,11 @@ load_env_file "${RUNTIME_ENV_FILE}"
load_env_file ./.env.version load_env_file ./.env.version
TGSETTLE_GATEWAY_MONGO_SECRET_PATH="${TGSETTLE_GATEWAY_MONGO_SECRET_PATH:?missing TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" TGSETTLE_GATEWAY_MONGO_SECRET_PATH="${TGSETTLE_GATEWAY_MONGO_SECRET_PATH:?missing TGSETTLE_GATEWAY_MONGO_SECRET_PATH}"
: "${NATS_HOST:?missing NATS_HOST}"
: "${NATS_PORT:?missing NATS_PORT}"
export TGSETTLE_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" user)" export TGSETTLE_GATEWAY_MONGO_USER="$(./ci/vlt kv_get kv "${TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" user)"
export TGSETTLE_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" password)" export TGSETTLE_GATEWAY_MONGO_PASSWORD="$(./ci/vlt kv_get kv "${TGSETTLE_GATEWAY_MONGO_SECRET_PATH}" password)"
export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)" load_nats_env
export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}"
bash ci/prod/scripts/bootstrap/network.sh bash ci/prod/scripts/bootstrap/network.sh
bash ci/prod/scripts/deploy/tgsettle_gateway.sh bash ci/prod/scripts/deploy/tgsettle_gateway.sh

View File

@@ -12,14 +12,18 @@ part 'signup.g.dart';
class SignupRequest { class SignupRequest {
final AccountData account; final AccountData account;
final DescribableDTO organization; final DescribableDTO organization;
final String organizationTimeZone;
final DescribableDTO ownerRole; final DescribableDTO ownerRole;
final DescribableDTO cryptoWallet;
final DescribableDTO ledgerWallet;
final String organizationTimeZone;
const SignupRequest({ const SignupRequest({
required this.account, required this.account,
required this.organization, required this.organization,
required this.organizationTimeZone, required this.organizationTimeZone,
required this.ownerRole, required this.ownerRole,
required this.cryptoWallet,
required this.ledgerWallet,
}); });
factory SignupRequest.build({ factory SignupRequest.build({
@@ -27,11 +31,15 @@ class SignupRequest {
required Describable organization, required Describable organization,
required String organizationTimeZone, required String organizationTimeZone,
required Describable ownerRole, required Describable ownerRole,
required Describable cryptoWallet,
required Describable ledgerWallet,
}) => SignupRequest( }) => SignupRequest(
account: account, account: account,
organization: organization.toDTO(), organization: organization.toDTO(),
organizationTimeZone: organizationTimeZone, organizationTimeZone: organizationTimeZone,
ownerRole: ownerRole.toDTO(), ownerRole: ownerRole.toDTO(),
cryptoWallet: cryptoWallet.toDTO(),
ledgerWallet: ledgerWallet.toDTO(),
); );
factory SignupRequest.fromJson(Map<String, dynamic> json) => _$SignupRequestFromJson(json); factory SignupRequest.fromJson(Map<String, dynamic> json) => _$SignupRequestFromJson(json);

Some files were not shown because too many files have changed in this diff Show More