Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32cccc7895 | ||
| 81d2db394b | |||
|
|
8d6a302cb8 | ||
| 0e48d2a318 | |||
|
|
32653e11fc | ||
| a24ead2c36 | |||
|
|
ce59cb1b26 | ||
| cecaebfc5e | |||
| e16f11d48a | |||
|
|
0804ad71f7 | ||
| 7a2f921de9 | |||
|
|
999f0684cb | ||
| 602b77ddc7 | |||
|
|
64ad8c8b38 | ||
| f478219990 | |||
|
|
bf39b1d401 | ||
| f7bf3138ac | |||
|
|
7cb747f9a9 | ||
| f2658aea44 | |||
|
|
5e49ee3244 | ||
| 1073be187f | |||
|
|
e854963fa6 | ||
| e5f283432b | |||
|
|
d62a3413b2 | ||
| f720ba9bdf | |||
|
|
98f254e34b | ||
|
|
980bb96c74 | ||
| 4bb18f0210 | |||
|
|
574b40fe9f | ||
| a3a807e625 | |||
|
|
3b047af7ca | ||
| 36cc46577c | |||
|
|
e1da16448b | ||
| fed6f39de6 | |||
|
|
85fb567ed9 | ||
|
|
fd07c10cba | ||
|
|
c44edc85fa | ||
| 57a48fe2a3 | |||
|
|
2b2a8afc2f | ||
| d431317a50 | |||
|
|
b4c696f1ef | ||
| 4d03a6ead8 | |||
|
|
2fe5151650 | ||
|
|
2754a7aa13 | ||
|
|
f71cc76f64 | ||
| 082d782a80 | |||
|
|
18f8f3c476 | ||
| 659b90b6a5 | |||
|
|
84318883d2 | ||
| 668ade2014 | |||
|
|
43c4866ad7 | ||
|
|
396a0c0c88 | ||
| f439f53524 | |||
|
|
2052602050 |
74
.woodpecker/mntx_gateway.yml
Normal file
74
.woodpecker/mntx_gateway.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- MNTX_GATEWAY_IMAGE_PATH: gateway/mntx
|
||||||
|
MNTX_GATEWAY_DOCKERFILE: ci/prod/compose/mntx_gateway.dockerfile
|
||||||
|
MNTX_GATEWAY_ENV: prod
|
||||||
|
MNTX_GATEWAY_MONETIX_SECRET_PATH: sendico/gateway/monetix
|
||||||
|
MNTX_GATEWAY_NATS_SECRET_PATH: sendico/nats
|
||||||
|
|
||||||
|
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/mntx/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/mntx/deploy.sh
|
||||||
@@ -18,7 +18,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -44,11 +44,11 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
|||||||
@@ -2,448 +2,16 @@ package fees
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"math/big"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
|
||||||
dmath "github.com/tech/sendico/pkg/decimal"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
||||||
|
// Implementation lives under internal/service/fees/internal/calculator.
|
||||||
type Calculator interface {
|
type Calculator interface {
|
||||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
|
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error)
|
||||||
}
|
|
||||||
|
|
||||||
// CalculationResult contains derived fee lines and audit metadata.
|
|
||||||
type CalculationResult struct {
|
|
||||||
Lines []*feesv1.DerivedPostingLine
|
|
||||||
Applied []*feesv1.AppliedRule
|
|
||||||
FxUsed *feesv1.FXUsed
|
|
||||||
}
|
|
||||||
|
|
||||||
// quoteCalculator is the default Calculator implementation.
|
|
||||||
type fxOracle interface {
|
|
||||||
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type quoteCalculator struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
oracle fxOracle
|
|
||||||
}
|
|
||||||
|
|
||||||
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
|
|
||||||
return "eCalculator{
|
|
||||||
logger: logger.Named("calculator"),
|
|
||||||
oracle: oracle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
|
||||||
if plan == nil {
|
|
||||||
return nil, merrors.InvalidArgument("plan is required")
|
|
||||||
}
|
|
||||||
if intent == nil {
|
|
||||||
return nil, merrors.InvalidArgument("intent is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger := convertTrigger(intent.GetTrigger())
|
|
||||||
if trigger == model.TriggerUnspecified {
|
|
||||||
return nil, merrors.InvalidArgument("unsupported trigger")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid base amount")
|
|
||||||
}
|
|
||||||
if baseAmount.Sign() < 0 {
|
|
||||||
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
|
||||||
|
|
||||||
rules := make([]model.FeeRule, len(plan.Rules))
|
|
||||||
copy(rules, plan.Rules)
|
|
||||||
sort.SliceStable(rules, func(i, j int) bool {
|
|
||||||
if rules[i].Priority == rules[j].Priority {
|
|
||||||
return rules[i].RuleID < rules[j].RuleID
|
|
||||||
}
|
|
||||||
return rules[i].Priority < rules[j].Priority
|
|
||||||
})
|
|
||||||
|
|
||||||
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
|
||||||
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
|
||||||
|
|
||||||
planID := ""
|
|
||||||
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
|
||||||
planID = planRef.Hex()
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rule := range rules {
|
|
||||||
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
|
||||||
if ledgerAccountRef == "" {
|
|
||||||
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
|
||||||
if calcErr != nil {
|
|
||||||
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
|
||||||
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if amount.Sign() == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
currency := intent.GetBaseAmount().GetCurrency()
|
|
||||||
if override := strings.TrimSpace(rule.Currency); override != "" {
|
|
||||||
currency = override
|
|
||||||
}
|
|
||||||
|
|
||||||
entrySide := mapEntrySide(rule.EntrySide)
|
|
||||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
|
||||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := map[string]string{
|
|
||||||
"fee_rule_id": rule.RuleID,
|
|
||||||
}
|
|
||||||
if planID != "" {
|
|
||||||
meta["fee_plan_id"] = planID
|
|
||||||
}
|
|
||||||
if rule.Metadata != nil {
|
|
||||||
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
|
||||||
meta["tax_code"] = taxCode
|
|
||||||
}
|
|
||||||
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
|
||||||
meta["tax_rate"] = taxRate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines = append(lines, &feesv1.DerivedPostingLine{
|
|
||||||
LedgerAccountRef: ledgerAccountRef,
|
|
||||||
Money: &moneyv1.Money{
|
|
||||||
Amount: dmath.FormatRat(amount, scale),
|
|
||||||
Currency: currency,
|
|
||||||
},
|
|
||||||
LineType: mapLineType(rule.LineType),
|
|
||||||
Side: entrySide,
|
|
||||||
Meta: meta,
|
|
||||||
})
|
|
||||||
|
|
||||||
applied = append(applied, &feesv1.AppliedRule{
|
|
||||||
RuleId: rule.RuleID,
|
|
||||||
RuleVersion: planID,
|
|
||||||
Formula: rule.Formula,
|
|
||||||
Rounding: mapRoundingMode(rule.Rounding),
|
|
||||||
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
|
||||||
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
|
||||||
Parameters: cloneStringMap(rule.Metadata),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var fxUsed *feesv1.FXUsed
|
|
||||||
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
|
||||||
fxUsed = c.buildFxUsed(ctx, intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CalculationResult{
|
|
||||||
Lines: lines,
|
|
||||||
Applied: applied,
|
|
||||||
FxUsed: fxUsed,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
|
||||||
scale, err := resolveRuleScale(rule, baseScale)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := new(big.Rat)
|
|
||||||
|
|
||||||
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
|
||||||
percentageRat, perr := dmath.RatFromString(percentage)
|
|
||||||
if perr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
|
||||||
}
|
|
||||||
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
|
||||||
}
|
|
||||||
|
|
||||||
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
|
||||||
fixedRat, ferr := dmath.RatFromString(fixed)
|
|
||||||
if ferr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
|
||||||
}
|
|
||||||
result = dmath.AddRat(result, fixedRat)
|
|
||||||
}
|
|
||||||
|
|
||||||
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
|
||||||
minRat, merr := dmath.RatFromString(minStr)
|
|
||||||
if merr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
|
||||||
}
|
|
||||||
if dmath.CmpRat(result, minRat) < 0 {
|
|
||||||
result = new(big.Rat).Set(minRat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
|
||||||
maxRat, merr := dmath.RatFromString(maxStr)
|
|
||||||
if merr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
|
||||||
}
|
|
||||||
if dmath.CmpRat(result, maxRat) > 0 {
|
|
||||||
result = new(big.Rat).Set(maxRat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Sign() < 0 {
|
|
||||||
result = new(big.Rat).Abs(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
|
||||||
if rerr != nil {
|
|
||||||
return nil, 0, rerr
|
|
||||||
}
|
|
||||||
|
|
||||||
return rounded, scale, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
attrFxBaseCurrency = "fx_base_currency"
|
|
||||||
attrFxQuoteCurrency = "fx_quote_currency"
|
|
||||||
attrFxProvider = "fx_provider"
|
|
||||||
attrFxSide = "fx_side"
|
|
||||||
attrFxRateOverride = "fx_rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
|
||||||
if intent == nil || c.oracle == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := intent.GetAttributes()
|
|
||||||
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
|
||||||
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
|
||||||
if base == "" || quote == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
|
||||||
provider := strings.TrimSpace(attrs[attrFxProvider])
|
|
||||||
|
|
||||||
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
|
||||||
Meta: oracleclient.RequestMeta{},
|
|
||||||
Pair: pair,
|
|
||||||
Provider: provider,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if snapshot == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Mid
|
|
||||||
}
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Ask
|
|
||||||
}
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Bid
|
|
||||||
}
|
|
||||||
|
|
||||||
return &feesv1.FXUsed{
|
|
||||||
Pair: pair,
|
|
||||||
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
|
||||||
Rate: &moneyv1.Decimal{Value: rateValue},
|
|
||||||
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
|
||||||
Provider: snapshot.Provider,
|
|
||||||
RateRef: snapshot.RateRef,
|
|
||||||
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFxSide(value string) fxv1.Side {
|
|
||||||
switch strings.ToLower(value) {
|
|
||||||
case "buy_base", "buy_base_sell_quote", "buy":
|
|
||||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
|
||||||
case "sell_base", "sell_base_buy_quote", "sell":
|
|
||||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
|
||||||
default:
|
|
||||||
return fxv1.Side_SIDE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func inferScale(amount string) uint32 {
|
|
||||||
value := strings.TrimSpace(amount)
|
|
||||||
if value == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
|
||||||
value = value[:idx]
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
|
||||||
value = value[1:]
|
|
||||||
}
|
|
||||||
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
|
||||||
return uint32(len(value[dot+1:]))
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
|
||||||
if rule.Trigger != trigger {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if rule.EffectiveFrom.After(bookedAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ruleMatchesAttributes(rule, attributes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
|
||||||
if rule.Metadata != nil {
|
|
||||||
for _, field := range []string{"scale", "decimals", "precision"} {
|
|
||||||
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
|
||||||
return parseScale(field, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseScale(field, value string) (uint32, error) {
|
|
||||||
clean := strings.TrimSpace(value)
|
|
||||||
if clean == "" {
|
|
||||||
return 0, merrors.InvalidArgument(field + " is empty")
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseUint(clean, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
|
||||||
}
|
|
||||||
return uint32(parsed), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func metadataValue(meta map[string]string, key string) string {
|
|
||||||
if meta == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(meta[key])
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneStringMap(src map[string]string) map[string]string {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make(map[string]string, len(src))
|
|
||||||
for k, v := range src {
|
|
||||||
cloned[k] = v
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
|
||||||
if len(rule.AppliesTo) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for key, value := range rule.AppliesTo {
|
|
||||||
if attributes == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if attrValue, ok := attributes[key]; !ok || attrValue != value {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
|
||||||
switch trigger {
|
|
||||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
|
||||||
return model.TriggerCapture
|
|
||||||
case feesv1.Trigger_TRIGGER_REFUND:
|
|
||||||
return model.TriggerRefund
|
|
||||||
case feesv1.Trigger_TRIGGER_DISPUTE:
|
|
||||||
return model.TriggerDispute
|
|
||||||
case feesv1.Trigger_TRIGGER_PAYOUT:
|
|
||||||
return model.TriggerPayout
|
|
||||||
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
|
||||||
return model.TriggerFXConversion
|
|
||||||
default:
|
|
||||||
return model.TriggerUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapLineType(lineType string) accountingv1.PostingLineType {
|
|
||||||
switch strings.ToLower(lineType) {
|
|
||||||
case "tax":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
|
||||||
case "spread":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
|
||||||
case "reversal":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
|
||||||
default:
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
|
||||||
switch strings.ToLower(entrySide) {
|
|
||||||
case "debit":
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
|
||||||
case "credit":
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
|
||||||
default:
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toDecimalRounding(mode string) dmath.RoundingMode {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
||||||
case "half_up":
|
|
||||||
return dmath.RoundingModeHalfUp
|
|
||||||
case "down":
|
|
||||||
return dmath.RoundingModeDown
|
|
||||||
case "half_even", "bankers":
|
|
||||||
return dmath.RoundingModeHalfEven
|
|
||||||
default:
|
|
||||||
return dmath.RoundingModeHalfEven
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
|
||||||
switch strings.ToLower(mode) {
|
|
||||||
case "half_up":
|
|
||||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
|
||||||
case "down":
|
|
||||||
return moneyv1.RoundingMode_ROUND_DOWN
|
|
||||||
default:
|
|
||||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package calculator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
dmath "github.com/tech/sendico/pkg/decimal"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fxOracle captures the oracle dependency for FX conversions.
|
||||||
|
type fxOracle interface {
|
||||||
|
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs the default calculator implementation.
|
||||||
|
func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return "eCalculator{
|
||||||
|
logger: logger.Named("calculator"),
|
||||||
|
oracle: oracle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type quoteCalculator struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
oracle fxOracle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||||
|
if plan == nil {
|
||||||
|
return nil, merrors.InvalidArgument("plan is required")
|
||||||
|
}
|
||||||
|
if intent == nil {
|
||||||
|
return nil, merrors.InvalidArgument("intent is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger := convertTrigger(intent.GetTrigger())
|
||||||
|
if trigger == model.TriggerUnspecified {
|
||||||
|
return nil, merrors.InvalidArgument("unsupported trigger")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid base amount")
|
||||||
|
}
|
||||||
|
if baseAmount.Sign() < 0 {
|
||||||
|
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
||||||
|
|
||||||
|
rules := make([]model.FeeRule, len(plan.Rules))
|
||||||
|
copy(rules, plan.Rules)
|
||||||
|
sort.SliceStable(rules, func(i, j int) bool {
|
||||||
|
if rules[i].Priority == rules[j].Priority {
|
||||||
|
return rules[i].RuleID < rules[j].RuleID
|
||||||
|
}
|
||||||
|
return rules[i].Priority < rules[j].Priority
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
||||||
|
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||||
|
|
||||||
|
planID := ""
|
||||||
|
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
||||||
|
planID = planRef.Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
||||||
|
if ledgerAccountRef == "" {
|
||||||
|
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||||
|
if calcErr != nil {
|
||||||
|
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
|
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if amount.Sign() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := intent.GetBaseAmount().GetCurrency()
|
||||||
|
if override := strings.TrimSpace(rule.Currency); override != "" {
|
||||||
|
currency = override
|
||||||
|
}
|
||||||
|
|
||||||
|
entrySide := mapEntrySide(rule.EntrySide)
|
||||||
|
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||||
|
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := map[string]string{
|
||||||
|
"fee_rule_id": rule.RuleID,
|
||||||
|
}
|
||||||
|
if planID != "" {
|
||||||
|
meta["fee_plan_id"] = planID
|
||||||
|
}
|
||||||
|
if rule.Metadata != nil {
|
||||||
|
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
||||||
|
meta["tax_code"] = taxCode
|
||||||
|
}
|
||||||
|
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
||||||
|
meta["tax_rate"] = taxRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, &feesv1.DerivedPostingLine{
|
||||||
|
LedgerAccountRef: ledgerAccountRef,
|
||||||
|
Money: &moneyv1.Money{
|
||||||
|
Amount: dmath.FormatRat(amount, scale),
|
||||||
|
Currency: currency,
|
||||||
|
},
|
||||||
|
LineType: mapLineType(rule.LineType),
|
||||||
|
Side: entrySide,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
|
||||||
|
applied = append(applied, &feesv1.AppliedRule{
|
||||||
|
RuleId: rule.RuleID,
|
||||||
|
RuleVersion: planID,
|
||||||
|
Formula: rule.Formula,
|
||||||
|
Rounding: mapRoundingMode(rule.Rounding),
|
||||||
|
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
||||||
|
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
||||||
|
Parameters: cloneStringMap(rule.Metadata),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var fxUsed *feesv1.FXUsed
|
||||||
|
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
||||||
|
fxUsed = c.buildFxUsed(ctx, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CalculationResult{
|
||||||
|
Lines: lines,
|
||||||
|
Applied: applied,
|
||||||
|
FxUsed: fxUsed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
||||||
|
scale, err := resolveRuleScale(rule, baseScale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := new(big.Rat)
|
||||||
|
|
||||||
|
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
||||||
|
percentageRat, perr := dmath.RatFromString(percentage)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
||||||
|
}
|
||||||
|
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
||||||
|
fixedRat, ferr := dmath.RatFromString(fixed)
|
||||||
|
if ferr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
||||||
|
}
|
||||||
|
result = dmath.AddRat(result, fixedRat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
||||||
|
minRat, merr := dmath.RatFromString(minStr)
|
||||||
|
if merr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
||||||
|
}
|
||||||
|
if dmath.CmpRat(result, minRat) < 0 {
|
||||||
|
result = new(big.Rat).Set(minRat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
||||||
|
maxRat, merr := dmath.RatFromString(maxStr)
|
||||||
|
if merr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
||||||
|
}
|
||||||
|
if dmath.CmpRat(result, maxRat) > 0 {
|
||||||
|
result = new(big.Rat).Set(maxRat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Sign() < 0 {
|
||||||
|
result = new(big.Rat).Abs(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
||||||
|
if rerr != nil {
|
||||||
|
return nil, 0, rerr
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded, scale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
attrFxBaseCurrency = "fx_base_currency"
|
||||||
|
attrFxQuoteCurrency = "fx_quote_currency"
|
||||||
|
attrFxProvider = "fx_provider"
|
||||||
|
attrFxSide = "fx_side"
|
||||||
|
attrFxRateOverride = "fx_rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
||||||
|
if intent == nil || c.oracle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := intent.GetAttributes()
|
||||||
|
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||||
|
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||||
|
if base == "" || quote == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
||||||
|
provider := strings.TrimSpace(attrs[attrFxProvider])
|
||||||
|
|
||||||
|
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
||||||
|
Meta: oracleclient.RequestMeta{},
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Mid
|
||||||
|
}
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Ask
|
||||||
|
}
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Bid
|
||||||
|
}
|
||||||
|
|
||||||
|
return &feesv1.FXUsed{
|
||||||
|
Pair: pair,
|
||||||
|
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
||||||
|
Rate: &moneyv1.Decimal{Value: rateValue},
|
||||||
|
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
||||||
|
Provider: snapshot.Provider,
|
||||||
|
RateRef: snapshot.RateRef,
|
||||||
|
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFxSide(value string) fxv1.Side {
|
||||||
|
switch strings.ToLower(value) {
|
||||||
|
case "buy_base", "buy_base_sell_quote", "buy":
|
||||||
|
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||||
|
case "sell_base", "sell_base_buy_quote", "sell":
|
||||||
|
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||||
|
default:
|
||||||
|
return fxv1.Side_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferScale(amount string) uint32 {
|
||||||
|
value := strings.TrimSpace(amount)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
||||||
|
value = value[:idx]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
||||||
|
value = value[1:]
|
||||||
|
}
|
||||||
|
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
||||||
|
return uint32(len(value[dot+1:]))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
||||||
|
if rule.Trigger != trigger {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.EffectiveFrom.After(bookedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ruleMatchesAttributes(rule, attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
||||||
|
if rule.Metadata != nil {
|
||||||
|
for _, field := range []string{"scale", "decimals", "precision"} {
|
||||||
|
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
||||||
|
return parseScale(field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScale(field, value string) (uint32, error) {
|
||||||
|
clean := strings.TrimSpace(value)
|
||||||
|
if clean == "" {
|
||||||
|
return 0, merrors.InvalidArgument(field + " is empty")
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(clean, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
||||||
|
}
|
||||||
|
return uint32(parsed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataValue(meta map[string]string, key string) string {
|
||||||
|
if meta == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(meta[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
cloned[k] = v
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
||||||
|
if len(rule.AppliesTo) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for key, value := range rule.AppliesTo {
|
||||||
|
if attributes == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attrValue, ok := attributes[key]; !ok || attrValue != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapLineType(lineType string) accountingv1.PostingLineType {
|
||||||
|
switch strings.ToLower(lineType) {
|
||||||
|
case "tax":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||||
|
case "spread":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||||
|
case "reversal":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||||
|
default:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
||||||
|
switch strings.ToLower(entrySide) {
|
||||||
|
case "debit":
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||||
|
case "credit":
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||||
|
default:
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDecimalRounding(mode string) dmath.RoundingMode {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "half_up":
|
||||||
|
return dmath.RoundingModeHalfUp
|
||||||
|
case "down":
|
||||||
|
return dmath.RoundingModeDown
|
||||||
|
case "half_even", "bankers":
|
||||||
|
return dmath.RoundingModeHalfEven
|
||||||
|
default:
|
||||||
|
return dmath.RoundingModeHalfEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
||||||
|
switch strings.ToLower(mode) {
|
||||||
|
case "half_up":
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||||
|
case "down":
|
||||||
|
return moneyv1.RoundingMode_ROUND_DOWN
|
||||||
|
default:
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||||
|
switch trigger {
|
||||||
|
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||||
|
return model.TriggerCapture
|
||||||
|
case feesv1.Trigger_TRIGGER_REFUND:
|
||||||
|
return model.TriggerRefund
|
||||||
|
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||||
|
return model.TriggerDispute
|
||||||
|
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||||
|
return model.TriggerPayout
|
||||||
|
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||||
|
return model.TriggerFXConversion
|
||||||
|
default:
|
||||||
|
return model.TriggerUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/pkg/merrors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNoFeeRuleFound indicates that no applicable rule exists for the given context.
|
||||||
|
ErrNoFeeRuleFound = merrors.ErrNoData
|
||||||
|
// ErrConflictingFeeRules indicates multiple rules share the same highest priority.
|
||||||
|
ErrConflictingFeeRules = merrors.ErrDataConflict
|
||||||
|
)
|
||||||
148
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
148
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type planFinder interface {
|
||||||
|
FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||||
|
FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type feeResolver struct {
|
||||||
|
plans storage.PlansStore
|
||||||
|
finder planFinder
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
||||||
|
var finder planFinder
|
||||||
|
if pf, ok := plans.(planFinder); ok {
|
||||||
|
finder = pf
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &feeResolver{
|
||||||
|
plans: plans,
|
||||||
|
finder: finder,
|
||||||
|
logger: logger.Named("resolver"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||||
|
if r.plans == nil {
|
||||||
|
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try org-specific first if provided.
|
||||||
|
if orgID != nil && !orgID.IsZero() {
|
||||||
|
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
|
||||||
|
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
|
||||||
|
return plan, rule, nil
|
||||||
|
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
||||||
|
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex()))
|
||||||
|
return nil, nil, selErr
|
||||||
|
}
|
||||||
|
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex()))
|
||||||
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := r.getGlobalPlan(ctx, at)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
|
}
|
||||||
|
r.logger.Warn("failed resolving global fee plan", zap.Error(err))
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := selectRule(plan, trigger, at, attrs)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrNoFeeRuleFound) {
|
||||||
|
r.logger.Warn("failed selecting rule in global plan", zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan, rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if r.finder != nil {
|
||||||
|
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
|
||||||
|
}
|
||||||
|
return r.plans.GetActivePlan(ctx, orgRef, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if r.finder != nil {
|
||||||
|
return r.finder.FindActiveGlobalPlan(ctx, at)
|
||||||
|
}
|
||||||
|
// Treat zero ObjectID as global in legacy path.
|
||||||
|
return r.plans.GetActivePlan(ctx, primitive.NilObjectID, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
|
||||||
|
if plan == nil {
|
||||||
|
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected *model.FeeRule
|
||||||
|
var highestPriority int
|
||||||
|
for _, rule := range plan.Rules {
|
||||||
|
if rule.Trigger != trigger {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.EffectiveFrom.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected == nil || rule.Priority > highestPriority {
|
||||||
|
copy := rule
|
||||||
|
selected = ©
|
||||||
|
highestPriority = rule.Priority
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.Priority == highestPriority {
|
||||||
|
return nil, merrors.DataConflict("fees: conflicting fee rules")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected == nil {
|
||||||
|
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
|
}
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
|
||||||
|
if len(appliesTo) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for key, value := range appliesTo {
|
||||||
|
if attrs == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attrs[key] != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
orgA := primitive.NewObjectID()
|
||||||
|
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||||
|
}
|
||||||
|
if !plan.GetOrganizationRef().IsZero() {
|
||||||
|
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex())
|
||||||
|
}
|
||||||
|
if rule.RuleID != "global_capture" {
|
||||||
|
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := primitive.NewObjectID()
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan.SetOrganizationRef(org)
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected org plan rule, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "org_capture" {
|
||||||
|
t.Fatalf("expected org rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherOrg := primitive.NewObjectID()
|
||||||
|
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected global fallback for other org, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "global_capture" {
|
||||||
|
t.Fatalf("expected global rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := primitive.NewObjectID()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "low", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan.SetOrganizationRef(org)
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected rule resolution, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "high" {
|
||||||
|
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.Rules = append(plan.Rules, model.FeeRule{
|
||||||
|
RuleID: "conflict",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 200,
|
||||||
|
Percentage: "0.02",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, ErrConflictingFeeRules) {
|
||||||
|
t.Fatalf("expected conflicting fee rules error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := primitive.NewObjectID()
|
||||||
|
past := now.Add(-24 * time.Hour)
|
||||||
|
future := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
orgPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: past,
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan.SetOrganizationRef(org)
|
||||||
|
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: past,
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "current", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &future},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{orgPlan, globalPlan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "current" {
|
||||||
|
t.Fatalf("expected current global rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "card", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", AppliesTo: map[string]string{"paymentMethod": "card"}, EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
{RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "card"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected card rule, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "card" {
|
||||||
|
t.Fatalf("expected card rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "bank"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected default rule, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "default" {
|
||||||
|
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerRefund, now, nil); !errors.Is(err, ErrNoFeeRuleFound) {
|
||||||
|
t.Fatalf("expected ErrNoFeeRuleFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := primitive.NewObjectID()
|
||||||
|
p1 := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p1.SetOrganizationRef(org)
|
||||||
|
p2 := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-30 * time.Minute),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p2.SetOrganizationRef(org)
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
|
||||||
|
t.Fatalf("expected conflicting plans error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryPlansStore struct {
|
||||||
|
plans []*model.FeePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) Create(context.Context, *model.FeePlan) error { return nil }
|
||||||
|
func (m *memoryPlansStore) Update(context.Context, *model.FeePlan) error { return nil }
|
||||||
|
func (m *memoryPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if !orgRef.IsZero() {
|
||||||
|
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
|
||||||
|
return plan, nil
|
||||||
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.FindActiveGlobalPlan(ctx, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
var matches []*model.FeePlan
|
||||||
|
for _, plan := range m.plans {
|
||||||
|
if plan == nil || plan.GetOrganizationRef() != orgRef {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !plan.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plan.EffectiveFrom.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, plan)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return nil, storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
|
var matches []*model.FeePlan
|
||||||
|
for _, plan := range m.plans {
|
||||||
|
if plan == nil || !plan.GetOrganizationRef().IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !plan.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plan.EffectiveFrom.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, plan)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return nil, storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.PlansStore = (*memoryPlansStore)(nil)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package fees
|
package fees
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
)
|
)
|
||||||
@@ -30,8 +31,18 @@ func WithCalculator(calculator Calculator) Option {
|
|||||||
func WithOracleClient(oracle oracleclient.Client) Option {
|
func WithOracleClient(oracle oracleclient.Client) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.oracle = oracle
|
s.oracle = oracle
|
||||||
if qc, ok := s.calculator.(*quoteCalculator); ok {
|
// Rebuild default calculator if none was injected.
|
||||||
qc.oracle = oracle
|
if s.calculator == nil {
|
||||||
|
s.calculator = internalcalculator.New(s.logger, oracle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFeeResolver injects a custom fee resolver (useful for tests).
|
||||||
|
func WithFeeResolver(r FeeResolver) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if r != nil {
|
||||||
|
s.resolver = r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeeResolver centralises plan/rule resolution with org override and global fallback.
|
||||||
|
// Implementations live under the internal/resolver package.
|
||||||
|
type FeeResolver interface {
|
||||||
|
ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error)
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
@@ -32,6 +35,7 @@ type Service struct {
|
|||||||
clock clockpkg.Clock
|
clock clockpkg.Clock
|
||||||
calculator Calculator
|
calculator Calculator
|
||||||
oracle oracleclient.Client
|
oracle oracleclient.Client
|
||||||
|
resolver FeeResolver
|
||||||
feesv1.UnimplementedFeeEngineServer
|
feesv1.UnimplementedFeeEngineServer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +56,10 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
svc.clock = clockpkg.NewSystem()
|
svc.clock = clockpkg.NewSystem()
|
||||||
}
|
}
|
||||||
if svc.calculator == nil {
|
if svc.calculator == nil {
|
||||||
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
|
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
|
||||||
|
}
|
||||||
|
if svc.resolver == nil {
|
||||||
|
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
@@ -273,15 +280,34 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
|||||||
bookedAt = intent.GetBookedAt().AsTime()
|
bookedAt = intent.GetBookedAt().AsTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
|
var orgPtr *primitive.ObjectID
|
||||||
if err != nil {
|
if !orgRef.IsZero() {
|
||||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
orgPtr = &orgRef
|
||||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
|
||||||
}
|
|
||||||
s.logger.Warn("failed to load active fee plan", zap.Error(err))
|
|
||||||
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
|
||||||
|
case errors.Is(err, storage.ErrConflictingFeePlans):
|
||||||
|
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee plans")
|
||||||
|
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||||
|
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||||
|
default:
|
||||||
|
s.logger.Warn("failed to resolve fee rule", zap.Error(err))
|
||||||
|
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRules := plan.Rules
|
||||||
|
plan.Rules = []model.FeeRule{*rule}
|
||||||
|
defer func() {
|
||||||
|
plan.Rules = originalRules
|
||||||
|
}()
|
||||||
|
|
||||||
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
||||||
if calcErr != nil {
|
if calcErr != nil {
|
||||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package fees
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
@@ -263,11 +265,21 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
|||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
EffectiveFrom: now.Add(-time.Hour),
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{
|
||||||
|
RuleID: "stub",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 1,
|
||||||
|
Percentage: "0.01",
|
||||||
|
LedgerAccountRef: "acct:stub",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(primitive.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.SetOrganizationRef(orgRef)
|
||||||
|
|
||||||
result := &CalculationResult{
|
result := &types.CalculationResult{
|
||||||
Lines: []*feesv1.DerivedPostingLine{
|
Lines: []*feesv1.DerivedPostingLine{
|
||||||
{
|
{
|
||||||
LedgerAccountRef: "acct:stub",
|
LedgerAccountRef: "acct:stub",
|
||||||
@@ -409,7 +421,8 @@ func (s *stubRepository) Plans() storage.PlansStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stubPlansStore struct {
|
type stubPlansStore struct {
|
||||||
plan *model.FeePlan
|
plan *model.FeePlan
|
||||||
|
globalPlan *model.FeePlan
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
||||||
@@ -425,6 +438,17 @@ func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePla
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if !orgRef.IsZero() {
|
||||||
|
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
|
||||||
|
return plan, nil
|
||||||
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.FindActiveGlobalPlan(context.Background(), at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
if s.plan == nil {
|
if s.plan == nil {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
@@ -434,15 +458,31 @@ func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.Objec
|
|||||||
if !s.plan.Active {
|
if !s.plan.Active {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
|
if s.plan.EffectiveFrom.After(at) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
|
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
return s.plan, nil
|
return s.plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if s.globalPlan == nil {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if !s.globalPlan.Active {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if s.globalPlan.EffectiveFrom.After(at) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
return s.globalPlan, nil
|
||||||
|
}
|
||||||
|
|
||||||
type noopProducer struct{}
|
type noopProducer struct{}
|
||||||
|
|
||||||
func (noopProducer) SendMessage(me.Envelope) error {
|
func (noopProducer) SendMessage(me.Envelope) error {
|
||||||
@@ -458,14 +498,14 @@ func (f fixedClock) Now() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stubCalculator struct {
|
type stubCalculator struct {
|
||||||
result *CalculationResult
|
result *types.CalculationResult
|
||||||
err error
|
err error
|
||||||
called bool
|
called bool
|
||||||
gotPlan *model.FeePlan
|
gotPlan *model.FeePlan
|
||||||
bookedAt time.Time
|
bookedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||||
s.called = true
|
s.called = true
|
||||||
s.gotPlan = plan
|
s.gotPlan = plan
|
||||||
s.bookedAt = bookedAt
|
s.bookedAt = bookedAt
|
||||||
|
|||||||
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||||
|
switch trigger {
|
||||||
|
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||||
|
return model.TriggerCapture
|
||||||
|
case feesv1.Trigger_TRIGGER_REFUND:
|
||||||
|
return model.TriggerRefund
|
||||||
|
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||||
|
return model.TriggerDispute
|
||||||
|
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||||
|
return model.TriggerPayout
|
||||||
|
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||||
|
return model.TriggerFXConversion
|
||||||
|
default:
|
||||||
|
return model.TriggerUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculationResult contains derived fee lines and audit metadata.
|
||||||
|
type CalculationResult struct {
|
||||||
|
Lines []*feesv1.DerivedPostingLine
|
||||||
|
Applied []*feesv1.AppliedRule
|
||||||
|
FxUsed *feesv1.FXUsed
|
||||||
|
}
|
||||||
@@ -42,21 +42,21 @@ func (*FeePlan) Collection() string {
|
|||||||
|
|
||||||
// FeeRule represents a single pricing rule within a plan.
|
// FeeRule represents a single pricing rule within a plan.
|
||||||
type FeeRule struct {
|
type FeeRule struct {
|
||||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||||
Priority int `bson:"priority" json:"priority"`
|
Priority int `bson:"priority" json:"priority"`
|
||||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
dmath "github.com/tech/sendico/pkg/decimal"
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
@@ -53,6 +57,19 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recommended index to speed up active-plan lookups (org/global + active + dates).
|
||||||
|
activeIndex := &ri.Definition{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: m.OrganizationRefField, Sort: ri.Asc},
|
||||||
|
{Field: "active", Sort: ri.Asc},
|
||||||
|
{Field: "effectiveFrom", Sort: ri.Asc},
|
||||||
|
{Field: "effectiveTo", Sort: ri.Asc},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repo.CreateIndex(activeIndex); err != nil {
|
||||||
|
logger.Warn("failed to ensure fee plan active index", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
return &plansStore{
|
return &plansStore{
|
||||||
logger: logger.Named("plans"),
|
logger: logger.Named("plans"),
|
||||||
repo: repo,
|
repo: repo,
|
||||||
@@ -60,9 +77,13 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||||
if plan == nil {
|
if err := validatePlan(plan); err != nil {
|
||||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
return err
|
||||||
}
|
}
|
||||||
|
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
return storage.ErrDuplicateFeePlan
|
return storage.ErrDuplicateFeePlan
|
||||||
@@ -77,6 +98,13 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
|
|||||||
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
||||||
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
||||||
}
|
}
|
||||||
|
if err := validatePlan(plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := p.repo.Update(ctx, plan); err != nil {
|
if err := p.repo.Update(ctx, plan); err != nil {
|
||||||
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
@@ -99,13 +127,42 @@ func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
|
||||||
|
if orgRef.IsZero() {
|
||||||
|
return p.FindActiveGlobalPlan(ctx, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at)
|
||||||
|
if err == nil {
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return p.FindActiveGlobalPlan(ctx, at)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
if orgRef.IsZero() {
|
if orgRef.IsZero() {
|
||||||
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
||||||
}
|
}
|
||||||
|
query := repository.Query().Filter(repository.OrgField(), orgRef)
|
||||||
|
return p.findActivePlan(ctx, query, at)
|
||||||
|
}
|
||||||
|
|
||||||
limit := int64(1)
|
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
query := repository.Query().
|
globalQuery := repository.Query().Or(
|
||||||
Filter(repository.OrgField(), orgRef).
|
repository.Exists(repository.OrgField(), false),
|
||||||
|
repository.Query().Filter(repository.OrgField(), nil),
|
||||||
|
)
|
||||||
|
return p.findActivePlan(ctx, globalQuery, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.PlansStore = (*plansStore)(nil)
|
||||||
|
|
||||||
|
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
|
||||||
|
limit := int64(2)
|
||||||
|
query := orgQuery.
|
||||||
Filter(repository.Field("active"), true).
|
Filter(repository.Field("active"), true).
|
||||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
||||||
Sort(repository.Field("effectiveFrom"), false).
|
Sort(repository.Field("effectiveFrom"), false).
|
||||||
@@ -118,13 +175,13 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
var plan *model.FeePlan
|
var plans []*model.FeePlan
|
||||||
decoder := func(cursor *mongo.Cursor) error {
|
decoder := func(cursor *mongo.Cursor) error {
|
||||||
target := &model.FeePlan{}
|
target := &model.FeePlan{}
|
||||||
if err := cursor.Decode(target); err != nil {
|
if err := cursor.Decode(target); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
plan = target
|
plans = append(plans, target)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,10 +192,127 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if plan == nil {
|
if len(plans) == 0 {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
return plan, nil
|
if len(plans) > 1 {
|
||||||
|
return nil, storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
return plans[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ storage.PlansStore = (*plansStore)(nil)
|
func validatePlan(plan *model.FeePlan) error {
|
||||||
|
if plan == nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||||
|
}
|
||||||
|
if len(plan.Rules) == 0 {
|
||||||
|
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
|
||||||
|
}
|
||||||
|
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
|
||||||
|
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure unique priority per (trigger, appliesTo) combination.
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, rule := range plan.Rules {
|
||||||
|
if strings.TrimSpace(rule.Percentage) != "" {
|
||||||
|
if _, err := dmath.RatFromString(rule.Percentage); err != nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid rule percentage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.FixedAmount) != "" {
|
||||||
|
if _, err := dmath.RatFromString(rule.FixedAmount); err != nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid rule fixed amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.MinimumAmount) != "" {
|
||||||
|
if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid rule minimum amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.MaximumAmount) != "" {
|
||||||
|
if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid rule maximum amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appliesKey := normalizeAppliesTo(rule.AppliesTo)
|
||||||
|
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
|
||||||
|
if _, ok := seen[priorityKey]; ok {
|
||||||
|
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
|
||||||
|
}
|
||||||
|
seen[priorityKey] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAppliesTo(applies map[string]string) string {
|
||||||
|
if len(applies) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(applies))
|
||||||
|
for k := range applies {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, k+"="+applies[k])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error {
|
||||||
|
if plan == nil || !plan.Active {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orgQuery := repository.Query()
|
||||||
|
if plan.OrganizationRef.IsZero() {
|
||||||
|
orgQuery = repository.Query().Or(
|
||||||
|
repository.Exists(repository.OrgField(), false),
|
||||||
|
repository.Query().Filter(repository.OrgField(), nil),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
orgQuery = repository.Query().Filter(repository.OrgField(), plan.OrganizationRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
newFrom := plan.EffectiveFrom
|
||||||
|
newTo := maxTime
|
||||||
|
if plan.EffectiveTo != nil {
|
||||||
|
newTo = *plan.EffectiveTo
|
||||||
|
}
|
||||||
|
|
||||||
|
query := orgQuery.
|
||||||
|
Filter(repository.Field("active"), true).
|
||||||
|
Comparison(repository.Field("effectiveFrom"), builder.Lte, newTo).
|
||||||
|
And(repository.Query().Or(
|
||||||
|
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
||||||
|
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, newFrom),
|
||||||
|
))
|
||||||
|
|
||||||
|
if id := plan.GetID(); id != nil && !id.IsZero() {
|
||||||
|
query = query.And(repository.Query().Comparison(repository.IDField(), builder.Ne, *id))
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(1)
|
||||||
|
query = query.Limit(&limit)
|
||||||
|
|
||||||
|
var overlapFound bool
|
||||||
|
decoder := func(cursor *mongo.Cursor) error {
|
||||||
|
overlapFound = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if overlapFound {
|
||||||
|
return storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ var (
|
|||||||
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
||||||
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
||||||
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
||||||
|
// ErrConflictingFeePlans indicates multiple active plans matched a query.
|
||||||
|
ErrConflictingFeePlans = storageError("billing.fees.storage: conflicting fee plans")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository defines the root storage contract for the fees service.
|
// Repository defines the root storage contract for the fees service.
|
||||||
@@ -32,5 +34,6 @@ type PlansStore interface {
|
|||||||
Create(ctx context.Context, plan *model.FeePlan) error
|
Create(ctx context.Context, plan *model.FeePlan) error
|
||||||
Update(ctx context.Context, plan *model.FeePlan) error
|
Update(ctx context.Context, plan *model.FeePlan) error
|
||||||
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
||||||
|
// Legacy helper that now prefers an org plan and falls back to a global plan.
|
||||||
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ market:
|
|||||||
- driver: COINGECKO
|
- driver: COINGECKO
|
||||||
settings:
|
settings:
|
||||||
base_url: "https://api.coingecko.com/api/v3"
|
base_url: "https://api.coingecko.com/api/v3"
|
||||||
|
- driver: CBR
|
||||||
|
settings:
|
||||||
|
base_url: "https://www.cbr.ru"
|
||||||
pairs:
|
pairs:
|
||||||
BINANCE:
|
BINANCE:
|
||||||
- base: "USDT"
|
- base: "USDT"
|
||||||
@@ -26,6 +29,15 @@ market:
|
|||||||
- base: "USDT"
|
- base: "USDT"
|
||||||
quote: "RUB"
|
quote: "RUB"
|
||||||
symbol: "tether:rub"
|
symbol: "tether:rub"
|
||||||
|
CBR:
|
||||||
|
- base: "USD"
|
||||||
|
quote: "RUB"
|
||||||
|
symbol: "USD"
|
||||||
|
provider: "cbr"
|
||||||
|
- base: "EUR"
|
||||||
|
quote: "RUB"
|
||||||
|
symbol: "EUR"
|
||||||
|
provider: "cbr"
|
||||||
|
|
||||||
metrics:
|
metrics:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ require (
|
|||||||
github.com/tech/sendico/fx/storage v0.0.0
|
github.com/tech/sendico/fx/storage v0.0.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
|
||||||
|
golang.org/x/net v0.48.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -44,11 +45,10 @@ require (
|
|||||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.77.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/ingestor"
|
"github.com/tech/sendico/fx/ingestor/internal/ingestor"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/metrics"
|
"github.com/tech/sendico/fx/ingestor/internal/metrics"
|
||||||
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
||||||
"github.com/tech/sendico/pkg/api/routers/health"
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ type App struct {
|
|||||||
|
|
||||||
func New(logger mlogger.Logger, cfgPath string) (*App, error) {
|
func New(logger mlogger.Logger, cfgPath string) (*App, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return nil, fmerrors.New("app: logger is nil")
|
return nil, merrors.InvalidArgument("app: logger is nil")
|
||||||
}
|
}
|
||||||
path := strings.TrimSpace(cfgPath)
|
path := strings.TrimSpace(cfgPath)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,33 +25,33 @@ type Config struct {
|
|||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, fmerrors.New("config: path is empty")
|
return nil, merrors.InvalidArgument("config: path is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("config: failed to read file", err)
|
return nil, merrors.InternalWrap(err, "config: failed to read file")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
|
return nil, merrors.InternalWrap(err, "config: failed to parse yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Market.Sources) == 0 {
|
if len(cfg.Market.Sources) == 0 {
|
||||||
return nil, fmerrors.New("config: no market sources configured")
|
return nil, merrors.InvalidArgument("config: no market sources configured")
|
||||||
}
|
}
|
||||||
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
|
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
|
||||||
for idx := range cfg.Market.Sources {
|
for idx := range cfg.Market.Sources {
|
||||||
src := &cfg.Market.Sources[idx]
|
src := &cfg.Market.Sources[idx]
|
||||||
if src.Driver.IsEmpty() {
|
if src.Driver.IsEmpty() {
|
||||||
return nil, fmerrors.New("config: market source driver is empty")
|
return nil, merrors.InvalidArgument("config: market source driver is empty")
|
||||||
}
|
}
|
||||||
sourceSet[src.Driver] = struct{}{}
|
sourceSet[src.Driver] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Market.Pairs) == 0 {
|
if len(cfg.Market.Pairs) == 0 {
|
||||||
return nil, fmerrors.New("config: no pairs configured")
|
return nil, merrors.InvalidArgument("config: no pairs configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
|
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
|
||||||
@@ -61,10 +61,10 @@ func Load(path string) (*Config, error) {
|
|||||||
for rawSource, pairList := range cfg.Market.Pairs {
|
for rawSource, pairList := range cfg.Market.Pairs {
|
||||||
driver := mmodel.Driver(rawSource)
|
driver := mmodel.Driver(rawSource)
|
||||||
if driver.IsEmpty() {
|
if driver.IsEmpty() {
|
||||||
return nil, fmerrors.New("config: pair source is empty")
|
return nil, merrors.InvalidArgument("config: pair source is empty")
|
||||||
}
|
}
|
||||||
if _, ok := sourceSet[driver]; !ok {
|
if _, ok := sourceSet[driver]; !ok {
|
||||||
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
|
return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
processed := make([]PairConfig, len(pairList))
|
processed := make([]PairConfig, len(pairList))
|
||||||
@@ -74,7 +74,7 @@ func Load(path string) (*Config, error) {
|
|||||||
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
|
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
|
||||||
pair.Symbol = strings.TrimSpace(pair.Symbol)
|
pair.Symbol = strings.TrimSpace(pair.Symbol)
|
||||||
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
|
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
|
||||||
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
|
return nil, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(pair.Provider) == "" {
|
if strings.TrimSpace(pair.Provider) == "" {
|
||||||
pair.Provider = strings.ToLower(driver.String())
|
pair.Provider = strings.ToLower(driver.String())
|
||||||
@@ -93,7 +93,7 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.pairsBySource = pairsBySource
|
cfg.pairsBySource = pairsBySource
|
||||||
cfg.pairs = flattened
|
cfg.pairs = flattened
|
||||||
if cfg.Database == nil {
|
if cfg.Database == nil {
|
||||||
return nil, fmerrors.New("config: database configuration is required")
|
return nil, merrors.InvalidArgument("config: database configuration is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Metrics != nil && cfg.Metrics.Enabled {
|
if cfg.Metrics != nil && cfg.Metrics.Enabled {
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package fmerrors
|
|
||||||
|
|
||||||
type Error struct {
|
|
||||||
message string
|
|
||||||
cause error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Error) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if e.cause == nil {
|
|
||||||
return e.message
|
|
||||||
}
|
|
||||||
return e.message + ": " + e.cause.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Error) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.cause
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(message string) error {
|
|
||||||
return &Error{message: message}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Wrap(message string, cause error) error {
|
|
||||||
return &Error{message: message, cause: cause}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDecimal(value string) error {
|
|
||||||
return &Error{message: "invalid decimal \"" + value + "\""}
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market"
|
"github.com/tech/sendico/fx/ingestor/internal/market"
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
"github.com/tech/sendico/fx/storage"
|
"github.com/tech/sendico/fx/storage"
|
||||||
"github.com/tech/sendico/fx/storage/model"
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -26,18 +26,18 @@ type Service struct {
|
|||||||
|
|
||||||
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
|
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return nil, fmerrors.New("ingestor: nil logger")
|
return nil, merrors.InvalidArgument("ingestor: nil logger")
|
||||||
}
|
}
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil, fmerrors.New("ingestor: nil config")
|
return nil, merrors.InvalidArgument("ingestor: nil config")
|
||||||
}
|
}
|
||||||
if repo == nil {
|
if repo == nil {
|
||||||
return nil, fmerrors.New("ingestor: nil repository")
|
return nil, merrors.InvalidArgument("ingestor: nil repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
|
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("build connectors", err)
|
return nil, merrors.InternalWrap(err, "build connectors")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
@@ -110,21 +110,21 @@ func (s *Service) pollOnce(ctx context.Context) error {
|
|||||||
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||||
connector, ok := s.connectors[pair.Source]
|
connector, ok := s.connectors[pair.Source]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
|
return merrors.InvalidArgument("connector not configured for source "+pair.Source.String(), "source")
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmerrors.Wrap("fetch ticker", err)
|
return merrors.InternalWrap(err, "fetch ticker")
|
||||||
}
|
}
|
||||||
|
|
||||||
bid, err := parseDecimal(ticker.BidPrice)
|
bid, err := parseDecimal(ticker.BidPrice)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmerrors.Wrap("parse bid price", err)
|
return merrors.InvalidArgumentWrap(err, "parse bid price", "bid")
|
||||||
}
|
}
|
||||||
ask, err := parseDecimal(ticker.AskPrice)
|
ask, err := parseDecimal(ticker.AskPrice)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmerrors.Wrap("parse ask price", err)
|
return merrors.InvalidArgumentWrap(err, "parse ask price", "ask")
|
||||||
}
|
}
|
||||||
|
|
||||||
if pair.Invert {
|
if pair.Invert {
|
||||||
@@ -166,7 +166,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
|
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
|
||||||
return fmerrors.Wrap("upsert snapshot", err)
|
return merrors.InternalWrap(err, "upsert snapshot")
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Debug("Snapshot ingested",
|
s.logger.Debug("Snapshot ingested",
|
||||||
@@ -183,7 +183,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
|||||||
func parseDecimal(value string) (*big.Rat, error) {
|
func parseDecimal(value string) (*big.Rat, error) {
|
||||||
r := new(big.Rat)
|
r := new(big.Rat)
|
||||||
if _, ok := r.SetString(value); !ok {
|
if _, ok := r.SetString(value); !ok {
|
||||||
return nil, fmerrors.NewDecimal(value)
|
return nil, merrors.InvalidArgument("invalid decimal \""+value+"\"", "value")
|
||||||
}
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
"github.com/tech/sendico/fx/storage"
|
"github.com/tech/sendico/fx/storage"
|
||||||
"github.com/tech/sendico/fx/storage/model"
|
"github.com/tech/sendico/fx/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ func TestServiceUpsertPairInvertsPrices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServicePollOnceReturnsFirstError(t *testing.T) {
|
func TestServicePollOnceReturnsFirstError(t *testing.T) {
|
||||||
errFetch := fmerrors.New("fetch failed")
|
errFetch := merrors.Internal("fetch failed")
|
||||||
connectorSuccess := &connectorStub{
|
connectorSuccess := &connectorStub{
|
||||||
id: mmarket.DriverBinance,
|
id: mmarket.DriverBinance,
|
||||||
ticker: &mmarket.Ticker{
|
ticker: &mmarket.Ticker{
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -60,7 +60,7 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
|||||||
|
|
||||||
parsed, err := url.Parse(baseURL)
|
parsed, err := url.Parse(baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("binance: invalid base url", err)
|
return nil, merrors.InvalidArgumentWrap(err, "binance: invalid base url", "base_url")
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
@@ -89,12 +89,12 @@ func (c *binanceConnector) ID() mmodel.Driver {
|
|||||||
|
|
||||||
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||||
if strings.TrimSpace(symbol) == "" {
|
if strings.TrimSpace(symbol) == "" {
|
||||||
return nil, fmerrors.New("binance: symbol is empty")
|
return nil, merrors.InvalidArgument("binance: symbol is empty", "symbol")
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint, err := url.Parse(c.base)
|
endpoint, err := url.Parse(c.base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("binance: parse base url", err)
|
return nil, merrors.InternalWrap(err, "binance: parse base url")
|
||||||
}
|
}
|
||||||
endpoint.Path = "/api/v3/ticker/bookTicker"
|
endpoint.Path = "/api/v3/ticker/bookTicker"
|
||||||
query := endpoint.Query()
|
query := endpoint.Query()
|
||||||
@@ -103,19 +103,19 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("binance: build request", err)
|
return nil, merrors.InternalWrap(err, "binance: build request")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
|
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
return nil, fmerrors.Wrap("binance: request failed", err)
|
return nil, merrors.InternalWrap(err, "binance: request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||||
return nil, fmerrors.New("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
|
return nil, merrors.Internal("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload struct {
|
var payload struct {
|
||||||
@@ -126,7 +126,7 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
|
|||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
|
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
return nil, fmerrors.Wrap("binance: decode response", err)
|
return nil, merrors.InternalWrap(err, "binance: decode response")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &mmodel.Ticker{
|
return &mmodel.Ticker{
|
||||||
|
|||||||
537
api/fx/ingestor/internal/market/cbr/connector.go
Normal file
537
api/fx/ingestor/internal/market/cbr/connector.go
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
package cbr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||||
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/net/html/charset"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cbrConnector struct {
|
||||||
|
id mmodel.Driver
|
||||||
|
provider string
|
||||||
|
client *http.Client
|
||||||
|
base string
|
||||||
|
dailyPath string
|
||||||
|
directoryPath string
|
||||||
|
dynamicPath string
|
||||||
|
logger mlogger.Logger
|
||||||
|
|
||||||
|
byISO map[string]valuteInfo
|
||||||
|
byID map[string]valuteInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCBRBaseURL = "https://www.cbr.ru"
|
||||||
|
const (
|
||||||
|
defaultDirectoryPath = "/scripts/XML_valFull.asp"
|
||||||
|
defaultDailyPath = "/scripts/XML_daily.asp"
|
||||||
|
defaultDynamicPath = "/scripts/XML_dynamic.asp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDialTimeoutSeconds = 5 * time.Second
|
||||||
|
defaultDialKeepAliveSeconds = 30 * time.Second
|
||||||
|
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
|
||||||
|
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
|
||||||
|
defaultRequestTimeoutSeconds = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
|
||||||
|
baseURL := defaultCBRBaseURL
|
||||||
|
provider := strings.ToLower(mmodel.DriverCBR.String())
|
||||||
|
dialTimeout := defaultDialTimeoutSeconds
|
||||||
|
dialKeepAlive := defaultDialKeepAliveSeconds
|
||||||
|
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
|
||||||
|
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
|
||||||
|
requestTimeout := defaultRequestTimeoutSeconds
|
||||||
|
directoryPath := defaultDirectoryPath
|
||||||
|
dailyPath := defaultDailyPath
|
||||||
|
dynamicPath := defaultDynamicPath
|
||||||
|
|
||||||
|
if settings != nil {
|
||||||
|
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
baseURL = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
provider = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["directory_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
directoryPath = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["daily_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
dailyPath = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
dynamicPath = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||||
|
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||||
|
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||||
|
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
|
||||||
|
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgumentWrap(err, "cbr: invalid base url", "base_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
var transport http.RoundTripper = &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
|
||||||
|
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||||
|
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
if customTransport, ok := settings["http_round_tripper"].(http.RoundTripper); ok && customTransport != nil {
|
||||||
|
transport = customTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := &cbrConnector{
|
||||||
|
id: mmodel.DriverCBR,
|
||||||
|
provider: provider,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: requestTimeout,
|
||||||
|
Transport: transport,
|
||||||
|
},
|
||||||
|
base: strings.TrimRight(parsed.String(), "/"),
|
||||||
|
dailyPath: dailyPath,
|
||||||
|
directoryPath: directoryPath,
|
||||||
|
dynamicPath: dynamicPath,
|
||||||
|
logger: logger.Named("cbr"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := connector.refreshDirectory(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return connector, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) ID() mmodel.Driver {
|
||||||
|
return c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||||
|
isoCode, asOfDate, err := parseSymbol(symbol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
valute, ok := c.byISO[isoCode]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("cbr: unknown currency "+isoCode, "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
var price string
|
||||||
|
if asOfDate != nil {
|
||||||
|
price, err = c.fetchHistoricalRate(ctx, valute, *asOfDate)
|
||||||
|
} else {
|
||||||
|
price, err = c.fetchDailyRate(ctx, valute)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return &mmodel.Ticker{
|
||||||
|
Symbol: formatSymbol(isoCode, asOfDate),
|
||||||
|
BidPrice: price,
|
||||||
|
AskPrice: price,
|
||||||
|
Provider: c.provider,
|
||||||
|
Timestamp: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) refreshDirectory() error {
|
||||||
|
endpoint, err := c.buildURL(c.directoryPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.InternalWrap(err, "cbr: build directory request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("CBR directory request failed", zap.Error(err))
|
||||||
|
return merrors.InternalWrap(err, "cbr: directory request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||||
|
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(resp.Body)
|
||||||
|
decoder.CharsetReader = charset.NewReaderLabel
|
||||||
|
|
||||||
|
var directory valuteDirectory
|
||||||
|
if err := decoder.Decode(&directory); err != nil {
|
||||||
|
c.logger.Warn("CBR directory decode failed", zap.Error(err))
|
||||||
|
return merrors.InternalWrap(err, "cbr: decode directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping, err := buildValuteMapping(directory.Items)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.byISO = mapping.byISO
|
||||||
|
c.byID = mapping.byID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
|
||||||
|
endpoint, err := c.buildURL(c.dailyPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: build daily request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: daily request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||||
|
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(resp.Body)
|
||||||
|
decoder.CharsetReader = charset.NewReaderLabel
|
||||||
|
|
||||||
|
var payload dailyRates
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
c.logger.Warn("CBR daily decode failed", zap.Error(err))
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: decode daily response")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := payload.find(valute.ID)
|
||||||
|
if entry == nil {
|
||||||
|
return "", merrors.NoData("cbr: currency not found in daily rates: " + valute.ISOCharCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateDailyEntry(valute, entry); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return computePrice(entry.Value, entry.Nominal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInfo, date time.Time) (string, error) {
|
||||||
|
query := map[string]string{
|
||||||
|
"date_req1": date.Format("02/01/2006"),
|
||||||
|
"date_req2": date.Format("02/01/2006"),
|
||||||
|
"VAL_NM_RQ": valute.ID,
|
||||||
|
}
|
||||||
|
endpoint, err := c.buildURL(c.dynamicPath, query)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: build historical request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: historical request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||||
|
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(resp.Body)
|
||||||
|
decoder.CharsetReader = charset.NewReaderLabel
|
||||||
|
|
||||||
|
var payload dynamicRates
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
c.logger.Warn("CBR historical decode failed", zap.Error(err))
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: decode historical response")
|
||||||
|
}
|
||||||
|
|
||||||
|
record := payload.find(valute.ID, date)
|
||||||
|
if record == nil {
|
||||||
|
return "", merrors.NoData("cbr: historical rate not found for " + valute.ISOCharCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Nominal != "" {
|
||||||
|
nominal, err := parseNominal(record.Nominal)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.InvalidDataType(err.Error())
|
||||||
|
}
|
||||||
|
if nominal != valute.Nominal {
|
||||||
|
return "", merrors.Internal("cbr: historical nominal mismatch for " + valute.ISOCharCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return computePrice(record.Value, strconv.FormatInt(valute.Nominal, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) buildURL(path string, query map[string]string) (string, error) {
|
||||||
|
base, err := url.Parse(c.base)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: parse base url")
|
||||||
|
}
|
||||||
|
base.Path = strings.TrimRight(base.Path, "/") + path
|
||||||
|
q := base.Query()
|
||||||
|
for key, value := range query {
|
||||||
|
q.Set(key, value)
|
||||||
|
}
|
||||||
|
base.RawQuery = q.Encode()
|
||||||
|
return base.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuteDirectory struct {
|
||||||
|
Items []valuteItem `xml:"Item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuteItem struct {
|
||||||
|
ID string `xml:"ID,attr"`
|
||||||
|
ISOChar string `xml:"ISO_Char_Code"`
|
||||||
|
ISONum string `xml:"ISO_Num_Code"`
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
EngName string `xml:"EngName"`
|
||||||
|
NominalStr string `xml:"Nominal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuteInfo struct {
|
||||||
|
ID string
|
||||||
|
ISOCharCode string
|
||||||
|
ISONumCode string
|
||||||
|
Name string
|
||||||
|
EngName string
|
||||||
|
Nominal int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuteMapping struct {
|
||||||
|
byISO map[string]valuteInfo
|
||||||
|
byID map[string]valuteInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
||||||
|
byISO := make(map[string]valuteInfo, len(items))
|
||||||
|
byID := make(map[string]valuteInfo, len(items))
|
||||||
|
byNum := make(map[string]string, len(items))
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
id := strings.TrimSpace(item.ID)
|
||||||
|
isoChar := strings.ToUpper(strings.TrimSpace(item.ISOChar))
|
||||||
|
isoNum := strings.TrimSpace(item.ISONum)
|
||||||
|
name := strings.TrimSpace(item.Name)
|
||||||
|
engName := strings.TrimSpace(item.EngName)
|
||||||
|
nominal, err := parseNominal(item.NominalStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
|
||||||
|
}
|
||||||
|
if id == "" || isoChar == "" {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := valuteInfo{
|
||||||
|
ID: id,
|
||||||
|
ISOCharCode: isoChar,
|
||||||
|
ISONumCode: isoNum,
|
||||||
|
Name: name,
|
||||||
|
EngName: engName,
|
||||||
|
Nominal: nominal,
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, ok := byISO[isoChar]; ok && existing.ID != id {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar)
|
||||||
|
}
|
||||||
|
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
|
||||||
|
}
|
||||||
|
if isoNum != "" {
|
||||||
|
if existingID, ok := byNum[isoNum]; ok && existingID != id {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
|
||||||
|
}
|
||||||
|
byNum[isoNum] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
byISO[isoChar] = info
|
||||||
|
byID[id] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(byISO) == 0 {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: empty directory received")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &valuteMapping{
|
||||||
|
byISO: byISO,
|
||||||
|
byID: byID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyRates struct {
|
||||||
|
Valutes []dailyValute `xml:"Valute"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyValute struct {
|
||||||
|
ID string `xml:"ID,attr"`
|
||||||
|
NumCode string `xml:"NumCode"`
|
||||||
|
CharCode string `xml:"CharCode"`
|
||||||
|
Nominal string `xml:"Nominal"`
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
Value string `xml:"Value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dailyRates) find(id string) *dailyValute {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for idx := range d.Valutes {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(d.Valutes[idx].ID), id) {
|
||||||
|
return &d.Valutes[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dynamicRates struct {
|
||||||
|
Records []dynamicRecord `xml:"Record"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dynamicRecord struct {
|
||||||
|
ID string `xml:"Id,attr"`
|
||||||
|
DateRaw string `xml:"Date,attr"`
|
||||||
|
Nominal string `xml:"Nominal"`
|
||||||
|
Value string `xml:"Value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dynamicRates) find(id string, date time.Time) *dynamicRecord {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target := date.Format("02.01.2006")
|
||||||
|
for idx := range d.Records {
|
||||||
|
rec := &d.Records[idx]
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(rec.ID), id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rec.DateRaw) == target {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDailyEntry(expected valuteInfo, entry *dailyValute) error {
|
||||||
|
if entry == nil {
|
||||||
|
return merrors.NoData("cbr: missing daily entry")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(entry.CharCode), expected.ISOCharCode) {
|
||||||
|
return merrors.Internal("cbr: char code mismatch for " + expected.ISOCharCode)
|
||||||
|
}
|
||||||
|
if expected.ISONumCode != "" && strings.TrimSpace(entry.NumCode) != expected.ISONumCode {
|
||||||
|
return merrors.Internal("cbr: iso numeric mismatch for " + expected.ISOCharCode)
|
||||||
|
}
|
||||||
|
if expected.Name != "" && strings.TrimSpace(entry.Name) != expected.Name {
|
||||||
|
return merrors.Internal("cbr: currency name mismatch for " + expected.ISOCharCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
nominal, err := parseNominal(entry.Nominal)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.InvalidDataType("cbr: parse daily nominal: " + err.Error())
|
||||||
|
}
|
||||||
|
if nominal != expected.Nominal {
|
||||||
|
return merrors.Internal("cbr: nominal mismatch for " + expected.ISOCharCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSymbol(symbol string) (string, *time.Time, error) {
|
||||||
|
trimmed := strings.TrimSpace(symbol)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", nil, merrors.InvalidArgument("cbr: symbol is empty", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(trimmed, "@")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return "", nil, merrors.InvalidArgument("cbr: invalid symbol format", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
iso := strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||||
|
if len(iso) != 3 {
|
||||||
|
return "", nil, merrors.InvalidArgument("cbr: symbol must be ISO currency code", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return iso, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
datePart := strings.TrimSpace(parts[1])
|
||||||
|
if datePart == "" {
|
||||||
|
return "", nil, merrors.InvalidArgument("cbr: date component is empty", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.Parse("2006-01-02", datePart)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, merrors.InvalidArgumentWrap(err, "cbr: invalid date component", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
return iso, &parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNominal(value string) (int64, error) {
|
||||||
|
nominal, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
|
if err != nil || nominal <= 0 {
|
||||||
|
return 0, merrors.InvalidDataType("cbr: invalid nominal \"" + value + "\"")
|
||||||
|
}
|
||||||
|
return nominal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computePrice(value string, nominalStr string) (string, error) {
|
||||||
|
raw := strings.ReplaceAll(strings.TrimSpace(value), " ", "")
|
||||||
|
raw = strings.ReplaceAll(raw, ",", ".")
|
||||||
|
|
||||||
|
r := new(big.Rat)
|
||||||
|
if _, ok := r.SetString(raw); !ok {
|
||||||
|
return "", merrors.InvalidDataType("invalid decimal \"" + value + "\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
nominal, err := parseNominal(nominalStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
den := big.NewRat(nominal, 1)
|
||||||
|
price := new(big.Rat).Quo(r, den)
|
||||||
|
return price.FloatString(8), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSymbol(iso string, asOf *time.Time) string {
|
||||||
|
if asOf == nil {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
return iso + "@" + asOf.Format("2006-01-02")
|
||||||
|
}
|
||||||
226
api/fx/ingestor/internal/market/cbr/connector_test.go
Normal file
226
api/fx/ingestor/internal/market/cbr/connector_test.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package cbr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchTickerDaily(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
|
||||||
|
"/scripts/XML_daily.asp": {body: dailyRatesXML},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
"request_timeout_seconds": 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker, err := conn.FetchTicker(context.Background(), "USD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchTicker returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticker.Provider != "cbr" {
|
||||||
|
t.Fatalf("unexpected provider: %s", ticker.Provider)
|
||||||
|
}
|
||||||
|
if ticker.BidPrice != "95.12340000" || ticker.AskPrice != "95.12340000" {
|
||||||
|
t.Fatalf("unexpected bid/ask: %s / %s", ticker.BidPrice, ticker.AskPrice)
|
||||||
|
}
|
||||||
|
if ticker.Symbol != "USD" {
|
||||||
|
t.Fatalf("unexpected symbol: %s", ticker.Symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTickerValidatesDailyEntry(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
|
||||||
|
"/scripts/XML_daily.asp": {body: strings.ReplaceAll(dailyRatesXML, "<CharCode>USD</CharCode>", "<CharCode>XXX</CharCode>")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.FetchTicker(context.Background(), "USD"); err == nil {
|
||||||
|
t.Fatalf("FetchTicker expected to fail due to mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTickerHistorical(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
|
||||||
|
"/scripts/XML_dynamic.asp": {
|
||||||
|
body: dynamicRatesXML,
|
||||||
|
check: func(r *http.Request) error {
|
||||||
|
if got := r.URL.Query().Get("VAL_NM_RQ"); got != "R01235" {
|
||||||
|
return fmt.Errorf("unexpected valute id: %s", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("date_req1"); got != "05/01/2023" {
|
||||||
|
return fmt.Errorf("unexpected date_req1: %s", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("date_req2"); got != "05/01/2023" {
|
||||||
|
return fmt.Errorf("unexpected date_req2: %s", got)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker, err := conn.FetchTicker(context.Background(), "USD@2023-01-05")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchTicker returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticker.BidPrice != "70.10000000" || ticker.AskPrice != "70.10000000" {
|
||||||
|
t.Fatalf("unexpected bid/ask: %s / %s", ticker.BidPrice, ticker.AskPrice)
|
||||||
|
}
|
||||||
|
if ticker.Symbol != "USD@2023-01-05" {
|
||||||
|
t.Fatalf("unexpected symbol: %s", ticker.Symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTickerUnknownCurrency(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
|
||||||
|
"/scripts/XML_daily.asp": {body: dailyRatesXML},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.FetchTicker(context.Background(), "ZZZ")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("FetchTicker expected to fail for unknown currency")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||||
|
t.Fatalf("expected invalid argument error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTickerRespectsCustomPaths(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/dir.xml": {body: valuteDirectoryXML},
|
||||||
|
"/rates.xml": {body: dailyRatesXML},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"directory_path": "/dir.xml",
|
||||||
|
"daily_path": "/rates.xml",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.FetchTicker(context.Background(), "USD"); err != nil {
|
||||||
|
t.Fatalf("FetchTicker returned error with custom paths: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valuteDirectoryXML = `
|
||||||
|
<Valuta name="Foreign Currency Market">
|
||||||
|
<Item ID="R01235">
|
||||||
|
<ISO_Num_Code>840</ISO_Num_Code>
|
||||||
|
<ISO_Char_Code>USD</ISO_Char_Code>
|
||||||
|
<Nominal>1</Nominal>
|
||||||
|
<Name>US Dollar</Name>
|
||||||
|
<EngName>US Dollar</EngName>
|
||||||
|
</Item>
|
||||||
|
</Valuta>`
|
||||||
|
|
||||||
|
const dailyRatesXML = `
|
||||||
|
<ValCurs Date="02.09.2024" name="Foreign Currency Market">
|
||||||
|
<Valute ID="R01235">
|
||||||
|
<NumCode>840</NumCode>
|
||||||
|
<CharCode>USD</CharCode>
|
||||||
|
<Nominal>1</Nominal>
|
||||||
|
<Name>US Dollar</Name>
|
||||||
|
<Value>95,1234</Value>
|
||||||
|
</Valute>
|
||||||
|
</ValCurs>`
|
||||||
|
|
||||||
|
const dynamicRatesXML = `
|
||||||
|
<ValCurs ID="R01235" DateRange1="05/01/2023" DateRange2="05/01/2023" name="Foreign Currency Market Dynamic">
|
||||||
|
<Record Date="05.01.2023" Id="R01235">
|
||||||
|
<Nominal>1</Nominal>
|
||||||
|
<Value>70,1</Value>
|
||||||
|
</Record>
|
||||||
|
</ValCurs>`
|
||||||
|
|
||||||
|
type stubResponse struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
check func(*http.Request) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubRoundTripper struct {
|
||||||
|
responses map[string]stubResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if s.responses == nil {
|
||||||
|
return nil, fmt.Errorf("no responses configured")
|
||||||
|
}
|
||||||
|
res, ok := s.responses[req.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path)
|
||||||
|
}
|
||||||
|
if res.check != nil {
|
||||||
|
if err := res.check(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := res.status
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Body: io.NopCloser(strings.NewReader(res.body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"text/xml"}},
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -61,7 +61,7 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
|||||||
|
|
||||||
parsed, err := url.Parse(baseURL)
|
parsed, err := url.Parse(baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("coingecko: invalid base url", err)
|
return nil, merrors.InvalidArgumentWrap(err, "coingecko: invalid base url", "base_url")
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
@@ -96,7 +96,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
|
|||||||
|
|
||||||
endpoint, err := url.Parse(c.base)
|
endpoint, err := url.Parse(c.base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("coingecko: parse base url", err)
|
return nil, merrors.InternalWrap(err, "coingecko: parse base url")
|
||||||
}
|
}
|
||||||
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
|
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
|
||||||
query := endpoint.Query()
|
query := endpoint.Query()
|
||||||
@@ -107,19 +107,19 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("coingecko: build request", err)
|
return nil, merrors.InternalWrap(err, "coingecko: build request")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
|
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
return nil, fmerrors.Wrap("coingecko: request failed", err)
|
return nil, merrors.InternalWrap(err, "coingecko: request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||||
return nil, fmerrors.New("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
|
return nil, merrors.Internal("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
decoder := json.NewDecoder(resp.Body)
|
||||||
@@ -128,21 +128,21 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
|
|||||||
var payload map[string]map[string]interface{}
|
var payload map[string]map[string]interface{}
|
||||||
if err := decoder.Decode(&payload); err != nil {
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
|
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
return nil, fmerrors.Wrap("coingecko: decode response", err)
|
return nil, merrors.InternalWrap(err, "coingecko: decode response")
|
||||||
}
|
}
|
||||||
|
|
||||||
coinData, ok := payload[coinID]
|
coinData, ok := payload[coinID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmerrors.New("coingecko: coin id not found in response")
|
return nil, merrors.Internal("coingecko: coin id not found in response")
|
||||||
}
|
}
|
||||||
priceValue, ok := coinData[vsCurrency]
|
priceValue, ok := coinData[vsCurrency]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmerrors.New("coingecko: vs currency not found in response")
|
return nil, merrors.Internal("coingecko: vs currency not found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
price, ok := toFloat(priceValue)
|
price, ok := toFloat(priceValue)
|
||||||
if !ok || price <= 0 {
|
if !ok || price <= 0 {
|
||||||
return nil, fmerrors.New("coingecko: invalid price value in response")
|
return nil, merrors.Internal("coingecko: invalid price value in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
|
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
|
||||||
@@ -171,7 +171,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
|
|||||||
func parseSymbol(symbol string) (string, string, error) {
|
func parseSymbol(symbol string) (string, string, error) {
|
||||||
trimmed := strings.TrimSpace(symbol)
|
trimmed := strings.TrimSpace(symbol)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
return "", "", fmerrors.New("coingecko: symbol is empty")
|
return "", "", merrors.InvalidArgument("coingecko: symbol is empty", "symbol")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
|
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
|
||||||
@@ -183,13 +183,13 @@ func parseSymbol(symbol string) (string, string, error) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return "", "", fmerrors.New("coingecko: symbol must be <coin_id>/<vs_currency>")
|
return "", "", merrors.InvalidArgument("coingecko: symbol must be <coin_id>/<vs_currency>", "symbol")
|
||||||
}
|
}
|
||||||
|
|
||||||
coinID := strings.TrimSpace(parts[0])
|
coinID := strings.TrimSpace(parts[0])
|
||||||
vsCurrency := strings.TrimSpace(parts[1])
|
vsCurrency := strings.TrimSpace(parts[1])
|
||||||
if coinID == "" || vsCurrency == "" {
|
if coinID == "" || vsCurrency == "" {
|
||||||
return "", "", fmerrors.New("coingecko: symbol contains empty segments")
|
return "", "", merrors.InvalidArgument("coingecko: symbol contains empty segments", "symbol")
|
||||||
}
|
}
|
||||||
|
|
||||||
return coinID, vsCurrency, nil
|
return coinID, vsCurrency, nil
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
|
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market/cbr"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
|
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
)
|
)
|
||||||
@@ -21,7 +22,7 @@ func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.
|
|||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
driver := mmodel.NormalizeDriver(cfg.Driver)
|
driver := mmodel.NormalizeDriver(cfg.Driver)
|
||||||
if driver.IsEmpty() {
|
if driver.IsEmpty() {
|
||||||
return nil, fmerrors.New("market: connector driver is empty")
|
return nil, merrors.InvalidArgument("market: connector driver is empty", "driver")
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -34,12 +35,14 @@ func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.
|
|||||||
conn, err = binance.NewConnector(logger, cfg.Settings)
|
conn, err = binance.NewConnector(logger, cfg.Settings)
|
||||||
case mmodel.DriverCoinGecko:
|
case mmodel.DriverCoinGecko:
|
||||||
conn, err = coingecko.NewConnector(logger, cfg.Settings)
|
conn, err = coingecko.NewConnector(logger, cfg.Settings)
|
||||||
|
case mmodel.DriverCBR:
|
||||||
|
conn, err = cbr.NewConnector(logger, cfg.Settings)
|
||||||
default:
|
default:
|
||||||
err = fmerrors.New("market: unsupported driver " + driver.String())
|
err = merrors.InvalidArgument("market: unsupported driver "+driver.String(), "driver")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("market: build connector "+driver.String(), err)
|
return nil, merrors.InternalWrap(err, "market: build connector "+driver.String())
|
||||||
}
|
}
|
||||||
connectors[driver] = conn
|
connectors[driver] = conn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/api/routers/health"
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ type Server interface {
|
|||||||
|
|
||||||
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
|
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return nil, fmerrors.New("metrics: logger is nil")
|
return nil, merrors.InvalidArgument("metrics: logger is nil")
|
||||||
}
|
}
|
||||||
if cfg == nil || !cfg.Enabled {
|
if cfg == nil || !cfg.Enabled {
|
||||||
logger.Debug("Metrics disabled; using noop server")
|
logger.Debug("Metrics disabled; using noop server")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Driver string
|
|||||||
const (
|
const (
|
||||||
DriverBinance Driver = "BINANCE"
|
DriverBinance Driver = "BINANCE"
|
||||||
DriverCoinGecko Driver = "COINGECKO"
|
DriverCoinGecko Driver = "COINGECKO"
|
||||||
|
DriverCBR Driver = "CBR"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (d Driver) String() string {
|
func (d Driver) String() string {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -45,10 +45,10 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
@@ -25,8 +25,8 @@ require (
|
|||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -138,8 +138,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
|||||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -147,23 +147,23 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
|||||||
@@ -55,3 +55,6 @@ key_management:
|
|||||||
namespace: ""
|
namespace: ""
|
||||||
mount_path: kv
|
mount_path: kv
|
||||||
key_prefix: gateway/chain/wallets
|
key_prefix: gateway/chain/wallets
|
||||||
|
|
||||||
|
cache:
|
||||||
|
wallet_balance_ttl_seconds: 120
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7 // indirect
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
@@ -54,7 +54,7 @@ require (
|
|||||||
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
||||||
github.com/holiman/uint256 v1.3.2 // indirect
|
github.com/holiman/uint256 v1.3.2 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
@@ -79,12 +79,12 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
|||||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7 h1:uups37roJCTtR/BrJa0WoMrxt3rzgV+Qrj+TxYyJoAo=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -17,8 +17,8 @@ github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -159,8 +159,8 @@ github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
|||||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -320,8 +320,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
@@ -329,12 +329,12 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -343,16 +343,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -362,8 +362,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
|||||||
}
|
}
|
||||||
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
||||||
if keyPrefix == "" {
|
if keyPrefix == "" {
|
||||||
keyPrefix = "chain/gateway/wallets"
|
keyPrefix = "gateway/chain/wallets"
|
||||||
}
|
}
|
||||||
|
|
||||||
clientCfg := api.DefaultConfig()
|
clientCfg := api.DefaultConfig()
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ type Imp struct {
|
|||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
*grpcapp.Config `yaml:",inline"`
|
*grpcapp.Config `yaml:",inline"`
|
||||||
Chains []chainConfig `yaml:"chains"`
|
Chains []chainConfig `yaml:"chains"`
|
||||||
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
|
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
|
||||||
KeyManagement keymanager.Config `yaml:"key_management"`
|
KeyManagement keymanager.Config `yaml:"key_management"`
|
||||||
|
Settings gatewayservice.CacheSettings `yaml:"cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chainConfig struct {
|
type chainConfig struct {
|
||||||
@@ -111,11 +112,12 @@ func (i *Imp) Start() error {
|
|||||||
gatewayservice.WithServiceWallet(walletConfig),
|
gatewayservice.WithServiceWallet(walletConfig),
|
||||||
gatewayservice.WithKeyManager(keyManager),
|
gatewayservice.WithKeyManager(keyManager),
|
||||||
gatewayservice.WithTransferExecutor(executor),
|
gatewayservice.WithTransferExecutor(executor),
|
||||||
|
gatewayservice.WithSettings(cfg.Settings),
|
||||||
}
|
}
|
||||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "chain_gateway", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/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"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
@@ -14,6 +17,8 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fallbackBalanceCacheTTL = 2 * time.Minute
|
||||||
|
|
||||||
type getWalletBalanceCommand struct {
|
type getWalletBalanceCommand struct {
|
||||||
deps Deps
|
deps Deps
|
||||||
}
|
}
|
||||||
@@ -48,30 +53,88 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
|
|||||||
|
|
||||||
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
|
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
|
||||||
if chainErr != nil {
|
if chainErr != nil {
|
||||||
c.deps.Logger.Warn("on-chain balance fetch failed, falling back to stored balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
||||||
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("stored balance not found", zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("cached balance not found", zap.String("wallet_ref", walletRef))
|
||||||
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
|
||||||
}
|
}
|
||||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
if c.isCachedBalanceStale(stored) {
|
||||||
|
c.deps.Logger.Warn("cached balance is stale",
|
||||||
|
zap.String("wallet_ref", walletRef),
|
||||||
|
zap.Time("calculated_at", stored.CalculatedAt),
|
||||||
|
zap.Duration("ttl", c.cacheTTL()),
|
||||||
|
)
|
||||||
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
|
||||||
|
}
|
||||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)})
|
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)})
|
||||||
}
|
}
|
||||||
|
|
||||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: onChainBalanceToProto(balance)})
|
calculatedAt := c.now()
|
||||||
|
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
|
||||||
|
|
||||||
|
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
||||||
|
Balance: onChainBalanceToProto(balance, calculatedAt),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func onChainBalanceToProto(balance *moneyv1.Money) *chainv1.WalletBalance {
|
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||||
if balance == nil {
|
if balance == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
zero := &moneyv1.Money{Currency: balance.Currency, Amount: "0"}
|
zero := zeroMoney(balance.Currency)
|
||||||
return &chainv1.WalletBalance{
|
return &chainv1.WalletBalance{
|
||||||
Available: balance,
|
Available: balance,
|
||||||
PendingInbound: zero,
|
PendingInbound: zero,
|
||||||
PendingOutbound: zero,
|
PendingOutbound: zero,
|
||||||
CalculatedAt: timestamppb.Now(),
|
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
|
||||||
|
if available == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record := &model.WalletBalance{
|
||||||
|
WalletRef: walletRef,
|
||||||
|
Available: shared.CloneMoney(available),
|
||||||
|
PendingInbound: zeroMoney(available.Currency),
|
||||||
|
PendingOutbound: zeroMoney(available.Currency),
|
||||||
|
CalculatedAt: calculatedAt,
|
||||||
|
}
|
||||||
|
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
|
||||||
|
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *getWalletBalanceCommand) isCachedBalanceStale(balance *model.WalletBalance) bool {
|
||||||
|
if balance == nil || balance.CalculatedAt.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return c.now().After(balance.CalculatedAt.Add(c.cacheTTL()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *getWalletBalanceCommand) cacheTTL() time.Duration {
|
||||||
|
if c.deps.BalanceCacheTTL > 0 {
|
||||||
|
return c.deps.BalanceCacheTTL
|
||||||
|
}
|
||||||
|
// Fallback to sane default if not configured.
|
||||||
|
return fallbackBalanceCacheTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *getWalletBalanceCommand) now() time.Time {
|
||||||
|
if c.deps.Clock != nil {
|
||||||
|
return c.deps.Clock.Now().UTC()
|
||||||
|
}
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func zeroMoney(currency string) *moneyv1.Money {
|
||||||
|
if strings.TrimSpace(currency) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{Currency: currency, Amount: "0"}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package wallet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ type Deps struct {
|
|||||||
Networks map[string]shared.Network
|
Networks map[string]shared.Network
|
||||||
KeyManager keymanager.Manager
|
KeyManager keymanager.Manager
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
|
Clock clockpkg.Clock
|
||||||
|
BalanceCacheTTL time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,3 +67,10 @@ func WithClock(clk clockpkg.Clock) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSettings applies gateway settings.
|
||||||
|
func WithSettings(settings CacheSettings) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.settings = settings.withDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type Service struct {
|
|||||||
producer msg.Producer
|
producer msg.Producer
|
||||||
clock clockpkg.Clock
|
clock clockpkg.Clock
|
||||||
|
|
||||||
|
settings CacheSettings
|
||||||
|
|
||||||
networks map[string]shared.Network
|
networks map[string]shared.Network
|
||||||
serviceWallet shared.ServiceWallet
|
serviceWallet shared.ServiceWallet
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
@@ -52,6 +54,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
storage: repo,
|
storage: repo,
|
||||||
producer: producer,
|
producer: producer,
|
||||||
clock: clockpkg.System{},
|
clock: clockpkg.System{},
|
||||||
|
settings: defaultSettings(),
|
||||||
networks: map[string]shared.Network{},
|
networks: map[string]shared.Network{},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
if svc.networks == nil {
|
if svc.networks == nil {
|
||||||
svc.networks = map[string]shared.Network{}
|
svc.networks = map[string]shared.Network{}
|
||||||
}
|
}
|
||||||
|
svc.settings = svc.settings.withDefaults()
|
||||||
|
|
||||||
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
||||||
Wallet: commandsWalletDeps(svc),
|
Wallet: commandsWalletDeps(svc),
|
||||||
@@ -130,6 +134,8 @@ func commandsWalletDeps(s *Service) wallet.Deps {
|
|||||||
Networks: s.networks,
|
Networks: s.networks,
|
||||||
KeyManager: s.keyManager,
|
KeyManager: s.keyManager,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
|
Clock: s.clock,
|
||||||
|
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
api/gateway/chain/internal/service/gateway/settings.go
Normal file
30
api/gateway/chain/internal/service/gateway/settings.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
||||||
|
|
||||||
|
// CacheSettings holds tunable gateway behaviour.
|
||||||
|
type CacheSettings struct {
|
||||||
|
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSettings() CacheSettings {
|
||||||
|
return CacheSettings{
|
||||||
|
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CacheSettings) withDefaults() CacheSettings {
|
||||||
|
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||||
|
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
|
||||||
|
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||||
|
return defaultWalletBalanceCacheTTL
|
||||||
|
}
|
||||||
|
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
||||||
|
}
|
||||||
@@ -13,5 +13,5 @@ func factory(logger mlogger.Logger, file string, debug bool) (server.Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
smain.RunServer("main", appversion.Create(), factory)
|
smain.RunServer("gateway", appversion.Create(), factory)
|
||||||
}
|
}
|
||||||
|
|||||||
1
api/gateway/mntx/.gitignore
vendored
Normal file
1
api/gateway/mntx/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/mntx-gateway
|
||||||
46
api/gateway/mntx/README.md
Normal file
46
api/gateway/mntx/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Monetix Gateway – Card Payouts
|
||||||
|
|
||||||
|
This service now supports Monetix “payout by card”.
|
||||||
|
|
||||||
|
## Runtime entry points
|
||||||
|
- gRPC: `MntxGatewayService.CreateCardPayout` and `GetCardPayoutStatus`.
|
||||||
|
- Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications.
|
||||||
|
- Metrics: Prometheus on `:9404/metrics`.
|
||||||
|
|
||||||
|
## Required config/env
|
||||||
|
`api/gateway/mntx/config.yml` shows defaults. Key values (usually injected via env):
|
||||||
|
- `MONETIX_BASE_URL` – e.g. `https://gate.monetix.com`
|
||||||
|
- `MONETIX_PROJECT_ID` – integer project ID
|
||||||
|
- `MONETIX_SECRET_KEY` – signature secret
|
||||||
|
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
|
||||||
|
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
|
||||||
|
|
||||||
|
## Outbound request (CreateCardPayout)
|
||||||
|
Payload is built per Monetix spec:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"general": { "project_id": <int>, "payment_id": "<payout_id>", "signature": "<hmac>" },
|
||||||
|
"customer": { id, first_name, last_name, middle_name?, ip_address, zip?, country?, state?, city?, address? },
|
||||||
|
"payment": { amount: <minor_units>, currency: "<ISO-4217>" },
|
||||||
|
"card": { pan, year?, month?, card_holder }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_SECRET_KEY`.
|
||||||
|
|
||||||
|
## Callback handling
|
||||||
|
- Endpoint only accepts POST with Monetix JSON body. Signature is verified with the same HMAC-SHA256 algorithm; invalid signatures return 403.
|
||||||
|
- Maps Monetix statuses:
|
||||||
|
- `payment.status=success` AND `operation.status=success` AND `operation.code` empty/`0` → `PAYOUT_STATUS_PROCESSED`
|
||||||
|
- `processing` → `PAYOUT_STATUS_PENDING`
|
||||||
|
- otherwise → `PAYOUT_STATUS_FAILED`
|
||||||
|
- Emits `CardPayoutStatusChangedEvent` over messaging (event type: `mntx_gateway`, action: `updated`).
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
- `sendico_mntx_gateway_card_payout_requests_total{outcome}`
|
||||||
|
- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}`
|
||||||
|
- `sendico_mntx_gateway_card_payout_callbacks_total{status}`
|
||||||
|
- Existing RPC/payout counters remain for compatibility.
|
||||||
|
|
||||||
|
## Notes / PCI
|
||||||
|
- PAN is only logged in masked form; do not persist raw PAN.
|
||||||
|
- Callback allows CIDR allow-listing; leave empty to accept all while testing.
|
||||||
40
api/gateway/mntx/config.yml
Normal file
40
api/gateway/mntx/config.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50075"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9404"
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Monetix Gateway Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
|
||||||
|
monetix:
|
||||||
|
base_url_env: MONETIX_BASE_URL
|
||||||
|
project_id_env: MONETIX_PROJECT_ID
|
||||||
|
secret_key_env: MONETIX_SECRET_KEY
|
||||||
|
allowed_currencies: ["USD", "EUR"]
|
||||||
|
require_customer_address: false
|
||||||
|
request_timeout_seconds: 15
|
||||||
|
status_success: "success"
|
||||||
|
status_processing: "processing"
|
||||||
|
|
||||||
|
http:
|
||||||
|
callback:
|
||||||
|
address: ":8084"
|
||||||
|
path: "/monetix/callback"
|
||||||
|
allowed_cidrs: []
|
||||||
|
max_body_bytes: 1048576
|
||||||
4
api/gateway/mntx/entrypoint.sh
Executable file
4
api/gateway/mntx/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
exec /app/mntx-gateway "$@"
|
||||||
54
api/gateway/mntx/go.mod
Normal file
54
api/gateway/mntx/go.mod
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
module github.com/tech/sendico/gateway/mntx
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/shopspring/decimal v1.4.0
|
||||||
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
|
go.uber.org/zap v1.27.1
|
||||||
|
google.golang.org/grpc v1.77.0
|
||||||
|
google.golang.org/protobuf v1.36.10
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/nats-io/nats.go v1.47.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.4 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
|
)
|
||||||
227
api/gateway/mntx/go.sum
Normal file
227
api/gateway/mntx/go.sum
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||||
|
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||||
|
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
|
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||||
|
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||||
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||||
|
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||||
|
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||||
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/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/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||||
|
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
|
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
27
api/gateway/mntx/internal/appversion/version.go
Normal file
27
api/gateway/mntx/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package appversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/pkg/version"
|
||||||
|
vf "github.com/tech/sendico/pkg/version/factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build information. Populated at build-time.
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
Revision string
|
||||||
|
Branch string
|
||||||
|
BuildUser string
|
||||||
|
BuildDate string
|
||||||
|
)
|
||||||
|
|
||||||
|
func Create() version.Printer {
|
||||||
|
info := version.Info{
|
||||||
|
Program: "Sendico Monetix Gateway Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&info)
|
||||||
|
}
|
||||||
346
api/gateway/mntx/internal/server/internal/serverimp.go
Normal file
346
api/gateway/mntx/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
|
||||||
|
config *config
|
||||||
|
app *grpcapp.App[struct{}]
|
||||||
|
http *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
Monetix monetixConfig `yaml:"monetix"`
|
||||||
|
HTTP httpConfig `yaml:"http"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type monetixConfig struct {
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
BaseURLEnv string `yaml:"base_url_env"`
|
||||||
|
ProjectID int64 `yaml:"project_id"`
|
||||||
|
ProjectIDEnv string `yaml:"project_id_env"`
|
||||||
|
SecretKey string `yaml:"secret_key"`
|
||||||
|
SecretKeyEnv string `yaml:"secret_key_env"`
|
||||||
|
AllowedCurrencies []string `yaml:"allowed_currencies"`
|
||||||
|
RequireCustomerAddress bool `yaml:"require_customer_address"`
|
||||||
|
RequestTimeoutSeconds int `yaml:"request_timeout_seconds"`
|
||||||
|
StatusSuccess string `yaml:"status_success"`
|
||||||
|
StatusProcessing string `yaml:"status_processing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpConfig struct {
|
||||||
|
Callback callbackConfig `yaml:"callback"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type callbackConfig struct {
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
AllowedCIDRs []string `yaml:"allowed_cidrs"`
|
||||||
|
MaxBodyBytes int64 `yaml:"max_body_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initialises the Monetix gateway server implementation.
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
|
return &Imp{
|
||||||
|
logger: logger.Named("server"),
|
||||||
|
file: file,
|
||||||
|
debug: debug,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Shutdown() {
|
||||||
|
if i.app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if i.http != nil {
|
||||||
|
_ = i.http.Shutdown(ctx)
|
||||||
|
i.http = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
i.app.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
|
svc := mntxservice.NewService(logger,
|
||||||
|
mntxservice.WithProducer(producer),
|
||||||
|
mntxservice.WithMonetixConfig(monetixCfg),
|
||||||
|
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, nil, serviceFactory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.app = app
|
||||||
|
|
||||||
|
return i.app.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
|
data, err := os.ReadFile(i.file)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config{
|
||||||
|
Config: &grpcapp.Config{},
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
i.logger.Error("failed to parse configuration", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Runtime == nil {
|
||||||
|
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.GRPC == nil {
|
||||||
|
cfg.GRPC = &routers.GRPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: ":50075",
|
||||||
|
EnableReflection: true,
|
||||||
|
EnableHealth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Metrics == nil {
|
||||||
|
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9405"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) {
|
||||||
|
baseURL := strings.TrimSpace(cfg.BaseURL)
|
||||||
|
if env := strings.TrimSpace(cfg.BaseURLEnv); env != "" {
|
||||||
|
if val := strings.TrimSpace(os.Getenv(env)); val != "" {
|
||||||
|
baseURL = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := cfg.ProjectID
|
||||||
|
if projectID == 0 && strings.TrimSpace(cfg.ProjectIDEnv) != "" {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(cfg.ProjectIDEnv))
|
||||||
|
if raw != "" {
|
||||||
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
projectID = id
|
||||||
|
} else {
|
||||||
|
return monetix.Config{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "monetix.project_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := strings.TrimSpace(cfg.SecretKey)
|
||||||
|
if env := strings.TrimSpace(cfg.SecretKeyEnv); env != "" {
|
||||||
|
if val := strings.TrimSpace(os.Getenv(env)); val != "" {
|
||||||
|
secret = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(cfg.RequestTimeoutSeconds) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
statusSuccess := strings.TrimSpace(cfg.StatusSuccess)
|
||||||
|
statusProcessing := strings.TrimSpace(cfg.StatusProcessing)
|
||||||
|
|
||||||
|
return monetix.Config{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
ProjectID: projectID,
|
||||||
|
SecretKey: secret,
|
||||||
|
AllowedCurrencies: cfg.AllowedCurrencies,
|
||||||
|
RequireCustomerAddress: cfg.RequireCustomerAddress,
|
||||||
|
RequestTimeout: timeout,
|
||||||
|
StatusSuccess: statusSuccess,
|
||||||
|
StatusProcessing: statusProcessing,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type callbackRuntimeConfig struct {
|
||||||
|
Address string
|
||||||
|
Path string
|
||||||
|
AllowedCIDRs []*net.IPNet
|
||||||
|
MaxBodyBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig, error) {
|
||||||
|
addr := strings.TrimSpace(cfg.Address)
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8084"
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(cfg.Path)
|
||||||
|
if path == "" {
|
||||||
|
path = "/monetix/callback"
|
||||||
|
}
|
||||||
|
maxBody := cfg.MaxBodyBytes
|
||||||
|
if maxBody <= 0 {
|
||||||
|
maxBody = 1 << 20 // 1MB
|
||||||
|
}
|
||||||
|
|
||||||
|
var cidrs []*net.IPNet
|
||||||
|
for _, raw := range cfg.AllowedCIDRs {
|
||||||
|
clean := strings.TrimSpace(raw)
|
||||||
|
if clean == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, block, err := net.ParseCIDR(clean)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cidrs = append(cidrs, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
return callbackRuntimeConfig{
|
||||||
|
Address: addr,
|
||||||
|
Path: path,
|
||||||
|
AllowedCIDRs: cidrs,
|
||||||
|
MaxBodyBytes: maxBody,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRuntimeConfig) error {
|
||||||
|
if svc == nil {
|
||||||
|
return errors.New("nil service provided for callback server")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Address) == "" {
|
||||||
|
i.logger.Info("Monetix callback server disabled: address is empty")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := svc.ProcessMonetixCallback(r.Context(), body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
})
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.Address,
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", cfg.Address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
i.http = server
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
i.logger.Error("Monetix callback server stopped with error", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
i.logger.Info("Monetix callback server listening", zap.String("address", cfg.Address), zap.String("path", cfg.Path))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientAllowed(r *http.Request, cidrs []*net.IPNet) bool {
|
||||||
|
if len(cidrs) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
host := clientIPFromRequest(r)
|
||||||
|
if host == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, block := range cidrs {
|
||||||
|
if block.Contains(host) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIPFromRequest(r *http.Request) net.IP {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if xfwd := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xfwd != "" {
|
||||||
|
parts := strings.Split(xfwd, ",")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return net.ParseIP(host)
|
||||||
|
}
|
||||||
12
api/gateway/mntx/internal/server/server.go
Normal file
12
api/gateway/mntx/internal/server/server.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/gateway/mntx/internal/server/internal"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create constructs the Monetix gateway server implementation.
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return serverimp.Create(logger, file, debug)
|
||||||
|
}
|
||||||
134
api/gateway/mntx/internal/service/gateway/callback.go
Normal file
134
api/gateway/mntx/internal/service/gateway/callback.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type callbackPayment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Sum struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
} `json:"sum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type callbackOperation struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
CreatedDate string `json:"created_date"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
SumInitial struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
} `json:"sum_initial"`
|
||||||
|
SumConverted struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
} `json:"sum_converted"`
|
||||||
|
Provider struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
PaymentID string `json:"payment_id"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
AuthCode string `json:"auth_code"`
|
||||||
|
} `json:"provider"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type monetixCallback struct {
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
Payment callbackPayment `json:"payment"`
|
||||||
|
Account struct {
|
||||||
|
Number string `json:"number"`
|
||||||
|
} `json:"account"`
|
||||||
|
Customer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"customer"`
|
||||||
|
Operation callbackOperation `json:"operation"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
|
||||||
|
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
|
||||||
|
if s.card == nil {
|
||||||
|
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||||
|
}
|
||||||
|
return s.card.ProcessCallback(ctx, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCallbackToState(clock clockpkg.Clock, cfg monetix.Config, cb monetixCallback) (*mntxv1.CardPayoutState, string) {
|
||||||
|
status := strings.ToLower(strings.TrimSpace(cb.Payment.Status))
|
||||||
|
opStatus := strings.ToLower(strings.TrimSpace(cb.Operation.Status))
|
||||||
|
code := strings.TrimSpace(cb.Operation.Code)
|
||||||
|
|
||||||
|
outcome := monetix.OutcomeDecline
|
||||||
|
internalStatus := mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
|
|
||||||
|
if status == cfg.SuccessStatus() && opStatus == cfg.SuccessStatus() && (code == "" || code == "0") {
|
||||||
|
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
|
||||||
|
outcome = monetix.OutcomeSuccess
|
||||||
|
} else if status == cfg.ProcessingStatus() || opStatus == cfg.ProcessingStatus() {
|
||||||
|
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
|
||||||
|
outcome = monetix.OutcomeProcessing
|
||||||
|
}
|
||||||
|
|
||||||
|
now := timestamppb.New(clock.Now())
|
||||||
|
state := &mntxv1.CardPayoutState{
|
||||||
|
PayoutId: cb.Payment.ID,
|
||||||
|
ProjectId: cb.ProjectID,
|
||||||
|
CustomerId: cb.Customer.ID,
|
||||||
|
AmountMinor: cb.Payment.Sum.Amount,
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(cb.Payment.Sum.Currency)),
|
||||||
|
Status: internalStatus,
|
||||||
|
ProviderCode: cb.Operation.Code,
|
||||||
|
ProviderMessage: cb.Operation.Message,
|
||||||
|
ProviderPaymentId: fallbackProviderPaymentID(cb),
|
||||||
|
UpdatedAt: now,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
return state, outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackProviderPaymentID(cb monetixCallback) string {
|
||||||
|
if cb.Operation.Provider.PaymentID != "" {
|
||||||
|
return cb.Operation.Provider.PaymentID
|
||||||
|
}
|
||||||
|
if cb.Operation.RequestID != "" {
|
||||||
|
return cb.Operation.RequestID
|
||||||
|
}
|
||||||
|
return cb.Payment.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyCallbackSignature(cb monetixCallback, secret string) error {
|
||||||
|
expected := cb.Signature
|
||||||
|
cb.Signature = ""
|
||||||
|
calculated, err := monetix.SignPayload(cb, secret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subtleConstantTimeCompare(expected, calculated) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return merrors.DataConflict("signature mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func subtleConstantTimeCompare(a, b string) bool {
|
||||||
|
return hmac.Equal([]byte(strings.TrimSpace(a)), []byte(strings.TrimSpace(b)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "CreateCardPayout", s.handleCreateCardPayout, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
|
||||||
|
if s.card == nil {
|
||||||
|
return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.card.Submit(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "CreateCardTokenPayout", s.handleCreateCardTokenPayout, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
|
||||||
|
if s.card == nil {
|
||||||
|
return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.card.SubmitToken(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "CreateCardToken", s.handleCreateCardToken, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
|
||||||
|
if s.card == nil {
|
||||||
|
return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.card.Tokenize(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Success(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "GetCardPayoutStatus", s.handleGetCardPayoutStatus, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
|
||||||
|
if s.card == nil {
|
||||||
|
return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := s.card.Status(context.Background(), req.GetPayoutId())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
|
||||||
|
}
|
||||||
|
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayoutRequest {
|
||||||
|
if req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clean := proto.Clone(req)
|
||||||
|
r, ok := clean.(*mntxv1.CardPayoutRequest)
|
||||||
|
if !ok {
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
r.PayoutId = strings.TrimSpace(r.GetPayoutId())
|
||||||
|
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
|
||||||
|
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
|
||||||
|
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
|
||||||
|
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
|
||||||
|
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
|
||||||
|
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
|
||||||
|
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
|
||||||
|
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
|
||||||
|
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
|
||||||
|
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
|
||||||
|
r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency()))
|
||||||
|
r.CardPan = strings.TrimSpace(r.GetCardPan())
|
||||||
|
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1.CardTokenPayoutRequest {
|
||||||
|
if req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clean := proto.Clone(req)
|
||||||
|
r, ok := clean.(*mntxv1.CardTokenPayoutRequest)
|
||||||
|
if !ok {
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
r.PayoutId = strings.TrimSpace(r.GetPayoutId())
|
||||||
|
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
|
||||||
|
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
|
||||||
|
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
|
||||||
|
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
|
||||||
|
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
|
||||||
|
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
|
||||||
|
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
|
||||||
|
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
|
||||||
|
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
|
||||||
|
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
|
||||||
|
r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency()))
|
||||||
|
r.CardToken = strings.TrimSpace(r.GetCardToken())
|
||||||
|
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
|
||||||
|
r.MaskedPan = strings.TrimSpace(r.GetMaskedPan())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeCardTokenizeRequest(req *mntxv1.CardTokenizeRequest) *mntxv1.CardTokenizeRequest {
|
||||||
|
if req == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clean := proto.Clone(req)
|
||||||
|
r, ok := clean.(*mntxv1.CardTokenizeRequest)
|
||||||
|
if !ok {
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
r.RequestId = strings.TrimSpace(r.GetRequestId())
|
||||||
|
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
|
||||||
|
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
|
||||||
|
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
|
||||||
|
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
|
||||||
|
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
|
||||||
|
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
|
||||||
|
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
|
||||||
|
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
|
||||||
|
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
|
||||||
|
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
|
||||||
|
r.CardPan = strings.TrimSpace(r.GetCardPan())
|
||||||
|
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
|
||||||
|
r.CardCvv = strings.TrimSpace(r.GetCardCvv())
|
||||||
|
if card := r.GetCard(); card != nil {
|
||||||
|
card.Pan = strings.TrimSpace(card.GetPan())
|
||||||
|
card.CardHolder = strings.TrimSpace(card.GetCardHolder())
|
||||||
|
card.Cvv = strings.TrimSpace(card.GetCvv())
|
||||||
|
r.Card = card
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCardPayoutRequest(projectID int64, req *mntxv1.CardPayoutRequest) monetix.CardPayoutRequest {
|
||||||
|
card := monetix.Card{
|
||||||
|
PAN: req.GetCardPan(),
|
||||||
|
Year: int(req.GetCardExpYear()),
|
||||||
|
Month: int(req.GetCardExpMonth()),
|
||||||
|
CardHolder: req.GetCardHolder(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return monetix.CardPayoutRequest{
|
||||||
|
General: monetix.General{
|
||||||
|
ProjectID: projectID,
|
||||||
|
PaymentID: req.GetPayoutId(),
|
||||||
|
},
|
||||||
|
Customer: monetix.Customer{
|
||||||
|
ID: req.GetCustomerId(),
|
||||||
|
FirstName: req.GetCustomerFirstName(),
|
||||||
|
Middle: req.GetCustomerMiddleName(),
|
||||||
|
LastName: req.GetCustomerLastName(),
|
||||||
|
IP: req.GetCustomerIp(),
|
||||||
|
Zip: req.GetCustomerZip(),
|
||||||
|
Country: req.GetCustomerCountry(),
|
||||||
|
State: req.GetCustomerState(),
|
||||||
|
City: req.GetCustomerCity(),
|
||||||
|
Address: req.GetCustomerAddress(),
|
||||||
|
},
|
||||||
|
Payment: monetix.Payment{
|
||||||
|
Amount: req.GetAmountMinor(),
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
|
||||||
|
},
|
||||||
|
Card: card,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCardTokenPayoutRequest(projectID int64, req *mntxv1.CardTokenPayoutRequest) monetix.CardTokenPayoutRequest {
|
||||||
|
return monetix.CardTokenPayoutRequest{
|
||||||
|
General: monetix.General{
|
||||||
|
ProjectID: projectID,
|
||||||
|
PaymentID: req.GetPayoutId(),
|
||||||
|
},
|
||||||
|
Customer: monetix.Customer{
|
||||||
|
ID: req.GetCustomerId(),
|
||||||
|
FirstName: req.GetCustomerFirstName(),
|
||||||
|
Middle: req.GetCustomerMiddleName(),
|
||||||
|
LastName: req.GetCustomerLastName(),
|
||||||
|
IP: req.GetCustomerIp(),
|
||||||
|
Zip: req.GetCustomerZip(),
|
||||||
|
Country: req.GetCustomerCountry(),
|
||||||
|
State: req.GetCustomerState(),
|
||||||
|
City: req.GetCustomerCity(),
|
||||||
|
Address: req.GetCustomerAddress(),
|
||||||
|
},
|
||||||
|
Payment: monetix.Payment{
|
||||||
|
Amount: req.GetAmountMinor(),
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
|
||||||
|
},
|
||||||
|
Token: monetix.Token{
|
||||||
|
CardToken: req.GetCardToken(),
|
||||||
|
CardHolder: req.GetCardHolder(),
|
||||||
|
MaskedPAN: req.GetMaskedPan(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCardTokenizeRequest(projectID int64, req *mntxv1.CardTokenizeRequest, card *tokenizeCardInput) monetix.CardTokenizeRequest {
|
||||||
|
tokenizeCard := monetix.CardTokenize{
|
||||||
|
PAN: card.pan,
|
||||||
|
Year: int(card.year),
|
||||||
|
Month: int(card.month),
|
||||||
|
CardHolder: card.holder,
|
||||||
|
CVV: card.cvv,
|
||||||
|
}
|
||||||
|
|
||||||
|
return monetix.CardTokenizeRequest{
|
||||||
|
General: monetix.General{
|
||||||
|
ProjectID: projectID,
|
||||||
|
PaymentID: req.GetRequestId(),
|
||||||
|
},
|
||||||
|
Customer: monetix.Customer{
|
||||||
|
ID: req.GetCustomerId(),
|
||||||
|
FirstName: req.GetCustomerFirstName(),
|
||||||
|
Middle: req.GetCustomerMiddleName(),
|
||||||
|
LastName: req.GetCustomerLastName(),
|
||||||
|
IP: req.GetCustomerIp(),
|
||||||
|
Zip: req.GetCustomerZip(),
|
||||||
|
Country: req.GetCustomerCountry(),
|
||||||
|
State: req.GetCustomerState(),
|
||||||
|
City: req.GetCustomerCity(),
|
||||||
|
Address: req.GetCustomerAddress(),
|
||||||
|
},
|
||||||
|
Card: tokenizeCard,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cardPayoutStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
payouts map[string]*mntxv1.CardPayoutState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCardPayoutStore() *cardPayoutStore {
|
||||||
|
return &cardPayoutStore{
|
||||||
|
payouts: make(map[string]*mntxv1.CardPayoutState),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cardPayoutStore) Save(p *mntxv1.CardPayoutState) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(p.GetPayoutId())
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.payouts[key] = cloneCardPayoutState(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cardPayoutStore) Get(payoutID string) (*mntxv1.CardPayoutState, bool) {
|
||||||
|
id := strings.TrimSpace(payoutID)
|
||||||
|
if id == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
val, ok := s.payouts[id]
|
||||||
|
return cloneCardPayoutState(val), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCardPayoutState(p *mntxv1.CardPayoutState) *mntxv1.CardPayoutState {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := proto.Clone(p)
|
||||||
|
if cp, ok := cloned.(*mntxv1.CardPayoutState); ok {
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg monetix.Config) error {
|
||||||
|
if req == nil {
|
||||||
|
return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.GetPayoutId()) == "" {
|
||||||
|
return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerId()) == "" {
|
||||||
|
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
|
||||||
|
return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
|
||||||
|
return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerIp()) == "" {
|
||||||
|
return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.GetAmountMinor() <= 0 {
|
||||||
|
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor"))
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency()))
|
||||||
|
if currency == "" {
|
||||||
|
return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency"))
|
||||||
|
}
|
||||||
|
if !cfg.CurrencyAllowed(currency) {
|
||||||
|
return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pan := strings.TrimSpace(req.GetCardPan())
|
||||||
|
if pan == "" {
|
||||||
|
return newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card_pan"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCardHolder()) == "" {
|
||||||
|
return newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card_holder"))
|
||||||
|
}
|
||||||
|
if err := validateCardExpiryFields(req.GetCardExpMonth(), req.GetCardExpYear()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RequireCustomerAddress {
|
||||||
|
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
|
||||||
|
return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerCity()) == "" {
|
||||||
|
return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
|
||||||
|
return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerZip()) == "" {
|
||||||
|
return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCardExpiryFields(month uint32, year uint32) error {
|
||||||
|
if month == 0 || month > 12 {
|
||||||
|
return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card_exp_month"))
|
||||||
|
}
|
||||||
|
yearStr := strconv.Itoa(int(year))
|
||||||
|
if len(yearStr) < 2 || year == 0 {
|
||||||
|
return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card_exp_year"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
352
api/gateway/mntx/internal/service/gateway/card_processor.go
Normal file
352
api/gateway/mntx/internal/service/gateway/card_processor.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
messaging "github.com/tech/sendico/pkg/messaging/envelope"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
nm "github.com/tech/sendico/pkg/model/notification"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cardPayoutProcessor struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
config monetix.Config
|
||||||
|
clock clockpkg.Clock
|
||||||
|
store *cardPayoutStore
|
||||||
|
httpClient *http.Client
|
||||||
|
producer msg.Producer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCardPayoutProcessor(logger mlogger.Logger, cfg monetix.Config, clock clockpkg.Clock, store *cardPayoutStore, client *http.Client, producer msg.Producer) *cardPayoutProcessor {
|
||||||
|
return &cardPayoutProcessor{
|
||||||
|
logger: logger.Named("card_payout_processor"),
|
||||||
|
config: cfg,
|
||||||
|
clock: clock,
|
||||||
|
store: store,
|
||||||
|
httpClient: client,
|
||||||
|
producer: producer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
|
if p == nil {
|
||||||
|
return nil, merrors.Internal("card payout processor not initialised")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
||||||
|
p.logger.Warn("monetix configuration is incomplete for payout submission")
|
||||||
|
return nil, merrors.Internal("monetix configuration is incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
req = sanitizeCardPayoutRequest(req)
|
||||||
|
if err := validateCardPayoutRequest(req, p.config); err != nil {
|
||||||
|
p.logger.Warn("card payout validation failed",
|
||||||
|
zap.String("payout_id", req.GetPayoutId()),
|
||||||
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := req.GetProjectId()
|
||||||
|
if projectID == 0 {
|
||||||
|
projectID = p.config.ProjectID
|
||||||
|
}
|
||||||
|
if projectID == 0 {
|
||||||
|
p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||||
|
return nil, merrors.Internal("monetix project_id is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := timestamppb.New(p.clock.Now())
|
||||||
|
state := &mntxv1.CardPayoutState{
|
||||||
|
PayoutId: req.GetPayoutId(),
|
||||||
|
ProjectId: projectID,
|
||||||
|
CustomerId: req.GetCustomerId(),
|
||||||
|
AmountMinor: req.GetAmountMinor(),
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
|
||||||
|
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
|
||||||
|
if existing.GetCreatedAt() != nil {
|
||||||
|
state.CreatedAt = existing.GetCreatedAt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := monetix.NewClient(p.config, p.httpClient, p.logger)
|
||||||
|
apiReq := buildCardPayoutRequest(projectID, req)
|
||||||
|
result, err := client.CreateCardPayout(ctx, apiReq)
|
||||||
|
if err != nil {
|
||||||
|
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
|
state.ProviderMessage = err.Error()
|
||||||
|
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||||
|
p.store.Save(state)
|
||||||
|
p.logger.Warn("monetix payout submission failed",
|
||||||
|
zap.String("payout_id", req.GetPayoutId()),
|
||||||
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ProviderPaymentId = result.ProviderRequestID
|
||||||
|
if result.Accepted {
|
||||||
|
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
|
||||||
|
} else {
|
||||||
|
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
|
state.ProviderCode = result.ErrorCode
|
||||||
|
state.ProviderMessage = result.ErrorMessage
|
||||||
|
}
|
||||||
|
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||||
|
p.store.Save(state)
|
||||||
|
|
||||||
|
resp := &mntxv1.CardPayoutResponse{
|
||||||
|
Payout: state,
|
||||||
|
Accepted: result.Accepted,
|
||||||
|
ProviderRequestId: result.ProviderRequestID,
|
||||||
|
ErrorCode: result.ErrorCode,
|
||||||
|
ErrorMessage: result.ErrorMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
||||||
|
if p == nil {
|
||||||
|
return nil, merrors.Internal("card payout processor not initialised")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
||||||
|
p.logger.Warn("monetix configuration is incomplete for token payout submission")
|
||||||
|
return nil, merrors.Internal("monetix configuration is incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
req = sanitizeCardTokenPayoutRequest(req)
|
||||||
|
if err := validateCardTokenPayoutRequest(req, p.config); err != nil {
|
||||||
|
p.logger.Warn("card token payout validation failed",
|
||||||
|
zap.String("payout_id", req.GetPayoutId()),
|
||||||
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := req.GetProjectId()
|
||||||
|
if projectID == 0 {
|
||||||
|
projectID = p.config.ProjectID
|
||||||
|
}
|
||||||
|
if projectID == 0 {
|
||||||
|
p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||||
|
return nil, merrors.Internal("monetix project_id is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := timestamppb.New(p.clock.Now())
|
||||||
|
state := &mntxv1.CardPayoutState{
|
||||||
|
PayoutId: req.GetPayoutId(),
|
||||||
|
ProjectId: projectID,
|
||||||
|
CustomerId: req.GetCustomerId(),
|
||||||
|
AmountMinor: req.GetAmountMinor(),
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
|
||||||
|
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
|
||||||
|
if existing.GetCreatedAt() != nil {
|
||||||
|
state.CreatedAt = existing.GetCreatedAt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := monetix.NewClient(p.config, p.httpClient, p.logger)
|
||||||
|
apiReq := buildCardTokenPayoutRequest(projectID, req)
|
||||||
|
result, err := client.CreateCardTokenPayout(ctx, apiReq)
|
||||||
|
if err != nil {
|
||||||
|
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
|
state.ProviderMessage = err.Error()
|
||||||
|
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||||
|
p.store.Save(state)
|
||||||
|
p.logger.Warn("monetix token payout submission failed",
|
||||||
|
zap.String("payout_id", req.GetPayoutId()),
|
||||||
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ProviderPaymentId = result.ProviderRequestID
|
||||||
|
if result.Accepted {
|
||||||
|
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
|
||||||
|
} else {
|
||||||
|
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
|
state.ProviderCode = result.ErrorCode
|
||||||
|
state.ProviderMessage = result.ErrorMessage
|
||||||
|
}
|
||||||
|
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||||
|
p.store.Save(state)
|
||||||
|
|
||||||
|
resp := &mntxv1.CardTokenPayoutResponse{
|
||||||
|
Payout: state,
|
||||||
|
Accepted: result.Accepted,
|
||||||
|
ProviderRequestId: result.ProviderRequestID,
|
||||||
|
ErrorCode: result.ErrorCode,
|
||||||
|
ErrorMessage: result.ErrorMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) {
|
||||||
|
if p == nil {
|
||||||
|
return nil, merrors.Internal("card payout processor not initialised")
|
||||||
|
}
|
||||||
|
cardInput, err := validateCardTokenizeRequest(req, p.config)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Warn("card tokenization validation failed",
|
||||||
|
zap.String("request_id", req.GetRequestId()),
|
||||||
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := req.GetProjectId()
|
||||||
|
if projectID == 0 {
|
||||||
|
projectID = p.config.ProjectID
|
||||||
|
}
|
||||||
|
if projectID == 0 {
|
||||||
|
p.logger.Warn("monetix project_id is not configured", zap.String("request_id", req.GetRequestId()))
|
||||||
|
return nil, merrors.Internal("monetix project_id is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
req = sanitizeCardTokenizeRequest(req)
|
||||||
|
cardInput = extractTokenizeCard(req)
|
||||||
|
client := monetix.NewClient(p.config, p.httpClient, p.logger)
|
||||||
|
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
|
||||||
|
result, err := client.CreateCardTokenization(ctx, apiReq)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Warn("monetix tokenization request failed",
|
||||||
|
zap.String("request_id", req.GetRequestId()),
|
||||||
|
zap.String("customer_id", req.GetCustomerId()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &mntxv1.CardTokenizeResponse{
|
||||||
|
RequestId: req.GetRequestId(),
|
||||||
|
Success: result.Accepted,
|
||||||
|
ErrorCode: result.ErrorCode,
|
||||||
|
ErrorMessage: result.ErrorMessage,
|
||||||
|
}
|
||||||
|
resp.Token = result.Token
|
||||||
|
resp.MaskedPan = result.MaskedPAN
|
||||||
|
resp.ExpiryMonth = result.ExpiryMonth
|
||||||
|
resp.ExpiryYear = result.ExpiryYear
|
||||||
|
resp.CardBrand = result.CardBrand
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv1.CardPayoutState, error) {
|
||||||
|
if p == nil {
|
||||||
|
return nil, merrors.Internal("card payout processor not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(payoutID)
|
||||||
|
if id == "" {
|
||||||
|
p.logger.Warn("payout status requested with empty payout_id")
|
||||||
|
return nil, merrors.InvalidArgument("payout_id is required", "payout_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, ok := p.store.Get(id)
|
||||||
|
if !ok || state == nil {
|
||||||
|
p.logger.Warn("payout status not found", zap.String("payout_id", id))
|
||||||
|
return nil, merrors.NoData("payout not found")
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byte) (int, error) {
|
||||||
|
if p == nil {
|
||||||
|
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
p.logger.Warn("received empty Monetix callback payload")
|
||||||
|
return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.config.SecretKey) == "" {
|
||||||
|
p.logger.Warn("monetix secret key is not configured; cannot verify callback")
|
||||||
|
return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
var cb monetixCallback
|
||||||
|
if err := json.Unmarshal(payload, &cb); err != nil {
|
||||||
|
p.logger.Warn("failed to unmarshal Monetix callback", zap.Error(err))
|
||||||
|
return http.StatusBadRequest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(cb.Signature) == "" {
|
||||||
|
p.logger.Warn("Monetix callback signature is missing", zap.String("payout_id", cb.Payment.ID))
|
||||||
|
return http.StatusBadRequest, merrors.InvalidArgument("signature is missing")
|
||||||
|
}
|
||||||
|
if err := verifyCallbackSignature(cb, p.config.SecretKey); err != nil {
|
||||||
|
p.logger.Warn("Monetix callback signature check failed", zap.Error(err))
|
||||||
|
return http.StatusForbidden, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state, statusLabel := mapCallbackToState(p.clock, p.config, cb)
|
||||||
|
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
|
||||||
|
if existing.GetCreatedAt() != nil {
|
||||||
|
state.CreatedAt = existing.GetCreatedAt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.store.Save(state)
|
||||||
|
p.emitCardPayoutEvent(state)
|
||||||
|
monetix.ObserveCallback(statusLabel)
|
||||||
|
|
||||||
|
p.logger.Info("Monetix payout callback processed",
|
||||||
|
zap.String("payout_id", state.GetPayoutId()),
|
||||||
|
zap.String("status", statusLabel),
|
||||||
|
zap.String("provider_code", state.GetProviderCode()),
|
||||||
|
zap.String("provider_message", state.GetProviderMessage()),
|
||||||
|
zap.String("masked_account", cb.Account.Number),
|
||||||
|
)
|
||||||
|
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState) {
|
||||||
|
if state == nil || p.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state}
|
||||||
|
payload, err := protojson.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.Warn("failed to marshal payout callback event", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated))
|
||||||
|
if _, err := env.Wrap(payload); err != nil {
|
||||||
|
p.logger.Warn("failed to wrap payout callback event payload", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.producer.SendMessage(env); err != nil {
|
||||||
|
p.logger.Warn("failed to publish payout callback event", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg monetix.Config) error {
|
||||||
|
if req == nil {
|
||||||
|
return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.GetPayoutId()) == "" {
|
||||||
|
return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerId()) == "" {
|
||||||
|
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
|
||||||
|
return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
|
||||||
|
return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerIp()) == "" {
|
||||||
|
return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
|
||||||
|
}
|
||||||
|
if req.GetAmountMinor() <= 0 {
|
||||||
|
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor"))
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency()))
|
||||||
|
if currency == "" {
|
||||||
|
return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency"))
|
||||||
|
}
|
||||||
|
if !cfg.CurrencyAllowed(currency) {
|
||||||
|
return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.GetCardToken()) == "" {
|
||||||
|
return newPayoutError("missing_card_token", merrors.InvalidArgument("card_token is required", "card_token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RequireCustomerAddress {
|
||||||
|
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
|
||||||
|
return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerCity()) == "" {
|
||||||
|
return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
|
||||||
|
return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerZip()) == "" {
|
||||||
|
return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tokenizeCardInput struct {
|
||||||
|
pan string
|
||||||
|
month uint32
|
||||||
|
year uint32
|
||||||
|
holder string
|
||||||
|
cvv string
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCardTokenizeRequest(req *mntxv1.CardTokenizeRequest, cfg monetix.Config) (*tokenizeCardInput, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetRequestId()) == "" {
|
||||||
|
return nil, newPayoutError("missing_request_id", merrors.InvalidArgument("request_id is required", "request_id"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerId()) == "" {
|
||||||
|
return nil, newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
|
||||||
|
return nil, newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
|
||||||
|
return nil, newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerIp()) == "" {
|
||||||
|
return nil, newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
|
||||||
|
}
|
||||||
|
|
||||||
|
card := extractTokenizeCard(req)
|
||||||
|
if card.pan == "" {
|
||||||
|
return nil, newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card.pan"))
|
||||||
|
}
|
||||||
|
if card.holder == "" {
|
||||||
|
return nil, newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card.holder"))
|
||||||
|
}
|
||||||
|
if card.month == 0 || card.month > 12 {
|
||||||
|
return nil, newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card.exp_month"))
|
||||||
|
}
|
||||||
|
if card.year == 0 {
|
||||||
|
return nil, newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card.exp_year"))
|
||||||
|
}
|
||||||
|
if card.cvv == "" {
|
||||||
|
return nil, newPayoutError("missing_cvv", merrors.InvalidArgument("card_cvv is required", "card.cvv"))
|
||||||
|
}
|
||||||
|
if expired(card.month, card.year) {
|
||||||
|
return nil, newPayoutError("expired_card", merrors.InvalidArgument("card expiry is in the past", "card.expiry"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RequireCustomerAddress {
|
||||||
|
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
|
||||||
|
return nil, newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerCity()) == "" {
|
||||||
|
return nil, newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
|
||||||
|
return nil, newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.GetCustomerZip()) == "" {
|
||||||
|
return nil, newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTokenizeCard(req *mntxv1.CardTokenizeRequest) *tokenizeCardInput {
|
||||||
|
card := req.GetCard()
|
||||||
|
if card != nil {
|
||||||
|
return &tokenizeCardInput{
|
||||||
|
pan: strings.TrimSpace(card.GetPan()),
|
||||||
|
month: card.GetExpMonth(),
|
||||||
|
year: card.GetExpYear(),
|
||||||
|
holder: strings.TrimSpace(card.GetCardHolder()),
|
||||||
|
cvv: strings.TrimSpace(card.GetCvv()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &tokenizeCardInput{
|
||||||
|
pan: strings.TrimSpace(req.GetCardPan()),
|
||||||
|
month: req.GetCardExpMonth(),
|
||||||
|
year: req.GetCardExpYear(),
|
||||||
|
holder: strings.TrimSpace(req.GetCardHolder()),
|
||||||
|
cvv: strings.TrimSpace(req.GetCardCvv()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expired(month uint32, year uint32) bool {
|
||||||
|
now := time.Now()
|
||||||
|
y := int(year)
|
||||||
|
m := time.Month(month)
|
||||||
|
// Normalize 2-digit years: assume 2000-2099.
|
||||||
|
if y < 100 {
|
||||||
|
y += 2000
|
||||||
|
}
|
||||||
|
expiry := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, -1)
|
||||||
|
return now.After(expiry)
|
||||||
|
}
|
||||||
174
api/gateway/mntx/internal/service/gateway/metrics.go
Normal file
174
api/gateway/mntx/internal/service/gateway/metrics.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsOnce sync.Once
|
||||||
|
|
||||||
|
rpcLatency *prometheus.HistogramVec
|
||||||
|
rpcStatus *prometheus.CounterVec
|
||||||
|
|
||||||
|
payoutCounter *prometheus.CounterVec
|
||||||
|
payoutAmountTotal *prometheus.CounterVec
|
||||||
|
payoutErrorCount *prometheus.CounterVec
|
||||||
|
payoutMissedAmounts *prometheus.CounterVec
|
||||||
|
)
|
||||||
|
|
||||||
|
func initMetrics() {
|
||||||
|
metricsOnce.Do(func() {
|
||||||
|
rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "rpc_latency_seconds",
|
||||||
|
Help: "Latency distribution for Monetix gateway RPC handlers.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"method"})
|
||||||
|
|
||||||
|
rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "rpc_requests_total",
|
||||||
|
Help: "Total number of RPC invocations grouped by method and status.",
|
||||||
|
}, []string{"method", "status"})
|
||||||
|
|
||||||
|
payoutCounter = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "payouts_total",
|
||||||
|
Help: "Total payouts processed grouped by outcome.",
|
||||||
|
}, []string{"status"})
|
||||||
|
|
||||||
|
payoutAmountTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "payout_amount_total",
|
||||||
|
Help: "Total payout amount grouped by outcome and currency.",
|
||||||
|
}, []string{"status", "currency"})
|
||||||
|
|
||||||
|
payoutErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "payout_errors_total",
|
||||||
|
Help: "Payout failures grouped by reason.",
|
||||||
|
}, []string{"reason"})
|
||||||
|
|
||||||
|
payoutMissedAmounts = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "payout_missed_amount_total",
|
||||||
|
Help: "Total payout volume that failed grouped by reason and currency.",
|
||||||
|
}, []string{"reason", "currency"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeRPC(method string, err error, duration time.Duration) {
|
||||||
|
if rpcLatency != nil {
|
||||||
|
rpcLatency.WithLabelValues(method).Observe(duration.Seconds())
|
||||||
|
}
|
||||||
|
if rpcStatus != nil {
|
||||||
|
rpcStatus.WithLabelValues(method, statusLabel(err)).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func observePayoutSuccess(amount *moneyv1.Money) {
|
||||||
|
if payoutCounter != nil {
|
||||||
|
payoutCounter.WithLabelValues("processed").Inc()
|
||||||
|
}
|
||||||
|
value, currency := monetaryValue(amount)
|
||||||
|
if value > 0 && payoutAmountTotal != nil {
|
||||||
|
payoutAmountTotal.WithLabelValues("processed", currency).Add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func observePayoutError(reason string, amount *moneyv1.Money) {
|
||||||
|
reason = reasonLabel(reason)
|
||||||
|
if payoutCounter != nil {
|
||||||
|
payoutCounter.WithLabelValues("failed").Inc()
|
||||||
|
}
|
||||||
|
if payoutErrorCount != nil {
|
||||||
|
payoutErrorCount.WithLabelValues(reason).Inc()
|
||||||
|
}
|
||||||
|
value, currency := monetaryValue(amount)
|
||||||
|
if value <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payoutAmountTotal != nil {
|
||||||
|
payoutAmountTotal.WithLabelValues("failed", currency).Add(value)
|
||||||
|
}
|
||||||
|
if payoutMissedAmounts != nil {
|
||||||
|
payoutMissedAmounts.WithLabelValues(reason, currency).Add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func monetaryValue(amount *moneyv1.Money) (float64, string) {
|
||||||
|
if amount == nil {
|
||||||
|
return 0, "unknown"
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(amount.Amount)
|
||||||
|
if val == "" {
|
||||||
|
return 0, currencyLabel(amount.Currency)
|
||||||
|
}
|
||||||
|
dec, err := decimal.NewFromString(val)
|
||||||
|
if err != nil {
|
||||||
|
return 0, currencyLabel(amount.Currency)
|
||||||
|
}
|
||||||
|
f, _ := dec.Float64()
|
||||||
|
if f < 0 {
|
||||||
|
return 0, currencyLabel(amount.Currency)
|
||||||
|
}
|
||||||
|
return f, currencyLabel(amount.Currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currencyLabel(code string) string {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
if code == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
func reasonLabel(reason string) string {
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if reason == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return strings.ToLower(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(err error) string {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return "ok"
|
||||||
|
case errors.Is(err, merrors.ErrInvalidArg):
|
||||||
|
return "invalid_argument"
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return "not_found"
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return "conflict"
|
||||||
|
case errors.Is(err, merrors.ErrAccessDenied):
|
||||||
|
return "denied"
|
||||||
|
case errors.Is(err, merrors.ErrInternal):
|
||||||
|
return "internal"
|
||||||
|
default:
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCallbackStatus(status string) string {
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
|
if status == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return strings.ToLower(status)
|
||||||
|
}
|
||||||
44
api/gateway/mntx/internal/service/gateway/options.go
Normal file
44
api/gateway/mntx/internal/service/gateway/options.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
"github.com/tech/sendico/pkg/clock"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Option configures optional service dependencies.
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
// WithClock injects a custom clock (useful for tests).
|
||||||
|
func WithClock(c clock.Clock) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if c != nil {
|
||||||
|
s.clock = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProducer attaches a messaging producer to the service.
|
||||||
|
func WithProducer(p msg.Producer) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.producer = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHTTPClient injects a custom HTTP client (useful for tests).
|
||||||
|
func WithHTTPClient(client *http.Client) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if client != nil {
|
||||||
|
s.httpClient = client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMonetixConfig sets the Monetix connectivity options.
|
||||||
|
func WithMonetixConfig(cfg monetix.Config) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.config = cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
30
api/gateway/mntx/internal/service/gateway/payout_get.go
Normal file
30
api/gateway/mntx/internal/service/gateway/payout_get.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "GetPayout", s.handleGetPayout, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
|
||||||
|
ref := strings.TrimSpace(req.GetPayoutRef())
|
||||||
|
if ref == "" {
|
||||||
|
return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
|
||||||
|
}
|
||||||
|
|
||||||
|
payout, ok := s.store.Get(ref)
|
||||||
|
if !ok {
|
||||||
|
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
|
||||||
|
}
|
||||||
46
api/gateway/mntx/internal/service/gateway/payout_store.go
Normal file
46
api/gateway/mntx/internal/service/gateway/payout_store.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type payoutStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
payouts map[string]*mntxv1.Payout
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPayoutStore() *payoutStore {
|
||||||
|
return &payoutStore{
|
||||||
|
payouts: make(map[string]*mntxv1.Payout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *payoutStore) Save(p *mntxv1.Payout) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.payouts[p.GetPayoutRef()] = clonePayout(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *payoutStore) Get(ref string) (*mntxv1.Payout, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
p, ok := s.payouts[ref]
|
||||||
|
return clonePayout(p), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePayout(p *mntxv1.Payout) *mntxv1.Payout {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := proto.Clone(p)
|
||||||
|
if cp, ok := cloned.(*mntxv1.Payout); ok {
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
131
api/gateway/mntx/internal/service/gateway/payout_submit.go
Normal file
131
api/gateway/mntx/internal/service/gateway/payout_submit.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
messaging "github.com/tech/sendico/pkg/messaging/envelope"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
nm "github.com/tech/sendico/pkg/model/notification"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequest) (*mntxv1.SubmitPayoutResponse, error) {
|
||||||
|
return executeUnary(ctx, s, "SubmitPayout", s.handleSubmitPayout, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] {
|
||||||
|
payout, err := s.buildPayout(req)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.store.Save(payout)
|
||||||
|
s.emitEvent(payout, nm.NAPending)
|
||||||
|
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason()))
|
||||||
|
|
||||||
|
return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
return nil, newPayoutError("missing_idempotency_key", merrors.InvalidArgument("idempotency_key is required", "idempotency_key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
orgRef := strings.TrimSpace(req.OrganizationRef)
|
||||||
|
if orgRef == "" {
|
||||||
|
return nil, newPayoutError("missing_organization_ref", merrors.InvalidArgument("organization_ref is required", "organization_ref"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAmount(req.Amount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateDestination(req.Destination); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if reason := strings.TrimSpace(req.SimulatedFailureReason); reason != "" {
|
||||||
|
return nil, newPayoutError(normalizeReason(reason), merrors.InvalidArgument("simulated payout failure requested"))
|
||||||
|
}
|
||||||
|
|
||||||
|
now := timestamppb.New(s.clock.Now())
|
||||||
|
payout := &mntxv1.Payout{
|
||||||
|
PayoutRef: newPayoutRef(),
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
OrganizationRef: orgRef,
|
||||||
|
Destination: req.Destination,
|
||||||
|
Amount: req.Amount,
|
||||||
|
Description: strings.TrimSpace(req.Description),
|
||||||
|
Metadata: req.Metadata,
|
||||||
|
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
return payout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
|
||||||
|
outcome := clonePayout(original)
|
||||||
|
if outcome == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate async processing delay for realism.
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
outcome.UpdatedAt = timestamppb.New(s.clock.Now())
|
||||||
|
|
||||||
|
if simulatedFailure != "" {
|
||||||
|
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||||
|
outcome.FailureReason = simulatedFailure
|
||||||
|
observePayoutError(simulatedFailure, outcome.Amount)
|
||||||
|
s.store.Save(outcome)
|
||||||
|
s.emitEvent(outcome, nm.NAUpdated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
|
||||||
|
observePayoutSuccess(outcome.Amount)
|
||||||
|
s.store.Save(outcome)
|
||||||
|
s.emitEvent(outcome, nm.NAUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) {
|
||||||
|
if payout == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("failed to marshal payout event", zapError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
|
||||||
|
if _, err := env.Wrap(payload); err != nil {
|
||||||
|
s.logger.Warn("failed to wrap payout event payload", zapError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.producer.SendMessage(env); err != nil {
|
||||||
|
s.logger.Warn("failed to publish payout event", zapError(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zapError(err error) zap.Field {
|
||||||
|
return zap.Error(err)
|
||||||
|
}
|
||||||
106
api/gateway/mntx/internal/service/gateway/payout_validation.go
Normal file
106
api/gateway/mntx/internal/service/gateway/payout_validation.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateAmount(amount *moneyv1.Money) error {
|
||||||
|
if amount == nil {
|
||||||
|
return newPayoutError("missing_amount", merrors.InvalidArgument("amount is required", "amount"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(amount.Currency) == "" {
|
||||||
|
return newPayoutError("missing_currency", merrors.InvalidArgument("amount currency is required", "amount.currency"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val := strings.TrimSpace(amount.Amount)
|
||||||
|
if val == "" {
|
||||||
|
return newPayoutError("missing_amount_value", merrors.InvalidArgument("amount value is required", "amount.amount"))
|
||||||
|
}
|
||||||
|
dec, err := decimal.NewFromString(val)
|
||||||
|
if err != nil {
|
||||||
|
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount must be a decimal value", "amount.amount"))
|
||||||
|
}
|
||||||
|
if dec.Sign() <= 0 {
|
||||||
|
return newPayoutError("non_positive_amount", merrors.InvalidArgument("amount must be positive", "amount.amount"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDestination(dest *mntxv1.PayoutDestination) error {
|
||||||
|
if dest == nil {
|
||||||
|
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if bank := dest.GetBankAccount(); bank != nil {
|
||||||
|
return validateBankAccount(bank)
|
||||||
|
}
|
||||||
|
|
||||||
|
if card := dest.GetCard(); card != nil {
|
||||||
|
return validateCardDestination(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include bank_account or card", "destination"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBankAccount(dest *mntxv1.BankAccount) error {
|
||||||
|
if dest == nil {
|
||||||
|
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
|
||||||
|
}
|
||||||
|
iban := strings.TrimSpace(dest.Iban)
|
||||||
|
holder := strings.TrimSpace(dest.AccountHolder)
|
||||||
|
|
||||||
|
if iban == "" && holder == "" {
|
||||||
|
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include iban or account_holder", "destination"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCardDestination(card *mntxv1.CardDestination) error {
|
||||||
|
if card == nil {
|
||||||
|
return newPayoutError("missing_destination", merrors.InvalidArgument("destination.card is required", "destination.card"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pan := strings.TrimSpace(card.GetPan())
|
||||||
|
token := strings.TrimSpace(card.GetToken())
|
||||||
|
if pan == "" && token == "" {
|
||||||
|
return newPayoutError("invalid_card_destination", merrors.InvalidArgument("card destination must include pan or token", "destination.card"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(card.GetCardholderName()) == "" {
|
||||||
|
return newPayoutError("missing_cardholder_name", merrors.InvalidArgument("cardholder_name is required", "destination.card.cardholder_name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
month := strings.TrimSpace(card.GetExpMonth())
|
||||||
|
year := strings.TrimSpace(card.GetExpYear())
|
||||||
|
if pan != "" {
|
||||||
|
if err := validateExpiry(month, year); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExpiry(month, year string) error {
|
||||||
|
if month == "" || year == "" {
|
||||||
|
return newPayoutError("missing_expiry", merrors.InvalidArgument("exp_month and exp_year are required for card payouts", "destination.card.expiry"))
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := strconv.Atoi(month)
|
||||||
|
if err != nil || m < 1 || m > 12 {
|
||||||
|
return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("exp_month must be between 01 and 12", "destination.card.exp_month"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := strconv.Atoi(year); err != nil || len(year) < 2 {
|
||||||
|
return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("exp_year must be numeric", "destination.card.exp_year"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
119
api/gateway/mntx/internal/service/gateway/service.go
Normal file
119
api/gateway/mntx/internal/service/gateway/service.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
clock clockpkg.Clock
|
||||||
|
producer msg.Producer
|
||||||
|
store *payoutStore
|
||||||
|
cardStore *cardPayoutStore
|
||||||
|
config monetix.Config
|
||||||
|
httpClient *http.Client
|
||||||
|
card *cardPayoutProcessor
|
||||||
|
|
||||||
|
mntxv1.UnimplementedMntxGatewayServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
type payoutFailure interface {
|
||||||
|
error
|
||||||
|
Reason() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type reasonedError struct {
|
||||||
|
reason string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r reasonedError) Error() string {
|
||||||
|
return r.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r reasonedError) Unwrap() error {
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r reasonedError) Reason() string {
|
||||||
|
return r.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs the Monetix gateway service skeleton.
|
||||||
|
func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
||||||
|
svc := &Service{
|
||||||
|
logger: logger.Named("service"),
|
||||||
|
clock: clockpkg.NewSystem(),
|
||||||
|
store: newPayoutStore(),
|
||||||
|
cardStore: newCardPayoutStore(),
|
||||||
|
config: monetix.DefaultConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
initMetrics()
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt != nil {
|
||||||
|
opt(svc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.clock == nil {
|
||||||
|
svc.clock = clockpkg.NewSystem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.httpClient == nil {
|
||||||
|
svc.httpClient = &http.Client{Timeout: svc.config.Timeout()}
|
||||||
|
} else if svc.httpClient.Timeout <= 0 {
|
||||||
|
svc.httpClient.Timeout = svc.config.Timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.cardStore == nil {
|
||||||
|
svc.cardStore = newCardPayoutStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer)
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register wires the service onto the provided gRPC router.
|
||||||
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
|
mntxv1.RegisterMntxGatewayServiceServer(reg, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||||
|
start := svc.clock.Now()
|
||||||
|
resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req)
|
||||||
|
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPayoutRef() string {
|
||||||
|
return "pyt_" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeReason(reason string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPayoutError(reason string, err error) error {
|
||||||
|
return reasonedError{
|
||||||
|
reason: normalizeReason(reason),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
65
api/gateway/mntx/internal/service/monetix/client.go
Normal file
65
api/gateway/mntx/internal/service/monetix/client.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package monetix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
client *http.Client
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg Config, httpClient *http.Client, logger mlogger.Logger) *Client {
|
||||||
|
client := httpClient
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: cfg.timeout()}
|
||||||
|
}
|
||||||
|
cl := logger
|
||||||
|
if cl == nil {
|
||||||
|
cl = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
client: client,
|
||||||
|
logger: cl.Named("client"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateCardPayout(ctx context.Context, req CardPayoutRequest) (*CardPayoutSendResult, error) {
|
||||||
|
return c.sendCardPayout(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) {
|
||||||
|
return c.sendCardTokenPayout(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) {
|
||||||
|
return c.sendTokenization(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signPayload(payload any, secret string) (string, error) {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, []byte(secret))
|
||||||
|
if _, err := h.Write(data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignPayload exposes signature calculation for callback verification.
|
||||||
|
func SignPayload(payload any, secret string) (string, error) {
|
||||||
|
return signPayload(payload, secret)
|
||||||
|
}
|
||||||
78
api/gateway/mntx/internal/service/monetix/config.go
Normal file
78
api/gateway/mntx/internal/service/monetix/config.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package monetix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultRequestTimeout = 15 * time.Second
|
||||||
|
DefaultStatusSuccess = "success"
|
||||||
|
DefaultStatusProcessing = "processing"
|
||||||
|
|
||||||
|
OutcomeSuccess = "success"
|
||||||
|
OutcomeProcessing = "processing"
|
||||||
|
OutcomeDecline = "decline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds resolved settings for communicating with Monetix.
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string
|
||||||
|
ProjectID int64
|
||||||
|
SecretKey string
|
||||||
|
AllowedCurrencies []string
|
||||||
|
RequireCustomerAddress bool
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
StatusSuccess string
|
||||||
|
StatusProcessing string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
RequestTimeout: DefaultRequestTimeout,
|
||||||
|
StatusSuccess: DefaultStatusSuccess,
|
||||||
|
StatusProcessing: DefaultStatusProcessing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) timeout() time.Duration {
|
||||||
|
if c.RequestTimeout <= 0 {
|
||||||
|
return DefaultRequestTimeout
|
||||||
|
}
|
||||||
|
return c.RequestTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout exposes the configured HTTP timeout for external callers.
|
||||||
|
func (c Config) Timeout() time.Duration {
|
||||||
|
return c.timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) CurrencyAllowed(code string) bool {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
if code == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(c.AllowedCurrencies) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, allowed := range c.AllowedCurrencies {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(allowed), code) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) SuccessStatus() string {
|
||||||
|
if strings.TrimSpace(c.StatusSuccess) == "" {
|
||||||
|
return DefaultStatusSuccess
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(c.StatusSuccess))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) ProcessingStatus() string {
|
||||||
|
if strings.TrimSpace(c.StatusProcessing) == "" {
|
||||||
|
return DefaultStatusProcessing
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(c.StatusProcessing))
|
||||||
|
}
|
||||||
21
api/gateway/mntx/internal/service/monetix/mask.go
Normal file
21
api/gateway/mntx/internal/service/monetix/mask.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package monetix
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// MaskPAN redacts a primary account number by keeping the first 6 and last 4 digits.
|
||||||
|
func MaskPAN(pan string) string {
|
||||||
|
p := strings.TrimSpace(pan)
|
||||||
|
if len(p) <= 4 {
|
||||||
|
return strings.Repeat("*", len(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p) <= 10 {
|
||||||
|
return p[:2] + strings.Repeat("*", len(p)-4) + p[len(p)-2:]
|
||||||
|
}
|
||||||
|
|
||||||
|
maskLen := len(p) - 10
|
||||||
|
if maskLen < 0 {
|
||||||
|
maskLen = 0
|
||||||
|
}
|
||||||
|
return p[:6] + strings.Repeat("*", maskLen) + p[len(p)-4:]
|
||||||
|
}
|
||||||
71
api/gateway/mntx/internal/service/monetix/metrics.go
Normal file
71
api/gateway/mntx/internal/service/monetix/metrics.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package monetix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsOnce sync.Once
|
||||||
|
|
||||||
|
cardPayoutRequests *prometheus.CounterVec
|
||||||
|
cardPayoutCallbacks *prometheus.CounterVec
|
||||||
|
cardPayoutLatency *prometheus.HistogramVec
|
||||||
|
)
|
||||||
|
|
||||||
|
func initMetrics() {
|
||||||
|
metricsOnce.Do(func() {
|
||||||
|
cardPayoutRequests = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "card_payout_requests_total",
|
||||||
|
Help: "Monetix card payout submissions grouped by outcome.",
|
||||||
|
}, []string{"outcome"})
|
||||||
|
|
||||||
|
cardPayoutCallbacks = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "card_payout_callbacks_total",
|
||||||
|
Help: "Monetix card payout callbacks grouped by provider status.",
|
||||||
|
}, []string{"status"})
|
||||||
|
|
||||||
|
cardPayoutLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "sendico",
|
||||||
|
Subsystem: "mntx_gateway",
|
||||||
|
Name: "card_payout_request_latency_seconds",
|
||||||
|
Help: "Latency distribution for outbound Monetix card payout requests.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"outcome"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeRequest(outcome string, duration time.Duration) {
|
||||||
|
initMetrics()
|
||||||
|
outcome = strings.ToLower(strings.TrimSpace(outcome))
|
||||||
|
if outcome == "" {
|
||||||
|
outcome = "unknown"
|
||||||
|
}
|
||||||
|
if cardPayoutLatency != nil {
|
||||||
|
cardPayoutLatency.WithLabelValues(outcome).Observe(duration.Seconds())
|
||||||
|
}
|
||||||
|
if cardPayoutRequests != nil {
|
||||||
|
cardPayoutRequests.WithLabelValues(outcome).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveCallback records callback status for Monetix card payouts.
|
||||||
|
func ObserveCallback(status string) {
|
||||||
|
initMetrics()
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
|
if status == "" {
|
||||||
|
status = "unknown"
|
||||||
|
}
|
||||||
|
status = strings.ToLower(status)
|
||||||
|
if cardPayoutCallbacks != nil {
|
||||||
|
cardPayoutCallbacks.WithLabelValues(status).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
96
api/gateway/mntx/internal/service/monetix/payloads.go
Normal file
96
api/gateway/mntx/internal/service/monetix/payloads.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package monetix
|
||||||
|
|
||||||
|
type General struct {
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
PaymentID string `json:"payment_id"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Customer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Middle string `json:"middle_name,omitempty"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
IP string `json:"ip_address"`
|
||||||
|
|
||||||
|
Zip string `json:"zip,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Payment struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Card struct {
|
||||||
|
PAN string `json:"pan"`
|
||||||
|
Year int `json:"year,omitempty"`
|
||||||
|
Month int `json:"month,omitempty"`
|
||||||
|
CardHolder string `json:"card_holder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardTokenize struct {
|
||||||
|
PAN string `json:"pan"`
|
||||||
|
Year int `json:"year,omitempty"`
|
||||||
|
Month int `json:"month,omitempty"`
|
||||||
|
CardHolder string `json:"card_holder"`
|
||||||
|
CVV string `json:"cvv,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
CardToken string `json:"card_token"`
|
||||||
|
CardHolder string `json:"card_holder,omitempty"`
|
||||||
|
MaskedPAN string `json:"masked_pan,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardPayoutRequest struct {
|
||||||
|
General General `json:"general"`
|
||||||
|
Customer Customer `json:"customer"`
|
||||||
|
Payment Payment `json:"payment"`
|
||||||
|
Card Card `json:"card"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardTokenPayoutRequest struct {
|
||||||
|
General General `json:"general"`
|
||||||
|
Customer Customer `json:"customer"`
|
||||||
|
Payment Payment `json:"payment"`
|
||||||
|
Token Token `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardTokenizeRequest struct {
|
||||||
|
General General `json:"general"`
|
||||||
|
Customer Customer `json:"customer"`
|
||||||
|
Card CardTokenize `json:"card"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardPayoutSendResult struct {
|
||||||
|
Accepted bool
|
||||||
|
ProviderRequestID string
|
||||||
|
StatusCode int
|
||||||
|
ErrorCode string
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenizationResult struct {
|
||||||
|
CardPayoutSendResult
|
||||||
|
Token string
|
||||||
|
MaskedPAN string
|
||||||
|
ExpiryMonth string
|
||||||
|
ExpiryYear string
|
||||||
|
CardBrand string
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIResponse struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Operation struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"operation"`
|
||||||
|
}
|
||||||
290
api/gateway/mntx/internal/service/monetix/sender.go
Normal file
290
api/gateway/mntx/internal/service/monetix/sender.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
package monetix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
outcomeAccepted = "accepted"
|
||||||
|
outcomeHTTPError = "http_error"
|
||||||
|
outcomeNetworkError = "network_error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sendCardPayout dispatches a PAN-based payout.
|
||||||
|
func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*CardPayoutSendResult, error) {
|
||||||
|
maskedPAN := MaskPAN(req.Card.PAN)
|
||||||
|
return c.send(ctx, &req, "/v2/payment/card/payout",
|
||||||
|
func() {
|
||||||
|
c.logger.Info("dispatching Monetix card payout",
|
||||||
|
zap.String("payout_id", req.General.PaymentID),
|
||||||
|
zap.Int64("amount_minor", req.Payment.Amount),
|
||||||
|
zap.String("currency", req.Payment.Currency),
|
||||||
|
zap.String("pan", maskedPAN),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
func(r *CardPayoutSendResult) {
|
||||||
|
c.logger.Info("Monetix payout response",
|
||||||
|
zap.String("payout_id", req.General.PaymentID),
|
||||||
|
zap.Bool("accepted", r.Accepted),
|
||||||
|
zap.Int("status_code", r.StatusCode),
|
||||||
|
zap.String("provider_request_id", r.ProviderRequestID),
|
||||||
|
zap.String("error_code", r.ErrorCode),
|
||||||
|
zap.String("error_message", r.ErrorMessage),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCardTokenPayout dispatches a token-based payout.
|
||||||
|
func (c *Client) sendCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) {
|
||||||
|
return c.send(ctx, &req, "/v2/payment/card/payout/token",
|
||||||
|
func() {
|
||||||
|
c.logger.Info("dispatching Monetix card token payout",
|
||||||
|
zap.String("payout_id", req.General.PaymentID),
|
||||||
|
zap.Int64("amount_minor", req.Payment.Amount),
|
||||||
|
zap.String("currency", req.Payment.Currency),
|
||||||
|
zap.String("masked_pan", req.Token.MaskedPAN),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
func(r *CardPayoutSendResult) {
|
||||||
|
c.logger.Info("Monetix token payout response",
|
||||||
|
zap.String("payout_id", req.General.PaymentID),
|
||||||
|
zap.Bool("accepted", r.Accepted),
|
||||||
|
zap.Int("status_code", r.StatusCode),
|
||||||
|
zap.String("provider_request_id", r.ProviderRequestID),
|
||||||
|
zap.String("error_code", r.ErrorCode),
|
||||||
|
zap.String("error_message", r.ErrorMessage),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTokenization sends a tokenization request.
|
||||||
|
func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
return nil, merrors.Internal("monetix client not initialised")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.cfg.SecretKey) == "" {
|
||||||
|
return nil, merrors.Internal("monetix secret key not configured")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.cfg.BaseURL) == "" {
|
||||||
|
return nil, merrors.Internal("monetix base url not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.General.Signature = ""
|
||||||
|
signature, err := signPayload(req, c.cfg.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to sign request: " + err.Error())
|
||||||
|
}
|
||||||
|
req.General.Signature = signature
|
||||||
|
|
||||||
|
payload, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to marshal request payload: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(c.cfg.BaseURL, "/") + "/v1/tokenize"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to build request: " + err.Error())
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
c.logger.Info("dispatching Monetix card tokenization",
|
||||||
|
zap.String("request_id", req.General.PaymentID),
|
||||||
|
zap.String("masked_pan", MaskPAN(req.Card.PAN)),
|
||||||
|
)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := c.client.Do(httpReq)
|
||||||
|
duration := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
observeRequest(outcomeNetworkError, duration)
|
||||||
|
c.logger.Warn("monetix tokenization request failed", zap.Error(err))
|
||||||
|
return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
outcome := outcomeAccepted
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
outcome = outcomeHTTPError
|
||||||
|
}
|
||||||
|
observeRequest(outcome, duration)
|
||||||
|
|
||||||
|
result := &TokenizationResult{
|
||||||
|
CardPayoutSendResult: CardPayoutSendResult{
|
||||||
|
Accepted: resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp APIResponse
|
||||||
|
if len(body) > 0 {
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
c.logger.Warn("failed to decode Monetix tokenization response", zap.String("request_id", req.General.PaymentID), zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
||||||
|
} else {
|
||||||
|
var tokenData struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
MaskedPAN string `json:"masked_pan"`
|
||||||
|
ExpiryMonth string `json:"expiry_month"`
|
||||||
|
ExpiryYear string `json:"expiry_year"`
|
||||||
|
CardBrand string `json:"card_brand"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &tokenData)
|
||||||
|
result.Token = tokenData.Token
|
||||||
|
result.MaskedPAN = tokenData.MaskedPAN
|
||||||
|
result.ExpiryMonth = tokenData.ExpiryMonth
|
||||||
|
result.ExpiryYear = tokenData.ExpiryYear
|
||||||
|
result.CardBrand = tokenData.CardBrand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Operation.RequestID != "" {
|
||||||
|
result.ProviderRequestID = apiResp.Operation.RequestID
|
||||||
|
} else if apiResp.RequestID != "" {
|
||||||
|
result.ProviderRequestID = apiResp.RequestID
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Accepted {
|
||||||
|
result.ErrorCode = apiResp.Code
|
||||||
|
if result.ErrorCode == "" {
|
||||||
|
result.ErrorCode = http.StatusText(resp.StatusCode)
|
||||||
|
}
|
||||||
|
result.ErrorMessage = apiResp.Message
|
||||||
|
if result.ErrorMessage == "" {
|
||||||
|
result.ErrorMessage = apiResp.Operation.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("Monetix tokenization response",
|
||||||
|
zap.String("request_id", req.General.PaymentID),
|
||||||
|
zap.Bool("accepted", result.Accepted),
|
||||||
|
zap.Int("status_code", resp.StatusCode),
|
||||||
|
zap.String("provider_request_id", result.ProviderRequestID),
|
||||||
|
zap.String("error_code", result.ErrorCode),
|
||||||
|
zap.String("error_message", result.ErrorMessage),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(ctx context.Context, req any, path string, dispatchLog func(), responseLog func(*CardPayoutSendResult)) (*CardPayoutSendResult, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
return nil, merrors.Internal("monetix client not initialised")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.cfg.SecretKey) == "" {
|
||||||
|
return nil, merrors.Internal("monetix secret key not configured")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.cfg.BaseURL) == "" {
|
||||||
|
return nil, merrors.Internal("monetix base url not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
setSignature, err := clearSignature(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signature, err := signPayload(req, c.cfg.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to sign request: " + err.Error())
|
||||||
|
}
|
||||||
|
setSignature(signature)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to marshal request payload: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(c.cfg.BaseURL, "/") + path
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("failed to build request: " + err.Error())
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
if dispatchLog != nil {
|
||||||
|
dispatchLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := c.client.Do(httpReq)
|
||||||
|
duration := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
observeRequest(outcomeNetworkError, duration)
|
||||||
|
return nil, merrors.Internal("monetix request failed: " + err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
outcome := outcomeAccepted
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
outcome = outcomeHTTPError
|
||||||
|
}
|
||||||
|
observeRequest(outcome, duration)
|
||||||
|
|
||||||
|
result := &CardPayoutSendResult{
|
||||||
|
Accepted: resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp APIResponse
|
||||||
|
if len(body) > 0 {
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
c.logger.Warn("failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Operation.RequestID != "" {
|
||||||
|
result.ProviderRequestID = apiResp.Operation.RequestID
|
||||||
|
} else if apiResp.RequestID != "" {
|
||||||
|
result.ProviderRequestID = apiResp.RequestID
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Accepted {
|
||||||
|
result.ErrorCode = apiResp.Code
|
||||||
|
if result.ErrorCode == "" {
|
||||||
|
result.ErrorCode = http.StatusText(resp.StatusCode)
|
||||||
|
}
|
||||||
|
result.ErrorMessage = apiResp.Message
|
||||||
|
if result.ErrorMessage == "" {
|
||||||
|
result.ErrorMessage = apiResp.Operation.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if responseLog != nil {
|
||||||
|
responseLog(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSignature(req any) (func(string), error) {
|
||||||
|
switch r := req.(type) {
|
||||||
|
case *CardPayoutRequest:
|
||||||
|
r.General.Signature = ""
|
||||||
|
return func(sig string) { r.General.Signature = sig }, nil
|
||||||
|
case *CardTokenPayoutRequest:
|
||||||
|
r.General.Signature = ""
|
||||||
|
return func(sig string) { r.General.Signature = sig }, nil
|
||||||
|
case *CardTokenizeRequest:
|
||||||
|
r.General.Signature = ""
|
||||||
|
return func(sig string) { r.General.Signature = sig }, nil
|
||||||
|
default:
|
||||||
|
return nil, merrors.Internal("unsupported monetix payload type for signing")
|
||||||
|
}
|
||||||
|
}
|
||||||
17
api/gateway/mntx/main.go
Normal file
17
api/gateway/mntx/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/gateway/mntx/internal/server"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
smain "github.com/tech/sendico/pkg/server/main"
|
||||||
|
)
|
||||||
|
|
||||||
|
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return si.Create(logger, file, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
smain.RunServer("gateway", appversion.Create(), factory)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -27,7 +27,7 @@ require (
|
|||||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
@@ -46,10 +46,10 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -59,8 +59,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -178,35 +178,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
@@ -214,8 +214,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
|||||||
@@ -14,21 +14,21 @@ require (
|
|||||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/text v0.31.0
|
golang.org/x/text v0.32.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/go-test/deep v1.1.1 // indirect
|
github.com/go-test/deep v1.1.1 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
@@ -48,11 +48,11 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.77.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -65,8 +65,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -191,35 +191,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
@@ -227,8 +227,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -54,10 +54,10 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -179,35 +179,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
|
|||||||
FXQuote: cloneFXQuote(src.GetFxQuote()),
|
FXQuote: cloneFXQuote(src.GetFxQuote()),
|
||||||
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
|
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
|
||||||
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
|
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
|
||||||
|
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +221,7 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
|
|||||||
FxQuote: cloneFXQuote(src.FXQuote),
|
FxQuote: cloneFXQuote(src.FXQuote),
|
||||||
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
|
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
|
||||||
FeeQuoteToken: src.FeeQuoteToken,
|
FeeQuoteToken: src.FeeQuoteToken,
|
||||||
|
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/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"
|
||||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
@@ -19,35 +21,53 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, error) {
|
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
|
||||||
intent := req.GetIntent()
|
intent := req.GetIntent()
|
||||||
amount := intent.GetAmount()
|
amount := intent.GetAmount()
|
||||||
baseAmount := cloneMoney(amount)
|
fxSide := fxv1.Side_SIDE_UNSPECIFIED
|
||||||
feeQuote, err := s.quoteFees(ctx, orgRef, req)
|
if intent.GetFx() != nil {
|
||||||
if err != nil {
|
fxSide = intent.GetFx().GetSide()
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
feeTotal := extractFeeTotal(feeQuote.GetLines(), amount.GetCurrency())
|
|
||||||
|
var fxQuote *oraclev1.Quote
|
||||||
|
var err error
|
||||||
|
if shouldRequestFX(intent) {
|
||||||
|
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
|
||||||
|
|
||||||
|
feeBaseAmount := payAmount
|
||||||
|
if feeBaseAmount == nil {
|
||||||
|
feeBaseAmount = cloneMoney(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
feeCurrency := ""
|
||||||
|
if feeBaseAmount != nil {
|
||||||
|
feeCurrency = feeBaseAmount.GetCurrency()
|
||||||
|
} else if amount != nil {
|
||||||
|
feeCurrency = amount.GetCurrency()
|
||||||
|
}
|
||||||
|
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
|
||||||
|
|
||||||
var networkFee *chainv1.EstimateTransferFeeResponse
|
var networkFee *chainv1.EstimateTransferFeeResponse
|
||||||
if shouldEstimateNetworkFee(intent) {
|
if shouldEstimateNetworkFee(intent) {
|
||||||
networkFee, err = s.estimateNetworkFee(ctx, intent)
|
networkFee, err = s.estimateNetworkFee(ctx, intent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, time.Time{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fxQuote *oraclev1.Quote
|
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
|
||||||
if shouldRequestFX(intent) {
|
|
||||||
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debitAmount, settlementAmount := computeAggregates(baseAmount, feeTotal, networkFee)
|
quote := &orchestratorv1.PaymentQuote{
|
||||||
|
|
||||||
return &orchestratorv1.PaymentQuote{
|
|
||||||
DebitAmount: debitAmount,
|
DebitAmount: debitAmount,
|
||||||
ExpectedSettlementAmount: settlementAmount,
|
ExpectedSettlementAmount: settlementAmount,
|
||||||
ExpectedFeeTotal: feeTotal,
|
ExpectedFeeTotal: feeTotal,
|
||||||
@@ -56,17 +76,25 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
|
|||||||
FxQuote: fxQuote,
|
FxQuote: fxQuote,
|
||||||
NetworkFee: networkFee,
|
NetworkFee: networkFee,
|
||||||
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
|
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
|
||||||
|
|
||||||
|
return quote, expiresAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*feesv1.PrecomputeFeesResponse, error) {
|
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
|
||||||
if !s.fees.available() {
|
if !s.fees.available() {
|
||||||
return &feesv1.PrecomputeFeesResponse{}, nil
|
return &feesv1.PrecomputeFeesResponse{}, nil
|
||||||
}
|
}
|
||||||
intent := req.GetIntent()
|
intent := req.GetIntent()
|
||||||
|
amount := cloneMoney(baseAmount)
|
||||||
|
if amount == nil {
|
||||||
|
amount = cloneMoney(intent.GetAmount())
|
||||||
|
}
|
||||||
feeIntent := &feesv1.Intent{
|
feeIntent := &feesv1.Intent{
|
||||||
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
|
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
|
||||||
BaseAmount: cloneMoney(intent.GetAmount()),
|
BaseAmount: amount,
|
||||||
BookedAt: timestamppb.New(s.clock.Now()),
|
BookedAt: timestamppb.New(s.clock.Now()),
|
||||||
OriginType: "payments.orchestrator.quote",
|
OriginType: "payments.orchestrator.quote",
|
||||||
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
|
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||||
@@ -160,7 +188,19 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
|
|||||||
}
|
}
|
||||||
|
|
||||||
if amount := intent.GetAmount(); amount != nil {
|
if amount := intent.GetAmount(); amount != nil {
|
||||||
params.BaseAmount = cloneMoney(amount)
|
pair := fxIntent.GetPair()
|
||||||
|
if pair != nil {
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
|
||||||
|
params.BaseAmount = cloneMoney(amount)
|
||||||
|
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
|
||||||
|
params.QuoteAmount = cloneMoney(amount)
|
||||||
|
default:
|
||||||
|
params.BaseAmount = cloneMoney(amount)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.BaseAmount = cloneMoney(amount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
quote, err := s.oracle.client.GetQuote(ctx, params)
|
quote, err := s.oracle.client.GetQuote(ctx, params)
|
||||||
@@ -287,11 +327,14 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
|
|||||||
if fq == nil {
|
if fq == nil {
|
||||||
return merrors.InvalidArgument("ledger: fx quote missing")
|
return merrors.InvalidArgument("ledger: fx quote missing")
|
||||||
}
|
}
|
||||||
fromMoney := cloneMoney(fq.GetBaseAmount())
|
fxSide := fxv1.Side_SIDE_UNSPECIFIED
|
||||||
|
if intent.FX != nil {
|
||||||
|
fxSide = intent.FX.Side
|
||||||
|
}
|
||||||
|
fromMoney, toMoney := resolveTradeAmounts(intent.Amount, fq, fxSide)
|
||||||
if fromMoney == nil {
|
if fromMoney == nil {
|
||||||
fromMoney = cloneMoney(intent.Amount)
|
fromMoney = cloneMoney(intent.Amount)
|
||||||
}
|
}
|
||||||
toMoney := cloneMoney(fq.GetQuoteAmount())
|
|
||||||
if toMoney == nil {
|
if toMoney == nil {
|
||||||
toMoney = cloneMoney(quote.GetExpectedSettlementAmount())
|
toMoney = cloneMoney(quote.GetExpectedSettlementAmount())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package orchestrator
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
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"
|
||||||
)
|
)
|
||||||
@@ -108,30 +110,91 @@ func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *money
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeAggregates(base, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse) (*moneyv1.Money, *moneyv1.Money) {
|
func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) {
|
||||||
if base == nil {
|
if fxQuote == nil {
|
||||||
|
return cloneMoney(intentAmount), cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
qSide := fxQuote.GetSide()
|
||||||
|
if qSide == fxv1.Side_SIDE_UNSPECIFIED {
|
||||||
|
qSide = side
|
||||||
|
}
|
||||||
|
|
||||||
|
switch qSide {
|
||||||
|
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||||
|
pay := cloneMoney(fxQuote.GetQuoteAmount())
|
||||||
|
settle := cloneMoney(fxQuote.GetBaseAmount())
|
||||||
|
if pay == nil {
|
||||||
|
pay = cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
if settle == nil {
|
||||||
|
settle = cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
return pay, settle
|
||||||
|
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||||
|
pay := cloneMoney(fxQuote.GetBaseAmount())
|
||||||
|
settle := cloneMoney(fxQuote.GetQuoteAmount())
|
||||||
|
if pay == nil {
|
||||||
|
pay = cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
if settle == nil {
|
||||||
|
settle = cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
return pay, settle
|
||||||
|
default:
|
||||||
|
return cloneMoney(intentAmount), cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote) (*moneyv1.Money, *moneyv1.Money) {
|
||||||
|
if pay == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
baseDecimal, err := decimalFromMoney(base)
|
debitDecimal, err := decimalFromMoney(pay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cloneMoney(base), cloneMoney(base)
|
return cloneMoney(pay), cloneMoney(settlement)
|
||||||
}
|
|
||||||
debit := baseDecimal
|
|
||||||
settlement := baseDecimal
|
|
||||||
|
|
||||||
if feeDecimal, err := decimalFromMoneyMatching(base, fee); err == nil && feeDecimal != nil {
|
|
||||||
debit = debit.Add(*feeDecimal)
|
|
||||||
settlement = settlement.Sub(*feeDecimal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if network != nil && network.GetNetworkFee() != nil {
|
settlementCurrency := pay.GetCurrency()
|
||||||
if networkDecimal, err := decimalFromMoneyMatching(base, network.GetNetworkFee()); err == nil && networkDecimal != nil {
|
if settlement != nil && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
||||||
debit = debit.Add(*networkDecimal)
|
settlementCurrency = settlement.GetCurrency()
|
||||||
settlement = settlement.Sub(*networkDecimal)
|
}
|
||||||
|
|
||||||
|
settlementDecimal := debitDecimal
|
||||||
|
if settlement != nil {
|
||||||
|
if val, err := decimalFromMoney(settlement); err == nil {
|
||||||
|
settlementDecimal = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return makeMoney(base.GetCurrency(), debit), makeMoney(base.GetCurrency(), settlement)
|
adjustDebit := func(m *moneyv1.Money) {
|
||||||
|
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
|
||||||
|
if err != nil || converted == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val, err := decimalFromMoney(converted); err == nil {
|
||||||
|
debitDecimal = debitDecimal.Add(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustSettlement := func(m *moneyv1.Money) {
|
||||||
|
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
|
||||||
|
if err != nil || converted == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val, err := decimalFromMoney(converted); err == nil {
|
||||||
|
settlementDecimal = settlementDecimal.Sub(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustDebit(fee)
|
||||||
|
adjustSettlement(fee)
|
||||||
|
|
||||||
|
if network != nil && network.GetNetworkFee() != nil {
|
||||||
|
adjustDebit(network.GetNetworkFee())
|
||||||
|
adjustSettlement(network.GetNetworkFee())
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
|
func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
|
||||||
@@ -162,6 +225,46 @@ func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
|
||||||
|
if m == nil || strings.TrimSpace(targetCurrency) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
|
||||||
|
return cloneMoney(m), nil
|
||||||
|
}
|
||||||
|
return convertWithQuote(m, quote, targetCurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
|
||||||
|
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSpace(quote.GetPair().GetBase())
|
||||||
|
qt := strings.TrimSpace(quote.GetPair().GetQuote())
|
||||||
|
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
|
||||||
|
if err != nil || price.IsZero() {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value, err := decimalFromMoney(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
|
||||||
|
return makeMoney(targetCurrency, value.Mul(price)), nil
|
||||||
|
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
|
||||||
|
return makeMoney(targetCurrency, value.Div(price)), nil
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
|
func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -219,6 +322,23 @@ func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
|
||||||
|
expiry := time.Time{}
|
||||||
|
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
|
||||||
|
expiry = feeQuote.GetExpiresAt().AsTime()
|
||||||
|
}
|
||||||
|
if expiry.IsZero() {
|
||||||
|
expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond)
|
||||||
|
}
|
||||||
|
if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 {
|
||||||
|
fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC()
|
||||||
|
if fxExpiry.Before(expiry) {
|
||||||
|
expiry = fxExpiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expiry
|
||||||
|
}
|
||||||
|
|
||||||
func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown {
|
func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown {
|
||||||
if quote == nil {
|
if quote == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveTradeAmountsBuyBase(t *testing.T) {
|
||||||
|
fxQuote := &oraclev1.Quote{
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
|
||||||
|
BaseAmount: &moneyv1.Money{
|
||||||
|
Currency: "EUR",
|
||||||
|
Amount: "100",
|
||||||
|
},
|
||||||
|
QuoteAmount: &moneyv1.Money{
|
||||||
|
Currency: "USD",
|
||||||
|
Amount: "110",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pay, settle := resolveTradeAmounts(nil, fxQuote, fxv1.Side_SIDE_UNSPECIFIED)
|
||||||
|
if pay.GetCurrency() != "USD" || pay.GetAmount() != "110" {
|
||||||
|
t.Fatalf("expected pay amount in USD 110, got %s %s", pay.GetCurrency(), pay.GetAmount())
|
||||||
|
}
|
||||||
|
if settle.GetCurrency() != "EUR" || settle.GetAmount() != "100" {
|
||||||
|
t.Fatalf("expected settlement in EUR 100, got %s %s", settle.GetCurrency(), settle.GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
|
||||||
|
pay := &moneyv1.Money{Currency: "USD", Amount: "100"}
|
||||||
|
settle := &moneyv1.Money{Currency: "EUR", Amount: "50"}
|
||||||
|
fee := &moneyv1.Money{Currency: "USD", Amount: "10"}
|
||||||
|
network := &chainv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: &moneyv1.Money{Currency: "USD", Amount: "5"},
|
||||||
|
}
|
||||||
|
fxQuote := &oraclev1.Quote{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
Price: &moneyv1.Decimal{
|
||||||
|
Value: "2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote)
|
||||||
|
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
|
||||||
|
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
|
||||||
|
}
|
||||||
|
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "42.5" {
|
||||||
|
t.Fatalf("expected settlement 42.5 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package orchestrator
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var captured oracleclient.GetQuoteParams
|
||||||
|
|
||||||
|
svc := &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
clock: testClock{now: time.Now()},
|
||||||
|
oracle: oracleDependency{
|
||||||
|
client: &oracleclient.Fake{
|
||||||
|
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||||
|
captured = params
|
||||||
|
return &oracleclient.Quote{
|
||||||
|
QuoteRef: "q",
|
||||||
|
Pair: params.Pair,
|
||||||
|
Side: params.Side,
|
||||||
|
Price: "1.1",
|
||||||
|
BaseAmount: params.BaseAmount,
|
||||||
|
QuoteAmount: params.QuoteAmount,
|
||||||
|
ExpiresAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &orchestratorv1.QuotePaymentRequest{
|
||||||
|
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||||
|
Intent: &orchestratorv1.PaymentIntent{
|
||||||
|
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||||
|
Fx: &orchestratorv1.FXIntent{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.requestFXQuote(ctx, "org", req); err != nil {
|
||||||
|
t.Fatalf("requestFXQuote returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if captured.QuoteAmount == nil {
|
||||||
|
t.Fatal("expected quote amount to be populated")
|
||||||
|
}
|
||||||
|
if captured.BaseAmount != nil {
|
||||||
|
t.Fatal("expected base amount to be nil when using quote amount input")
|
||||||
|
}
|
||||||
|
if captured.QuoteAmount.GetCurrency() != "USD" {
|
||||||
|
t.Fatalf("expected quote amount currency USD, got %s", captured.QuoteAmount.GetCurrency())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
type serviceError string
|
type serviceError string
|
||||||
@@ -132,6 +133,10 @@ func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.Q
|
|||||||
if orgRef == "" {
|
if orgRef == "" {
|
||||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
|
||||||
}
|
}
|
||||||
|
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
|
||||||
|
if parseErr != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
|
||||||
|
}
|
||||||
intent := req.GetIntent()
|
intent := req.GetIntent()
|
||||||
if intent == nil {
|
if intent == nil {
|
||||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
|
||||||
@@ -140,11 +145,31 @@ func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.Q
|
|||||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
quote, err := s.buildPaymentQuote(ctx, orgRef, req)
|
quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !req.GetPreviewOnly() {
|
||||||
|
quotesStore := s.storage.Quotes()
|
||||||
|
if quotesStore == nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||||
|
}
|
||||||
|
quoteRef := primitive.NewObjectID().Hex()
|
||||||
|
quote.QuoteRef = quoteRef
|
||||||
|
record := &model.PaymentQuoteRecord{
|
||||||
|
QuoteRef: quoteRef,
|
||||||
|
Intent: intentFromProto(intent),
|
||||||
|
Quote: quoteSnapshotToModel(quote),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
record.SetID(primitive.NewObjectID())
|
||||||
|
record.SetOrganizationRef(orgObjectID)
|
||||||
|
if err := quotesStore.Create(ctx, record); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,10 +219,34 @@ func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv
|
|||||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
quote := req.GetFeeQuoteToken()
|
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||||
|
quote := strings.TrimSpace(req.GetFeeQuoteToken())
|
||||||
var quoteSnapshot *orchestratorv1.PaymentQuote
|
var quoteSnapshot *orchestratorv1.PaymentQuote
|
||||||
if quote == "" {
|
if quoteRef != "" {
|
||||||
quoteSnapshot, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
quotesStore := s.storage.Quotes()
|
||||||
|
if quotesStore == nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||||
|
}
|
||||||
|
record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef)
|
||||||
|
if err != nil {
|
||||||
|
if err == storage.ErrQuoteNotFound {
|
||||||
|
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
||||||
|
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired"))
|
||||||
|
}
|
||||||
|
if !proto.Equal(protoIntentFromModel(record.Intent), intent) {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent"))
|
||||||
|
}
|
||||||
|
quoteSnapshot = modelQuoteToProto(record.Quote)
|
||||||
|
if quoteSnapshot == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
|
||||||
|
}
|
||||||
|
quoteSnapshot.QuoteRef = quoteRef
|
||||||
|
} else if quote == "" {
|
||||||
|
quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||||
Meta: req.GetMeta(),
|
Meta: req.GetMeta(),
|
||||||
IdempotencyKey: req.GetIdempotencyKey(),
|
IdempotencyKey: req.GetIdempotencyKey(),
|
||||||
Intent: req.GetIntent(),
|
Intent: req.GetIntent(),
|
||||||
@@ -389,7 +438,7 @@ func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestrat
|
|||||||
FeePolicy: req.GetFeePolicy(),
|
FeePolicy: req.GetFeePolicy(),
|
||||||
}
|
}
|
||||||
|
|
||||||
quote, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||||
Meta: req.GetMeta(),
|
Meta: req.GetMeta(),
|
||||||
IdempotencyKey: req.GetIdempotencyKey(),
|
IdempotencyKey: req.GetIdempotencyKey(),
|
||||||
Intent: intentProto,
|
Intent: intentProto,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
mo "github.com/tech/sendico/pkg/model"
|
mo "github.com/tech/sendico/pkg/model"
|
||||||
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"
|
||||||
@@ -208,11 +209,43 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
|
|||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
type stubRepository struct {
|
type stubRepository struct {
|
||||||
store *stubPaymentsStore
|
store *stubPaymentsStore
|
||||||
|
quotes storage.QuotesStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubRepository) Ping(context.Context) error { return nil }
|
func (r *stubRepository) Ping(context.Context) error { return nil }
|
||||||
func (r *stubRepository) Payments() storage.PaymentsStore { return r.store }
|
func (r *stubRepository) Payments() storage.PaymentsStore { return r.store }
|
||||||
|
func (r *stubRepository) Quotes() storage.QuotesStore {
|
||||||
|
if r.quotes != nil {
|
||||||
|
return r.quotes
|
||||||
|
}
|
||||||
|
return &stubQuotesStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubQuotesStore struct {
|
||||||
|
quotes map[string]*model.PaymentQuoteRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
|
||||||
|
if quote == nil {
|
||||||
|
return merrors.InvalidArgument("nil quote")
|
||||||
|
}
|
||||||
|
if s.quotes == nil {
|
||||||
|
s.quotes = map[string]*model.PaymentQuoteRecord{}
|
||||||
|
}
|
||||||
|
s.quotes[strings.TrimSpace(quote.QuoteRef)] = quote
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||||
|
if s.quotes == nil {
|
||||||
|
return nil, storage.ErrQuoteNotFound
|
||||||
|
}
|
||||||
|
if q, ok := s.quotes[strings.TrimSpace(quoteRef)]; ok {
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
return nil, storage.ErrQuoteNotFound
|
||||||
|
}
|
||||||
|
|
||||||
type stubPaymentsStore struct {
|
type stubPaymentsStore struct {
|
||||||
payments map[string]*model.Payment
|
payments map[string]*model.Payment
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ type PaymentQuoteSnapshot struct {
|
|||||||
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
|
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
|
||||||
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
|
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
|
||||||
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
|
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
|
||||||
|
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecutionRefs links to downstream systems.
|
// ExecutionRefs links to downstream systems.
|
||||||
|
|||||||
24
api/payments/orchestrator/storage/model/quote.go
Normal file
24
api/payments/orchestrator/storage/model/quote.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaymentQuoteRecord stores a quoted payment snapshot for later execution.
|
||||||
|
type PaymentQuoteRecord struct {
|
||||||
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
|
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
|
||||||
|
Intent PaymentIntent `bson:"intent" json:"intent"`
|
||||||
|
Quote *PaymentQuoteSnapshot `bson:"quote" json:"quote"`
|
||||||
|
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection implements storable.Storable.
|
||||||
|
func (*PaymentQuoteRecord) Collection() string {
|
||||||
|
return "payment_quotes"
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ type Store struct {
|
|||||||
ping func(context.Context) error
|
ping func(context.Context) error
|
||||||
|
|
||||||
payments storage.PaymentsStore
|
payments storage.PaymentsStore
|
||||||
|
quotes storage.QuotesStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
||||||
@@ -25,28 +26,37 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
|||||||
if conn == nil {
|
if conn == nil {
|
||||||
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
|
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
|
||||||
}
|
}
|
||||||
repo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
||||||
return NewWithRepository(logger, conn.Ping, repo)
|
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
|
||||||
|
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithRepository constructs a payments repository using the provided primitives.
|
// NewWithRepository constructs a payments repository using the provided primitives.
|
||||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository) (*Store, error) {
|
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository) (*Store, error) {
|
||||||
if ping == nil {
|
if ping == nil {
|
||||||
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||||
}
|
}
|
||||||
if paymentsRepo == nil {
|
if paymentsRepo == nil {
|
||||||
return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil")
|
return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil")
|
||||||
}
|
}
|
||||||
|
if quotesRepo == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payments.storage.mongo: quotes repository is nil")
|
||||||
|
}
|
||||||
|
|
||||||
childLogger := logger.Named("storage").Named("mongo")
|
childLogger := logger.Named("storage").Named("mongo")
|
||||||
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
quotesStore, err := store.NewQuotes(childLogger, quotesRepo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
result := &Store{
|
result := &Store{
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
ping: ping,
|
ping: ping,
|
||||||
payments: paymentsStore,
|
payments: paymentsStore,
|
||||||
|
quotes: quotesStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -65,4 +75,9 @@ func (s *Store) Payments() storage.PaymentsStore {
|
|||||||
return s.payments
|
return s.payments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quotes returns the quotes store.
|
||||||
|
func (s *Store) Quotes() storage.QuotesStore {
|
||||||
|
return s.quotes
|
||||||
|
}
|
||||||
|
|
||||||
var _ storage.Repository = (*Store)(nil)
|
var _ storage.Repository = (*Store)(nil)
|
||||||
|
|||||||
117
api/payments/orchestrator/storage/mongo/store/quotes.go
Normal file
117
api/payments/orchestrator/storage/mongo/store/quotes.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Quotes struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
repo repository.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQuotes constructs a Mongo-backed quotes store.
|
||||||
|
func NewQuotes(logger mlogger.Logger, repo repository.Repository) (*Quotes, error) {
|
||||||
|
if repo == nil {
|
||||||
|
return nil, merrors.InvalidArgument("quotesStore: repository is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
indexes := []*ri.Definition{
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "quoteRef", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}},
|
||||||
|
TTL: int32Ptr(0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, def := range indexes {
|
||||||
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
|
logger.Error("failed to ensure quotes index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Quotes{
|
||||||
|
logger: logger.Named("quotes"),
|
||||||
|
repo: repo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
|
||||||
|
if quote == nil {
|
||||||
|
return merrors.InvalidArgument("quotesStore: nil quote")
|
||||||
|
}
|
||||||
|
quote.QuoteRef = strings.TrimSpace(quote.QuoteRef)
|
||||||
|
if quote.QuoteRef == "" {
|
||||||
|
return merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||||
|
}
|
||||||
|
if quote.OrganizationRef == primitive.NilObjectID {
|
||||||
|
return merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||||
|
}
|
||||||
|
if quote.ExpiresAt.IsZero() {
|
||||||
|
return merrors.InvalidArgument("quotesStore: expires_at is required")
|
||||||
|
}
|
||||||
|
if quote.Intent.Attributes != nil {
|
||||||
|
for k, v := range quote.Intent.Attributes {
|
||||||
|
quote.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quote.Update()
|
||||||
|
|
||||||
|
filter := repository.OrgFilter(quote.OrganizationRef).And(
|
||||||
|
repository.Filter("quoteRef", quote.QuoteRef),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := q.repo.Insert(ctx, quote, filter); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
return storage.ErrDuplicateQuote
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Quotes) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||||
|
quoteRef = strings.TrimSpace(quoteRef)
|
||||||
|
if quoteRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||||
|
}
|
||||||
|
if orgRef == primitive.NilObjectID {
|
||||||
|
return nil, merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||||
|
}
|
||||||
|
entity := &model.PaymentQuoteRecord{}
|
||||||
|
query := repository.OrgFilter(orgRef).And(repository.Filter("quoteRef", quoteRef))
|
||||||
|
if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, storage.ErrQuoteNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) {
|
||||||
|
return nil, storage.ErrQuoteNotFound
|
||||||
|
}
|
||||||
|
return entity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.QuotesStore = (*Quotes)(nil)
|
||||||
|
|
||||||
|
func int32Ptr(v int32) *int32 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -18,12 +18,17 @@ var (
|
|||||||
ErrPaymentNotFound = storageError("payments.orchestrator.storage: payment not found")
|
ErrPaymentNotFound = storageError("payments.orchestrator.storage: payment not found")
|
||||||
// ErrDuplicatePayment signals that idempotency constraints were violated.
|
// ErrDuplicatePayment signals that idempotency constraints were violated.
|
||||||
ErrDuplicatePayment = storageError("payments.orchestrator.storage: duplicate payment")
|
ErrDuplicatePayment = storageError("payments.orchestrator.storage: duplicate payment")
|
||||||
|
// ErrQuoteNotFound signals that a stored quote does not exist or expired.
|
||||||
|
ErrQuoteNotFound = storageError("payments.orchestrator.storage: quote not found")
|
||||||
|
// ErrDuplicateQuote signals that a quote reference already exists.
|
||||||
|
ErrDuplicateQuote = storageError("payments.orchestrator.storage: duplicate quote")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository exposes persistence primitives for the orchestrator domain.
|
// Repository exposes persistence primitives for the orchestrator domain.
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
Payments() PaymentsStore
|
Payments() PaymentsStore
|
||||||
|
Quotes() QuotesStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaymentsStore manages payment lifecycle state.
|
// PaymentsStore manages payment lifecycle state.
|
||||||
@@ -35,3 +40,9 @@ type PaymentsStore interface {
|
|||||||
GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error)
|
GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error)
|
||||||
List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error)
|
List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotesStore manages temporary stored payment quotes.
|
||||||
|
type QuotesStore interface {
|
||||||
|
Create(ctx context.Context, quote *model.PaymentQuoteRecord) error
|
||||||
|
GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/pkg/auth"
|
"github.com/tech/sendico/pkg/auth"
|
||||||
"github.com/tech/sendico/pkg/db/policy"
|
"github.com/tech/sendico/pkg/db/policy"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
"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"
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PaymentMethodsDB struct {
|
type PaymentMethodsDB struct {
|
||||||
@@ -45,5 +47,13 @@ func Create(ctx context.Context,
|
|||||||
getArchivable,
|
getArchivable,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := res.DBImp.Repository.CreateIndex(&ri.Definition{
|
||||||
|
Keys: []ri.Key{{Field: "recipientRef", Sort: ri.Asc}},
|
||||||
|
}); err != nil {
|
||||||
|
res.DBImp.Logger.Error("Failed to create recipientRef index for payment methods", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/auth"
|
"github.com/tech/sendico/pkg/auth"
|
||||||
"github.com/tech/sendico/pkg/db/paymethod"
|
"github.com/tech/sendico/pkg/db/paymethod"
|
||||||
"github.com/tech/sendico/pkg/db/policy"
|
"github.com/tech/sendico/pkg/db/policy"
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
"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"
|
||||||
@@ -16,8 +15,7 @@ import (
|
|||||||
type RecipientDB struct {
|
type RecipientDB struct {
|
||||||
auth.ProtectedDBImp[*model.Recipient]
|
auth.ProtectedDBImp[*model.Recipient]
|
||||||
auth.ArchivableDB[*model.Recipient]
|
auth.ArchivableDB[*model.Recipient]
|
||||||
pmdb paymethod.DB
|
pmdb paymethod.DB
|
||||||
paymentMethodsRepo repository.Repository
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Create(ctx context.Context,
|
func Create(ctx context.Context,
|
||||||
@@ -27,7 +25,7 @@ func Create(ctx context.Context,
|
|||||||
pmdb paymethod.DB,
|
pmdb paymethod.DB,
|
||||||
db *mongo.Database,
|
db *mongo.Database,
|
||||||
) (*RecipientDB, error) {
|
) (*RecipientDB, error) {
|
||||||
p, err := auth.CreateDBImp[*model.Recipient](ctx, logger, pdb, enforcer, mservice.Organizations, db)
|
p, err := auth.CreateDBImp[*model.Recipient](ctx, logger, pdb, enforcer, mservice.Recipients, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -49,8 +47,7 @@ func Create(ctx context.Context,
|
|||||||
createEmpty,
|
createEmpty,
|
||||||
getArchivable,
|
getArchivable,
|
||||||
),
|
),
|
||||||
paymentMethodsRepo: repository.CreateMongoRepository(db, string(mservice.PaymentMethods)),
|
pmdb: pmdb,
|
||||||
pmdb: pmdb,
|
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module github.com/tech/sendico/pkg
|
|||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/casbin/casbin/v2 v2.134.0
|
github.com/casbin/casbin/v2 v2.135.0
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0
|
github.com/casbin/mongodb-adapter/v3 v3.7.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -16,7 +16,7 @@ require (
|
|||||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
go.mongodb.org/mongo-driver v1.17.6
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.46.0
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.77.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
)
|
)
|
||||||
@@ -46,7 +46,7 @@ require (
|
|||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/mock v1.6.0 // indirect
|
github.com/golang/mock v1.6.0 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -88,11 +88,11 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -70,8 +70,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rH
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -202,8 +202,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
@@ -216,15 +216,15 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
|||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -240,18 +240,18 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -269,8 +269,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user