Compare commits
1 Commits
b481de9ffc
...
SEND041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44ef56ff3 |
@@ -4,23 +4,11 @@ matrix:
|
|||||||
BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile
|
BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile
|
||||||
BFF_MONGO_SECRET_PATH: sendico/db
|
BFF_MONGO_SECRET_PATH: sendico/db
|
||||||
BFF_API_SECRET_PATH: sendico/api/endpoint
|
BFF_API_SECRET_PATH: sendico/api/endpoint
|
||||||
BFF_VAULT_SECRET_PATH: sendico/edge/bff/vault
|
|
||||||
BFF_ENV: prod
|
BFF_ENV: prod
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/edge/bff/**
|
|
||||||
- api/payments/methods/client/**
|
|
||||||
- api/payments/methods/go.mod
|
|
||||||
- api/payments/methods/go.sum
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/bff.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -47,14 +35,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh bff
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -75,7 +55,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/bff/build-image.sh
|
- sh ci/scripts/bff/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/billing/documents/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/billing_documents.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -42,14 +34,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh billing_documents
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -70,7 +54,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/billing_documents/build-image.sh
|
- sh ci/scripts/billing_documents/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/billing/fees/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/billing_fees.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -42,14 +34,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh billing_fees
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -70,7 +54,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/billing_fees/build-image.sh
|
- sh ci/scripts/billing_fees/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
matrix:
|
|
||||||
include:
|
|
||||||
- CALLBACKS_IMAGE_PATH: edge/callbacks
|
|
||||||
CALLBACKS_DOCKERFILE: ci/prod/compose/callbacks.dockerfile
|
|
||||||
CALLBACKS_MONGO_SECRET_PATH: sendico/db
|
|
||||||
CALLBACKS_VAULT_SECRET_PATH: sendico/edge/callbacks/vault
|
|
||||||
CALLBACKS_ENV: prod
|
|
||||||
|
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/edge/callbacks/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/callbacks.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
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: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh callbacks
|
|
||||||
|
|
||||||
- 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: [ backend-tests, secrets ]
|
|
||||||
commands:
|
|
||||||
- sh ci/scripts/callbacks/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/callbacks/deploy.sh
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
exclude: ['**']
|
|
||||||
ignore_message: '[infra]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
|
|||||||
@@ -7,14 +7,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/discovery/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/discovery.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -41,14 +33,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh discovery
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -69,7 +53,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/discovery/build-image.sh
|
- sh ci/scripts/discovery/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/edge/bff/**
|
|
||||||
- api/pkg/**
|
|
||||||
- api/proto/**
|
|
||||||
- frontend/**
|
|
||||||
- interface/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/frontend.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/fx/ingestor/**
|
|
||||||
- api/fx/storage/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/fx_ingestor.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -47,14 +38,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh fx_ingestor
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -75,7 +58,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/fx/build-image.sh
|
- sh ci/scripts/fx/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/fx/oracle/**
|
|
||||||
- api/fx/storage/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/fx_oracle.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -48,14 +38,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh fx_oracle
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -76,7 +58,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/fx/build-image.sh
|
- sh ci/scripts/fx/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/gateway/chain/**
|
|
||||||
- api/gateway/common/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/gateway_chain.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -46,14 +37,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh gateway_chain
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -74,7 +57,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/chain_gateway/build-image.sh
|
- sh ci/scripts/chain_gateway/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/gateway/mntx/**
|
|
||||||
- api/gateway/common/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/gateway_mntx.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -45,14 +36,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh gateway_mntx
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -73,7 +56,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/mntx/build-image.sh
|
- sh ci/scripts/mntx/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/gateway/tgsettle/**
|
|
||||||
- api/gateway/common/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/gateway_tgsettle.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -43,14 +34,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh gateway_tgsettle
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -71,7 +54,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/tgsettle/build-image.sh
|
- sh ci/scripts/tgsettle/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/gateway/tron/**
|
|
||||||
- api/gateway/common/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/gateway_tron.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -46,14 +37,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh gateway_tron
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -74,7 +57,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/tron_gateway/build-image.sh
|
- sh ci/scripts/tron_gateway/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/ledger/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/ledger.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -42,14 +34,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh ledger
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -70,7 +54,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/ledger/build-image.sh
|
- sh ci/scripts/ledger/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
exclude: ['**']
|
|
||||||
ignore_message: '[infra]'
|
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
|
|||||||
@@ -11,14 +11,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/notification/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/notification.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -45,14 +37,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh notification
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -73,7 +57,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/notification/build-image.sh
|
- sh ci/scripts/notification/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
matrix:
|
|
||||||
include:
|
|
||||||
- PAYMENTS_METHODS_IMAGE_PATH: payments/methods
|
|
||||||
PAYMENTS_METHODS_DOCKERFILE: ci/prod/compose/payments_methods.dockerfile
|
|
||||||
PAYMENTS_METHODS_MONGO_SECRET_PATH: sendico/db
|
|
||||||
PAYMENTS_METHODS_ENV: prod
|
|
||||||
|
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/payments/methods/**
|
|
||||||
- api/payments/storage/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/payments_methods.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
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: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh payments_methods
|
|
||||||
|
|
||||||
- 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: [ backend-tests, secrets ]
|
|
||||||
commands:
|
|
||||||
- sh ci/scripts/payments_methods/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/payments_methods/deploy.sh
|
|
||||||
@@ -8,15 +8,6 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/payments/orchestrator/**
|
|
||||||
- api/payments/storage/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/payments_orchestrator.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -43,14 +34,6 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- bash ci/scripts/proto/generate.sh
|
||||||
|
|
||||||
- name: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh payments_orchestrator
|
|
||||||
|
|
||||||
- name: secrets
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -71,7 +54,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ backend-tests, secrets ]
|
depends_on: [ proto, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/payments_orchestrator/build-image.sh
|
- sh ci/scripts/payments_orchestrator/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
matrix:
|
|
||||||
include:
|
|
||||||
- PAYMENTS_QUOTATION_IMAGE_PATH: payments/quotation
|
|
||||||
PAYMENTS_QUOTATION_DOCKERFILE: ci/prod/compose/payments_quotation.dockerfile
|
|
||||||
PAYMENTS_QUOTATION_MONGO_SECRET_PATH: sendico/db
|
|
||||||
PAYMENTS_QUOTATION_ENV: prod
|
|
||||||
|
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- api/payments/quotation/**
|
|
||||||
- api/payments/storage/**
|
|
||||||
- api/proto/**
|
|
||||||
- api/pkg/**
|
|
||||||
- ci/prod/**
|
|
||||||
- .woodpecker/payments_quotation.yml
|
|
||||||
ignore_message: '[rebuild]'
|
|
||||||
|
|
||||||
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: backend-tests
|
|
||||||
image: golang:alpine
|
|
||||||
depends_on: [ proto ]
|
|
||||||
commands:
|
|
||||||
- set -eu
|
|
||||||
- apk add --no-cache bash git build-base
|
|
||||||
- sh ci/scripts/common/run_backend_tests.sh payments_quotation
|
|
||||||
|
|
||||||
- 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: [ backend-tests, secrets ]
|
|
||||||
commands:
|
|
||||||
- sh ci/scripts/payments_quotation/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/payments_quotation/deploy.sh
|
|
||||||
134
Makefile
134
Makefile
@@ -1,31 +1,10 @@
|
|||||||
# Sendico Development Environment - Makefile
|
# Sendico Development Environment - Makefile
|
||||||
# Docker Compose + Makefile build system
|
# Docker Compose + Makefile build system
|
||||||
|
|
||||||
.PHONY: help init build up down restart logs rebuild clean vault-init proto generate generate-api generate-frontend update update-api update-frontend test test-api test-frontend backend-up backend-down backend-rebuild
|
.PHONY: help init build up down restart logs rebuild clean vault-init proto
|
||||||
|
|
||||||
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
|
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
|
||||||
SERVICE ?=
|
SERVICE ?=
|
||||||
BACKEND_SERVICES := \
|
|
||||||
dev-discovery \
|
|
||||||
dev-fx-oracle \
|
|
||||||
dev-fx-ingestor \
|
|
||||||
dev-billing-fees \
|
|
||||||
dev-billing-documents \
|
|
||||||
dev-ledger \
|
|
||||||
dev-payments-orchestrator \
|
|
||||||
dev-payments-quotation \
|
|
||||||
dev-payments-methods \
|
|
||||||
dev-chain-gateway-vault-agent \
|
|
||||||
dev-chain-gateway \
|
|
||||||
dev-tron-gateway-vault-agent \
|
|
||||||
dev-tron-gateway \
|
|
||||||
dev-aurora-gateway \
|
|
||||||
dev-tgsettle-gateway \
|
|
||||||
dev-notification \
|
|
||||||
dev-callbacks-vault-agent \
|
|
||||||
dev-callbacks \
|
|
||||||
dev-bff-vault-agent \
|
|
||||||
dev-bff
|
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
GREEN := \033[0;32m
|
GREEN := \033[0;32m
|
||||||
@@ -52,30 +31,18 @@ help:
|
|||||||
@echo "$(YELLOW)Selective Operations:$(NC)"
|
@echo "$(YELLOW)Selective Operations:$(NC)"
|
||||||
@echo " make infra-up Start infrastructure only (mongo, nats, vault)"
|
@echo " make infra-up Start infrastructure only (mongo, nats, vault)"
|
||||||
@echo " make services-up Start application services only"
|
@echo " make services-up Start application services only"
|
||||||
@echo " make backend-up Start backend services only (no infrastructure/frontend)"
|
|
||||||
@echo " make backend-down Stop backend services only"
|
|
||||||
@echo " make backend-rebuild Rebuild and restart backend services only"
|
|
||||||
@echo " make list-services List all available services"
|
@echo " make list-services List all available services"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "$(YELLOW)Build Groups:$(NC)"
|
@echo "$(YELLOW)Build Groups:$(NC)"
|
||||||
@echo " make build-core Build core services (discovery, ledger, fees, documents)"
|
@echo " make build-core Build core services (discovery, ledger, fees, documents)"
|
||||||
@echo " make build-fx Build FX services (oracle, ingestor)"
|
@echo " make build-fx Build FX services (oracle, ingestor)"
|
||||||
@echo " make build-payments Build payment orchestrator"
|
@echo " make build-payments Build payment orchestrator"
|
||||||
@echo " make build-gateways Build gateway services (chain, tron, aurora, tgsettle)"
|
@echo " make build-gateways Build gateway services (chain, tron, mntx, tgsettle)"
|
||||||
@echo " make build-api Build API services (notification, callbacks, bff)"
|
@echo " make build-api Build API services (notification, bff)"
|
||||||
@echo " make build-frontend Build Flutter web frontend"
|
@echo " make build-frontend Build Flutter web frontend"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "$(YELLOW)Development:$(NC)"
|
@echo "$(YELLOW)Development:$(NC)"
|
||||||
@echo " make proto Generate protobuf code"
|
@echo " make proto Generate protobuf code"
|
||||||
@echo " make generate Generate all code (protobuf + Flutter)"
|
|
||||||
@echo " make generate-api Generate protobuf code only"
|
|
||||||
@echo " make generate-frontend Generate Flutter code only"
|
|
||||||
@echo " make update Update all dependencies (Go + Flutter)"
|
|
||||||
@echo " make update-api Update Go dependencies only"
|
|
||||||
@echo " make update-frontend Update Flutter dependencies only"
|
|
||||||
@echo " make test Run all tests (API + frontend)"
|
|
||||||
@echo " make test-api Run Go API tests only"
|
|
||||||
@echo " make test-frontend Run Flutter tests only"
|
|
||||||
@echo " make health Check service health"
|
@echo " make health Check service health"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Examples:"
|
@echo "Examples:"
|
||||||
@@ -109,7 +76,7 @@ init:
|
|||||||
@echo "$(GREEN)Verifying .env.dev...$(NC)"
|
@echo "$(GREEN)Verifying .env.dev...$(NC)"
|
||||||
@cat .env.dev | grep -q "MONGO_USER=" || (echo "$(YELLOW)Error: .env.dev is incomplete$(NC)" && exit 1)
|
@cat .env.dev | grep -q "MONGO_USER=" || (echo "$(YELLOW)Error: .env.dev is incomplete$(NC)" && exit 1)
|
||||||
@echo "$(GREEN)Running proto generation...$(NC)"
|
@echo "$(GREEN)Running proto generation...$(NC)"
|
||||||
@./ci/scripts/proto/generate.sh
|
@./generate_protos.sh
|
||||||
@echo "$(GREEN)Building Docker images...$(NC)"
|
@echo "$(GREEN)Building Docker images...$(NC)"
|
||||||
@$(COMPOSE) build
|
@$(COMPOSE) build
|
||||||
@echo "$(GREEN)✅ Initialization complete!$(NC)"
|
@echo "$(GREEN)✅ Initialization complete!$(NC)"
|
||||||
@@ -121,7 +88,7 @@ init:
|
|||||||
# Build all images
|
# Build all images
|
||||||
build:
|
build:
|
||||||
@echo "$(GREEN)Building all service images...$(NC)"
|
@echo "$(GREEN)Building all service images...$(NC)"
|
||||||
@./ci/scripts/proto/generate.sh
|
@./generate_protos.sh
|
||||||
@$(COMPOSE) build
|
@$(COMPOSE) build
|
||||||
|
|
||||||
# Start all services
|
# Start all services
|
||||||
@@ -169,25 +136,12 @@ endif
|
|||||||
@echo "$(GREEN)✅ $(SERVICE) rebuilt$(NC)"
|
@echo "$(GREEN)✅ $(SERVICE) rebuilt$(NC)"
|
||||||
@echo "View logs: make logs SERVICE=$(SERVICE)"
|
@echo "View logs: make logs SERVICE=$(SERVICE)"
|
||||||
|
|
||||||
# Generate protobuf code (alias)
|
|
||||||
proto: generate-api
|
|
||||||
|
|
||||||
# Generate all code
|
|
||||||
generate: generate-api generate-frontend
|
|
||||||
|
|
||||||
# Generate protobuf code
|
# Generate protobuf code
|
||||||
generate-api:
|
proto:
|
||||||
@echo "$(GREEN)Generating protobuf code...$(NC)"
|
@echo "$(GREEN)Generating protobuf code...$(NC)"
|
||||||
@./ci/scripts/proto/generate.sh
|
@./generate_protos.sh
|
||||||
@echo "$(GREEN)✅ Protobuf generation complete$(NC)"
|
@echo "$(GREEN)✅ Protobuf generation complete$(NC)"
|
||||||
|
|
||||||
# Generate Flutter code (json_serializable, etc.)
|
|
||||||
generate-frontend:
|
|
||||||
@echo "$(GREEN)Generating Flutter code...$(NC)"
|
|
||||||
@cd frontend/pshared && dart run build_runner build --delete-conflicting-outputs
|
|
||||||
@cd frontend/pweb && dart run build_runner build --delete-conflicting-outputs
|
|
||||||
@echo "$(GREEN)✅ Flutter code generation complete$(NC)"
|
|
||||||
|
|
||||||
# Clean everything
|
# Clean everything
|
||||||
clean:
|
clean:
|
||||||
@echo "$(YELLOW)WARNING: This will remove all containers and volumes!$(NC)"
|
@echo "$(YELLOW)WARNING: This will remove all containers and volumes!$(NC)"
|
||||||
@@ -242,32 +196,14 @@ services-up:
|
|||||||
dev-billing-documents \
|
dev-billing-documents \
|
||||||
dev-ledger \
|
dev-ledger \
|
||||||
dev-payments-orchestrator \
|
dev-payments-orchestrator \
|
||||||
dev-payments-quotation \
|
|
||||||
dev-payments-methods \
|
|
||||||
dev-chain-gateway \
|
dev-chain-gateway \
|
||||||
dev-tron-gateway \
|
dev-tron-gateway \
|
||||||
dev-aurora-gateway \
|
dev-mntx-gateway \
|
||||||
dev-tgsettle-gateway \
|
dev-tgsettle-gateway \
|
||||||
dev-notification \
|
dev-notification \
|
||||||
dev-callbacks \
|
|
||||||
dev-bff \
|
dev-bff \
|
||||||
dev-frontend
|
dev-frontend
|
||||||
|
|
||||||
# Backend services only (no infrastructure, no frontend)
|
|
||||||
backend-up:
|
|
||||||
@echo "$(GREEN)Starting backend services only (no infra changes)...$(NC)"
|
|
||||||
@$(COMPOSE) up -d --no-deps $(BACKEND_SERVICES)
|
|
||||||
|
|
||||||
backend-down:
|
|
||||||
@echo "$(YELLOW)Stopping backend services only...$(NC)"
|
|
||||||
@$(COMPOSE) stop $(BACKEND_SERVICES)
|
|
||||||
|
|
||||||
backend-rebuild:
|
|
||||||
@echo "$(GREEN)Rebuilding backend services only (no infra changes)...$(NC)"
|
|
||||||
@$(COMPOSE) build $(BACKEND_SERVICES)
|
|
||||||
@$(COMPOSE) up -d --no-deps --force-recreate $(BACKEND_SERVICES)
|
|
||||||
@echo "$(GREEN)✅ Backend services rebuilt$(NC)"
|
|
||||||
|
|
||||||
# Status check
|
# Status check
|
||||||
status:
|
status:
|
||||||
@$(COMPOSE) ps
|
@$(COMPOSE) ps
|
||||||
@@ -287,14 +223,11 @@ list-services:
|
|||||||
@echo " - dev-billing-documents :50061, :9409 (Billing Documents)"
|
@echo " - dev-billing-documents :50061, :9409 (Billing Documents)"
|
||||||
@echo " - dev-ledger :50052, :9401 (Double-Entry Ledger)"
|
@echo " - dev-ledger :50052, :9401 (Double-Entry Ledger)"
|
||||||
@echo " - dev-payments-orchestrator :50062, :9403 (Payment Orchestration)"
|
@echo " - dev-payments-orchestrator :50062, :9403 (Payment Orchestration)"
|
||||||
@echo " - dev-payments-quotation :50064, :9414 (Payment Quotation)"
|
|
||||||
@echo " - dev-payments-methods :50066, :9416 (Payment Methods)"
|
|
||||||
@echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
|
@echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
|
||||||
@echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)"
|
@echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)"
|
||||||
@echo " - dev-aurora-gateway :50075, :9405, :8084 (Card Payouts Simulator)"
|
@echo " - dev-mntx-gateway :50075, :9405, :8084 (Card Payouts)"
|
||||||
@echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
|
@echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
|
||||||
@echo " - dev-notification :8081 (Notifications)"
|
@echo " - dev-notification :8081 (Notifications)"
|
||||||
@echo " - dev-callbacks :9420 (Webhook Callbacks)"
|
|
||||||
@echo " - dev-bff :8080 (Backend for Frontend)"
|
@echo " - dev-bff :8080 (Backend for Frontend)"
|
||||||
@echo " - dev-frontend :3000 (Flutter Web UI)"
|
@echo " - dev-frontend :3000 (Flutter Web UI)"
|
||||||
|
|
||||||
@@ -318,59 +251,16 @@ build-fx:
|
|||||||
|
|
||||||
build-payments:
|
build-payments:
|
||||||
@echo "$(GREEN)Building payment services...$(NC)"
|
@echo "$(GREEN)Building payment services...$(NC)"
|
||||||
@$(COMPOSE) build dev-payments-orchestrator dev-payments-quotation dev-payments-methods
|
@$(COMPOSE) build dev-payments-orchestrator
|
||||||
|
|
||||||
build-gateways:
|
build-gateways:
|
||||||
@echo "$(GREEN)Building gateway services...$(NC)"
|
@echo "$(GREEN)Building gateway services...$(NC)"
|
||||||
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-tgsettle-gateway
|
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-mntx-gateway dev-tgsettle-gateway
|
||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
@echo "$(GREEN)Building API services...$(NC)"
|
@echo "$(GREEN)Building API services...$(NC)"
|
||||||
@$(COMPOSE) build dev-notification dev-callbacks dev-bff
|
@$(COMPOSE) build dev-notification dev-bff
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
@echo "$(GREEN)Building frontend...$(NC)"
|
@echo "$(GREEN)Building frontend...$(NC)"
|
||||||
@$(COMPOSE) build dev-frontend
|
@$(COMPOSE) build dev-frontend
|
||||||
|
|
||||||
# Update all dependencies
|
|
||||||
update: update-api update-frontend
|
|
||||||
|
|
||||||
# Update Go API dependencies
|
|
||||||
update-api:
|
|
||||||
@echo "$(GREEN)Updating Go dependencies...$(NC)"
|
|
||||||
@for dir in $$(find api -name go.mod -exec dirname {} \;); do \
|
|
||||||
echo "Updating $$dir..."; \
|
|
||||||
(cd "$$dir" && go get -u ./... && go mod tidy); \
|
|
||||||
done
|
|
||||||
@echo "$(GREEN)✅ Go dependencies updated$(NC)"
|
|
||||||
|
|
||||||
# Update Flutter dependencies
|
|
||||||
update-frontend:
|
|
||||||
@echo "$(GREEN)Updating Flutter dependencies...$(NC)"
|
|
||||||
@cd frontend/pshared && flutter pub upgrade --major-versions
|
|
||||||
@cd frontend/pweb && flutter pub upgrade --major-versions
|
|
||||||
@echo "$(GREEN)✅ Flutter dependencies updated$(NC)"
|
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
test: test-api test-frontend
|
|
||||||
|
|
||||||
# Run Go API tests
|
|
||||||
test-api:
|
|
||||||
@echo "$(GREEN)Running API tests...$(NC)"
|
|
||||||
@failed=""; \
|
|
||||||
for dir in $$(find api -name go.mod -exec dirname {} \;); do \
|
|
||||||
echo "Testing $$dir..."; \
|
|
||||||
(cd "$$dir" && go test ./...) || failed="$$failed $$dir"; \
|
|
||||||
done; \
|
|
||||||
if [ -n "$$failed" ]; then \
|
|
||||||
echo "$(YELLOW)Failed:$$failed$(NC)"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "$(GREEN)✅ All API tests passed$(NC)"
|
|
||||||
|
|
||||||
# Run Flutter tests
|
|
||||||
test-frontend:
|
|
||||||
@echo "$(GREEN)Running frontend tests...$(NC)"
|
|
||||||
@cd frontend/pshared && flutter test
|
|
||||||
@cd frontend/pweb && flutter test
|
|
||||||
@echo "$(GREEN)✅ All frontend tests passed$(NC)"
|
|
||||||
|
|||||||
179
README.md
179
README.md
@@ -1,179 +0,0 @@
|
|||||||
# Sendico [](https://ci.sendico.io/repos/1)
|
|
||||||
|
|
||||||
|
|
||||||
Financial services platform providing payment orchestration, ledger accounting, FX conversion, and multi-rail payment processing.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Backend**: Go microservices with gRPC inter-service communication
|
|
||||||
- **Frontend**: Flutter/Dart web application
|
|
||||||
- **Infrastructure**: Woodpecker CI/CD, Docker, MongoDB, NATS, Vault
|
|
||||||
|
|
||||||
## Services
|
|
||||||
|
|
||||||
| Service | Path | Description |
|
|
||||||
|---------|------|-------------|
|
|
||||||
| Discovery | `api/discovery/` | Service registry |
|
|
||||||
| Ledger | `api/ledger/` | Double-entry accounting |
|
|
||||||
| Orchestrator | `api/payments/orchestrator/` | Payment orchestration |
|
|
||||||
| Quotation | `api/payments/quotation/` | Payment quotation |
|
|
||||||
| Payment Methods | `api/payments/methods/` | Payment methods |
|
|
||||||
| Billing Fees | `api/billing/fees/` | Fee calculation |
|
|
||||||
| Billing Documents | `api/billing/documents/` | Billing documents |
|
|
||||||
| FX Oracle | `api/fx/oracle/` | FX quote provider |
|
|
||||||
| FX Ingestor | `api/fx/ingestor/` | FX rate ingestion |
|
|
||||||
| Gateway Chain | `api/gateway/chain/` | EVM blockchain gateway |
|
|
||||||
| Gateway TRON | `api/gateway/tron/` | TRON blockchain gateway |
|
|
||||||
| Gateway Aurora | `api/gateway/aurora/` | Card payouts simulator |
|
|
||||||
| Gateway MNTX | `api/gateway/mntx/` | Card payouts |
|
|
||||||
| Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX |
|
|
||||||
| Notification | `api/notification/` | Notifications |
|
|
||||||
| BFF | `api/edge/bff/` | Backend for frontend |
|
|
||||||
| Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery |
|
|
||||||
| Frontend | `frontend/pweb/` | Flutter web UI |
|
|
||||||
|
|
||||||
Gateway note: current dev compose workflows (`make services-up`, `make build-gateways`) use Aurora for card-payout flows (`chain`, `tron`, `aurora`, `tgsettle`). The MNTX gateway codebase is retained separately for Monetix-specific integration.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker with Docker Compose plugin
|
|
||||||
- GNU Make
|
|
||||||
- Go toolchain
|
|
||||||
- Dart SDK
|
|
||||||
- Flutter SDK
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
Development uses Docker Compose via the Makefile. Run `make help` for all available commands.
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make init # First-time setup (generates keys, .env.dev, builds images)
|
|
||||||
make up # Start all services
|
|
||||||
make vault-init # Initialize Vault (if needed)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build # Build all service images
|
|
||||||
make up # Start all services
|
|
||||||
make down # Stop all services
|
|
||||||
make restart # Restart all services
|
|
||||||
make status # Show service status
|
|
||||||
make logs # View all logs
|
|
||||||
make logs SERVICE=dev-ledger # View logs for a specific service
|
|
||||||
make rebuild SERVICE=dev-ledger # Rebuild and restart a specific service
|
|
||||||
make list-services # List all services and ports
|
|
||||||
make health # Check service health
|
|
||||||
make clean # Remove all containers and volumes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Selective Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make infra-up # Start infrastructure only (MongoDB, NATS, Vault)
|
|
||||||
make services-up # Start application services only (assumes infra is running)
|
|
||||||
make backend-up # Start backend services only (no infrastructure/frontend changes)
|
|
||||||
make backend-down # Stop backend services only
|
|
||||||
make backend-rebuild # Rebuild and restart backend services only
|
|
||||||
make list-services # Show service names, ports, and descriptions
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build Groups
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build-core # discovery, ledger, fees, documents
|
|
||||||
make build-fx # oracle, ingestor
|
|
||||||
make build-payments # orchestrator, quotation, methods
|
|
||||||
make build-gateways # chain, tron, aurora, tgsettle
|
|
||||||
make build-api # notification, callbacks, bff
|
|
||||||
make build-frontend # Flutter web UI
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Generation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make generate # Generate all code (protobuf + Flutter)
|
|
||||||
make generate-api # Generate protobuf code only
|
|
||||||
make generate-frontend # Generate Flutter code only (build_runner)
|
|
||||||
make proto # Alias for generate-api
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test # Run all tests (API + frontend)
|
|
||||||
make test-api # Run Go API tests only
|
|
||||||
make test-frontend # Run Flutter tests only
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make update # Update all Go and Flutter dependencies
|
|
||||||
make update-api # Update Go dependencies only
|
|
||||||
make update-frontend # Update Flutter dependencies only
|
|
||||||
```
|
|
||||||
|
|
||||||
### Callbacks Secret References
|
|
||||||
|
|
||||||
Callbacks (`api/edge/callbacks`) supports three secret reference formats:
|
|
||||||
|
|
||||||
- `env:MY_SECRET_ENV` to read from environment variables.
|
|
||||||
- `vault:some/path#field` to read a field from Vault KV v2.
|
|
||||||
- `some/path#field` to read from Vault KV v2 when `secrets.vault` is configured.
|
|
||||||
|
|
||||||
If `#field` is omitted, callbacks uses `secrets.vault.default_field` (default: `value`).
|
|
||||||
|
|
||||||
### Callbacks Vault Auth (Dev + Prod)
|
|
||||||
|
|
||||||
Callbacks now authenticates to Vault through a sidecar Vault Agent (AppRole), same pattern as chain/tron gateways.
|
|
||||||
|
|
||||||
- Dev compose:
|
|
||||||
- service: `dev-callbacks-vault-agent`
|
|
||||||
- shared token file: `/run/vault/token`
|
|
||||||
- app reads token via `VAULT_TOKEN_FILE=/run/vault/token` and `token_env: VAULT_TOKEN`
|
|
||||||
- Prod compose:
|
|
||||||
- service: `sendico_callbacks_vault_agent`
|
|
||||||
- same token sink and env flow
|
|
||||||
- AppRole creds are injected at deploy from `CALLBACKS_VAULT_SECRET_PATH` (default `sendico/edge/callbacks/vault`)
|
|
||||||
|
|
||||||
Required Vault policy (minimal read-only for KV v2 mount `kv`):
|
|
||||||
|
|
||||||
```hcl
|
|
||||||
path "kv/data/sendico/callbacks/*" {
|
|
||||||
capabilities = ["read"]
|
|
||||||
}
|
|
||||||
|
|
||||||
path "kv/metadata/sendico/callbacks/*" {
|
|
||||||
capabilities = ["read", "list"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Create policy + role (example):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vault policy write callbacks callbacks-policy.hcl
|
|
||||||
vault write auth/approle/role/callbacks \
|
|
||||||
token_policies="callbacks" \
|
|
||||||
token_ttl="1h" \
|
|
||||||
token_max_ttl="24h"
|
|
||||||
vault read -field=role_id auth/approle/role/callbacks/role-id
|
|
||||||
vault write -f -field=secret_id auth/approle/role/callbacks/secret-id
|
|
||||||
```
|
|
||||||
|
|
||||||
Store AppRole creds for prod deploy pipeline:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vault kv put kv/sendico/edge/callbacks/vault \
|
|
||||||
role_id="<callbacks-role-id>" \
|
|
||||||
secret_id="<callbacks-secret-id>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Store webhook signing secrets (example path consumed by `secret_ref`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
vault kv put kv/sendico/callbacks/client-a/webhook secret="super-secret"
|
|
||||||
```
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
# See the dedicated "version" documentation section.
|
|
||||||
version: "2"
|
|
||||||
linters:
|
|
||||||
# Default set of linters.
|
|
||||||
# The value can be:
|
|
||||||
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
|
|
||||||
# - `all`: enables all linters by default.
|
|
||||||
# - `none`: disables all linters by default.
|
|
||||||
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
|
|
||||||
# Default: standard
|
|
||||||
default: all
|
|
||||||
# Enable specific linter.
|
|
||||||
enable:
|
|
||||||
- arangolint
|
|
||||||
- asasalint
|
|
||||||
- asciicheck
|
|
||||||
- bidichk
|
|
||||||
- bodyclose
|
|
||||||
- canonicalheader
|
|
||||||
- containedctx
|
|
||||||
- contextcheck
|
|
||||||
- copyloopvar
|
|
||||||
- cyclop
|
|
||||||
- decorder
|
|
||||||
- dogsled
|
|
||||||
- dupl
|
|
||||||
- dupword
|
|
||||||
- durationcheck
|
|
||||||
- embeddedstructfieldcheck
|
|
||||||
- err113
|
|
||||||
- errcheck
|
|
||||||
- errchkjson
|
|
||||||
- errname
|
|
||||||
- errorlint
|
|
||||||
- exhaustive
|
|
||||||
- exptostd
|
|
||||||
- fatcontext
|
|
||||||
- forbidigo
|
|
||||||
- forcetypeassert
|
|
||||||
- funcorder
|
|
||||||
- funlen
|
|
||||||
- ginkgolinter
|
|
||||||
- gocheckcompilerdirectives
|
|
||||||
- gochecknoglobals
|
|
||||||
- gochecknoinits
|
|
||||||
- gochecksumtype
|
|
||||||
- gocognit
|
|
||||||
- goconst
|
|
||||||
- gocritic
|
|
||||||
- gocyclo
|
|
||||||
- godoclint
|
|
||||||
- godot
|
|
||||||
- godox
|
|
||||||
- goheader
|
|
||||||
- gomodguard
|
|
||||||
- goprintffuncname
|
|
||||||
- gosec
|
|
||||||
- gosmopolitan
|
|
||||||
- govet
|
|
||||||
- grouper
|
|
||||||
- iface
|
|
||||||
- importas
|
|
||||||
- inamedparam
|
|
||||||
- ineffassign
|
|
||||||
- interfacebloat
|
|
||||||
- intrange
|
|
||||||
- iotamixing
|
|
||||||
- ireturn
|
|
||||||
- lll
|
|
||||||
- loggercheck
|
|
||||||
- maintidx
|
|
||||||
- makezero
|
|
||||||
- mirror
|
|
||||||
- misspell
|
|
||||||
- mnd
|
|
||||||
- modernize
|
|
||||||
- musttag
|
|
||||||
- nakedret
|
|
||||||
- nestif
|
|
||||||
- nilerr
|
|
||||||
- nilnesserr
|
|
||||||
- nilnil
|
|
||||||
- nlreturn
|
|
||||||
- noctx
|
|
||||||
- noinlineerr
|
|
||||||
- nolintlint
|
|
||||||
- nonamedreturns
|
|
||||||
- nosprintfhostport
|
|
||||||
- paralleltest
|
|
||||||
- perfsprint
|
|
||||||
- prealloc
|
|
||||||
- predeclared
|
|
||||||
- promlinter
|
|
||||||
- protogetter
|
|
||||||
- reassign
|
|
||||||
- recvcheck
|
|
||||||
- revive
|
|
||||||
- rowserrcheck
|
|
||||||
- sloglint
|
|
||||||
- spancheck
|
|
||||||
- sqlclosecheck
|
|
||||||
- staticcheck
|
|
||||||
- tagalign
|
|
||||||
- tagliatelle
|
|
||||||
- testableexamples
|
|
||||||
- testifylint
|
|
||||||
- testpackage
|
|
||||||
- thelper
|
|
||||||
- tparallel
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
- unqueryvet
|
|
||||||
- unused
|
|
||||||
- usestdlibvars
|
|
||||||
- usetesting
|
|
||||||
- varnamelen
|
|
||||||
- wastedassign
|
|
||||||
- whitespace
|
|
||||||
- wsl_v5
|
|
||||||
- zerologlint
|
|
||||||
# Disable specific linters.
|
|
||||||
disable:
|
|
||||||
- depguard
|
|
||||||
- exhaustruct
|
|
||||||
- gochecknoglobals
|
|
||||||
- gomoddirectives
|
|
||||||
- wsl
|
|
||||||
- wrapcheck
|
|
||||||
# All available settings of specific linters.
|
|
||||||
# See the dedicated "linters.settings" documentation section.
|
|
||||||
settings:
|
|
||||||
wsl_v5:
|
|
||||||
allow-first-in-block: true
|
|
||||||
allow-whole-block: false
|
|
||||||
branch-max-lines: 2
|
|
||||||
|
|
||||||
# Defines a set of rules to ignore issues.
|
|
||||||
# It does not skip the analysis, and so does not ignore "typecheck" errors.
|
|
||||||
exclusions:
|
|
||||||
# Mode of the generated files analysis.
|
|
||||||
#
|
|
||||||
# - `strict`: sources are excluded by strictly following the Go generated file convention.
|
|
||||||
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
|
|
||||||
# This line must appear before the first non-comment, non-blank text in the file.
|
|
||||||
# https://go.dev/s/generatedcode
|
|
||||||
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
|
|
||||||
# - `disable`: disable the generated files exclusion.
|
|
||||||
#
|
|
||||||
# Default: strict
|
|
||||||
generated: lax
|
|
||||||
# Log a warning if an exclusion rule is unused.
|
|
||||||
# Default: false
|
|
||||||
warn-unused: true
|
|
||||||
# Predefined exclusion rules.
|
|
||||||
# Default: []
|
|
||||||
presets:
|
|
||||||
- comments
|
|
||||||
- std-error-handling
|
|
||||||
- common-false-positives
|
|
||||||
- legacy
|
|
||||||
# Excluding configuration per-path, per-linter, per-text and per-source.
|
|
||||||
rules:
|
|
||||||
# Exclude some linters from running on tests files.
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- funlen
|
|
||||||
- gocyclo
|
|
||||||
- errcheck
|
|
||||||
- dupl
|
|
||||||
- gosec
|
|
||||||
# Run some linter only for test files by excluding its issues for everything else.
|
|
||||||
- path-except: _test\.go
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
# Exclude known linters from partially hard-vendored code,
|
|
||||||
# which is impossible to exclude via `nolint` comments.
|
|
||||||
# `/` will be replaced by the current OS file path separator to properly work on Windows.
|
|
||||||
- path: internal/hmac/
|
|
||||||
text: "weak cryptographic primitive"
|
|
||||||
linters:
|
|
||||||
- gosec
|
|
||||||
# Exclude some `staticcheck` messages.
|
|
||||||
- linters:
|
|
||||||
- staticcheck
|
|
||||||
text: "SA9003:"
|
|
||||||
# Exclude `lll` issues for long lines with `go:generate`.
|
|
||||||
- linters:
|
|
||||||
- lll
|
|
||||||
source: "^//go:generate "
|
|
||||||
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
|
|
||||||
# "/" will be replaced by the current OS file path separator to properly work on Windows.
|
|
||||||
# Default: []
|
|
||||||
paths: []
|
|
||||||
# Which file paths to not exclude.
|
|
||||||
# Default: []
|
|
||||||
paths-except: []
|
|
||||||
@@ -26,7 +26,7 @@ documents:
|
|||||||
issuer:
|
issuer:
|
||||||
legal_name: "Sendico Ltd"
|
legal_name: "Sendico Ltd"
|
||||||
legal_address: "12 Market Street, London, UK"
|
legal_address: "12 Market Street, London, UK"
|
||||||
logo_path: "assets/logo.png"
|
logo_path: "/assets/logo.png"
|
||||||
templates:
|
templates:
|
||||||
acceptance_path: "templates/acceptance.tpl"
|
acceptance_path: "templates/acceptance.tpl"
|
||||||
protection:
|
protection:
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ documents:
|
|||||||
issuer:
|
issuer:
|
||||||
legal_name: "Sendico Ltd"
|
legal_name: "Sendico Ltd"
|
||||||
legal_address: "12 Market Street, London, UK"
|
legal_address: "12 Market Street, London, UK"
|
||||||
logo_path: "/app/assets/logo.png"
|
logo_path: "/assets/logo.png"
|
||||||
templates:
|
templates:
|
||||||
acceptance_path: "/app/templates/acceptance.tpl"
|
acceptance_path: "templates/acceptance.tpl"
|
||||||
protection:
|
protection:
|
||||||
owner_password: "sendico-documents"
|
owner_password: "sendico-documents"
|
||||||
storage:
|
storage:
|
||||||
|
|||||||
@@ -1,70 +1,70 @@
|
|||||||
module github.com/tech/sendico/billing/documents
|
module github.com/tech/sendico/billing/documents
|
||||||
|
|
||||||
go 1.25.7
|
go 1.25.6
|
||||||
|
|
||||||
replace github.com/tech/sendico/pkg => ../../pkg
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||||
github.com/jung-kurt/gofpdf v1.16.2
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.1
|
google.golang.org/grpc v1.78.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||||
github.com/aws/smithy-go v1.24.2 // indirect
|
github.com/aws/smithy-go v1.24.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.135.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/v4 v4.3.0 // indirect
|
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
github.com/go-chi/chi/v5 v5.2.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.3 // 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
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
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
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,44 +4,44 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/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/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
@@ -78,8 +78,8 @@ 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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -100,8 +100,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
|||||||
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||||
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.3/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=
|
||||||
@@ -134,8 +134,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -158,8 +158,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
|||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||||
@@ -201,16 +201,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
|||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
@@ -221,16 +221,16 @@ 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
@@ -241,16 +241,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
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=
|
||||||
@@ -258,10 +258,10 @@ 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-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -24,6 +24,5 @@ func Create() version.Printer {
|
|||||||
BuildDate: BuildDate,
|
BuildDate: BuildDate,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
return vf.Create(&info)
|
return vf.Create(&info)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,11 @@ func NewLocalStore(logger mlogger.Logger, cfg LocalConfig) (*LocalStore, error)
|
|||||||
if root == "" {
|
if root == "" {
|
||||||
return nil, merrors.InvalidArgument("docstore: local root_path is empty")
|
return nil, merrors.InvalidArgument("docstore: local root_path is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
store := &LocalStore{
|
store := &LocalStore{
|
||||||
logger: logger.Named("docstore").Named("local"),
|
logger: logger.Named("docstore").Named("local"),
|
||||||
rootPath: root,
|
rootPath: root,
|
||||||
}
|
}
|
||||||
store.logger.Info("Document storage initialised", zap.String("root_path", root))
|
store.logger.Info("Document storage initialised", zap.String("root_path", root))
|
||||||
|
|
||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,19 +33,15 @@ func (s *LocalStore) Save(ctx context.Context, key string, data []byte) error {
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
s.logger.Warn("Failed to create document directory", zap.Error(err), zap.String("path", path))
|
s.logger.Warn("Failed to create document directory", zap.Error(err), zap.String("path", path))
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
s.logger.Warn("Failed to write document file", zap.Error(err), zap.String("path", path))
|
s.logger.Warn("Failed to write document file", zap.Error(err), zap.String("path", path))
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,16 +49,12 @@ func (s *LocalStore) Load(ctx context.Context, key string) ([]byte, error) {
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("Failed to read document file", zap.Error(err), zap.String("path", path))
|
s.logger.Warn("Failed to read document file", zap.Error(err), zap.String("path", path))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
|||||||
if accessKey == "" && cfg.AccessKeyEnv != "" {
|
if accessKey == "" && cfg.AccessKeyEnv != "" {
|
||||||
accessKey = strings.TrimSpace(os.Getenv(cfg.AccessKeyEnv))
|
accessKey = strings.TrimSpace(os.Getenv(cfg.AccessKeyEnv))
|
||||||
}
|
}
|
||||||
|
|
||||||
secretKey := strings.TrimSpace(cfg.SecretAccessKey)
|
secretKey := strings.TrimSpace(cfg.SecretAccessKey)
|
||||||
if secretKey == "" && cfg.SecretKeyEnv != "" {
|
if secretKey == "" && cfg.SecretKeyEnv != "" {
|
||||||
secretKey = strings.TrimSpace(os.Getenv(cfg.SecretKeyEnv))
|
secretKey = strings.TrimSpace(os.Getenv(cfg.SecretKeyEnv))
|
||||||
@@ -63,21 +62,23 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
|||||||
endpoint = "http://" + endpoint
|
endpoint = "http://" + endpoint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
|
||||||
|
if service == s3.ServiceID {
|
||||||
|
return aws.Endpoint{URL: endpoint, SigningRegion: region, HostnameImmutable: true}, nil
|
||||||
|
}
|
||||||
|
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
|
||||||
|
})
|
||||||
|
loadOpts = append(loadOpts, config.WithEndpointResolverWithOptions(resolver))
|
||||||
}
|
}
|
||||||
|
|
||||||
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
|
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucket))
|
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucket))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
|
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
|
||||||
opts.UsePathStyle = cfg.ForcePathStyle
|
opts.UsePathStyle = cfg.ForcePathStyle
|
||||||
|
|
||||||
if endpoint != "" {
|
|
||||||
opts.BaseEndpoint = aws.String(endpoint)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
store := &S3Store{
|
store := &S3Store{
|
||||||
@@ -86,7 +87,6 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
|||||||
bucket: bucket,
|
bucket: bucket,
|
||||||
}
|
}
|
||||||
store.logger.Info("Document storage initialised", zap.String("bucket", bucket), zap.String("endpoint", endpoint))
|
store.logger.Info("Document storage initialised", zap.String("bucket", bucket), zap.String("endpoint", endpoint))
|
||||||
|
|
||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,6 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
Bucket: aws.String(s.bucket),
|
Bucket: aws.String(s.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
@@ -102,10 +101,8 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("Failed to upload document", zap.Error(err), zap.String("key", key))
|
s.logger.Warn("Failed to upload document", zap.Error(err), zap.String("key", key))
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,19 +110,15 @@ func (s *S3Store) Load(ctx context.Context, key string) ([]byte, error) {
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
obj, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
obj, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
Bucket: aws.String(s.bucket),
|
Bucket: aws.String(s.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("Failed to fetch document", zap.Error(err), zap.String("key", key))
|
s.logger.Warn("Failed to fetch document", zap.Error(err), zap.String("key", key))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer obj.Body.Close()
|
defer obj.Body.Close()
|
||||||
|
|
||||||
return io.ReadAll(obj.Body)
|
return io.ReadAll(obj.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type S3Config struct {
|
|||||||
Bucket string `yaml:"bucket"`
|
Bucket string `yaml:"bucket"`
|
||||||
AccessKeyEnv string `yaml:"access_key_env"`
|
AccessKeyEnv string `yaml:"access_key_env"`
|
||||||
SecretKeyEnv string `yaml:"secret_access_key_env"`
|
SecretKeyEnv string `yaml:"secret_access_key_env"`
|
||||||
AccessKey string `yaml:"access_key"` //nolint:gosec // config field, not a hardcoded secret
|
AccessKey string `yaml:"access_key"`
|
||||||
SecretAccessKey string `yaml:"secret_access_key"`
|
SecretAccessKey string `yaml:"secret_access_key"`
|
||||||
UseSSL bool `yaml:"use_ssl"`
|
UseSSL bool `yaml:"use_ssl"`
|
||||||
ForcePathStyle bool `yaml:"force_path_style"`
|
ForcePathStyle bool `yaml:"force_path_style"`
|
||||||
@@ -55,13 +55,11 @@ func New(logger mlogger.Logger, cfg Config) (Store, error) {
|
|||||||
if cfg.Local == nil {
|
if cfg.Local == nil {
|
||||||
return nil, merrors.InvalidArgument("docstore: local config missing")
|
return nil, merrors.InvalidArgument("docstore: local config missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewLocalStore(logger, *cfg.Local)
|
return NewLocalStore(logger, *cfg.Local)
|
||||||
case string(DriverS3), string(DriverMinio):
|
case string(DriverS3), string(DriverMinio):
|
||||||
if cfg.S3 == nil {
|
if cfg.S3 == nil {
|
||||||
return nil, merrors.InvalidArgument("docstore: s3 config missing")
|
return nil, merrors.InvalidArgument("docstore: s3 config missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewS3Store(logger, *cfg.S3)
|
return NewS3Store(logger, *cfg.S3)
|
||||||
default:
|
default:
|
||||||
return nil, merrors.InvalidArgument("docstore: unsupported driver")
|
return nil, merrors.InvalidArgument("docstore: unsupported driver")
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ type Imp struct {
|
|||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
*grpcapp.Config `yaml:",inline"`
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
Documents documents.Config `yaml:"documents"`
|
||||||
Documents documents.Config `yaml:"documents"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create initialises the billing documents server implementation.
|
// Create initialises the billing documents server implementation.
|
||||||
@@ -47,7 +46,6 @@ func (i *Imp) Shutdown() {
|
|||||||
if i.service != nil {
|
if i.service != nil {
|
||||||
i.service.Shutdown()
|
i.service.Shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +68,6 @@ func (i *Imp) Start() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.config = cfg
|
i.config = cfg
|
||||||
|
|
||||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
@@ -80,23 +77,20 @@ func (i *Imp) Start() error {
|
|||||||
docStore, err := docstore.New(i.logger, cfg.Documents.Storage)
|
docStore, err := docstore.New(i.logger, cfg.Documents.Storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Failed to initialise document storage", zap.Error(err))
|
i.logger.Error("Failed to initialise document storage", zap.Error(err))
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { //nolint:lll // factory signature dictated by grpcapp
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
invokeURI := ""
|
invokeURI := ""
|
||||||
if cfg.GRPC != nil {
|
if cfg.GRPC != nil {
|
||||||
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := documents.NewService(logger, repo, producer,
|
svc := documents.NewService(logger, repo, producer,
|
||||||
documents.WithDiscoveryInvokeURI(invokeURI),
|
documents.WithDiscoveryInvokeURI(invokeURI),
|
||||||
documents.WithConfig(cfg.Documents),
|
documents.WithConfig(cfg.Documents),
|
||||||
documents.WithDocumentStore(docStore),
|
documents.WithDocumentStore(docStore),
|
||||||
)
|
)
|
||||||
i.service = svc
|
i.service = svc
|
||||||
|
|
||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +98,6 @@ func (i *Imp) Start() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.app = app
|
i.app = app
|
||||||
|
|
||||||
return i.app.Start()
|
return i.app.Start()
|
||||||
@@ -114,14 +107,12 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
data, err := os.ReadFile(i.file)
|
data, err := os.ReadFile(i.file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &config{Config: &grpcapp.Config{}}
|
cfg := &config{Config: &grpcapp.Config{}}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,5 @@ func (c Config) AcceptanceTemplatePath() string {
|
|||||||
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
||||||
return "templates/acceptance.tpl"
|
return "templates/acceptance.tpl"
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Templates.AcceptancePath
|
return c.Templates.AcceptancePath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,18 +85,14 @@ func statusFromError(err error) string {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
st, ok := status.FromError(err)
|
st, ok := status.FromError(err)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "error"
|
return "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
code := st.Code()
|
code := st.Code()
|
||||||
|
|
||||||
if code == codes.OK {
|
if code == codes.OK {
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.ToLower(code.String())
|
return strings.ToLower(code.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +101,5 @@ func docTypeLabel(docType documentsv1.DocumentType) string {
|
|||||||
if label == "" {
|
if label == "" {
|
||||||
return "DOCUMENT_TYPE_UNSPECIFIED"
|
return "DOCUMENT_TYPE_UNSPECIFIED"
|
||||||
}
|
}
|
||||||
|
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -40,7 +41,6 @@ func WithDiscoveryInvokeURI(uri string) Option {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.invokeURI = strings.TrimSpace(uri)
|
s.invokeURI = strings.TrimSpace(uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,6 @@ func WithProducer(producer msg.Producer) Option {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.producer = producer
|
s.producer = producer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +61,6 @@ func WithConfig(cfg Config) Option {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.config = cfg
|
s.config = cfg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +71,6 @@ func WithDocumentStore(store docstore.Store) Option {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.docStore = store
|
s.docStore = store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,15 +81,12 @@ func WithTemplateRenderer(renderer TemplateRenderer) Option {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.template = renderer
|
s.template = renderer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service provides billing document metadata and retrieval endpoints.
|
// Service provides billing document metadata and retrieval endpoints.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
documentsv1.UnimplementedDocumentServiceServer
|
|
||||||
|
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
docStore docstore.Store
|
docStore docstore.Store
|
||||||
@@ -101,12 +95,12 @@ type Service struct {
|
|||||||
invokeURI string
|
invokeURI string
|
||||||
config Config
|
config Config
|
||||||
template TemplateRenderer
|
template TemplateRenderer
|
||||||
|
documentsv1.UnimplementedDocumentServiceServer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a documents service with optional configuration.
|
// NewService constructs a documents service with optional configuration.
|
||||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||||
initMetrics()
|
initMetrics()
|
||||||
|
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
logger: logger.Named("documents"),
|
logger: logger.Named("documents"),
|
||||||
storage: repo,
|
storage: repo,
|
||||||
@@ -115,17 +109,14 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(svc)
|
opt(svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.template == nil {
|
if svc.template == nil {
|
||||||
if tmpl, err := newTemplateRenderer(svc.config.AcceptanceTemplatePath()); err != nil {
|
if tmpl, err := newTemplateRenderer(svc.config.AcceptanceTemplatePath()); err != nil {
|
||||||
svc.logger.Warn("Failed to load acceptance template", zap.Error(err))
|
svc.logger.Warn("failed to load acceptance template", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
svc.template = tmpl
|
svc.template = tmpl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.startDiscoveryAnnouncer()
|
svc.startDiscoveryAnnouncer()
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,50 +130,107 @@ func (s *Service) Shutdown() {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.announcer != nil {
|
if s.announcer != nil {
|
||||||
s.announcer.Stop()
|
s.announcer.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "BILLING_DOCUMENTS",
|
||||||
|
Operations: []string{"documents.batch_resolve", "documents.get"},
|
||||||
|
InvokeURI: s.invokeURI,
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.BillingDocuments), announce)
|
||||||
|
s.announcer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
paymentRefs := 0
|
var paymentRefs []string
|
||||||
if req != nil {
|
if req != nil {
|
||||||
paymentRefs = len(req.GetPaymentRefs())
|
paymentRefs = req.GetPaymentRefs()
|
||||||
}
|
}
|
||||||
|
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
|
||||||
logger := s.logger.With(zap.Int("payment_refs", paymentRefs))
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
statusLabel := statusFromError(err)
|
statusLabel := statusFromError(err)
|
||||||
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
||||||
observeBatchSize(paymentRefs)
|
observeBatchSize(len(paymentRefs))
|
||||||
|
|
||||||
itemsCount := 0
|
itemsCount := 0
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
itemsCount = len(resp.GetItems())
|
itemsCount = len(resp.GetItems())
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := []zap.Field{
|
fields := []zap.Field{
|
||||||
zap.String("status", statusLabel),
|
zap.String("status", statusLabel),
|
||||||
zap.Duration("duration", time.Since(start)),
|
zap.Duration("duration", time.Since(start)),
|
||||||
zap.Int("items", itemsCount),
|
zap.Int("items", itemsCount),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("BatchResolveDocuments failed", append(fields, zap.Error(err))...)
|
logger.Warn("BatchResolveDocuments failed", append(fields, zap.Error(err))...)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("BatchResolveDocuments finished", fields...)
|
logger.Info("BatchResolveDocuments finished", fields...)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_ = ctx
|
if len(paymentRefs) == 0 {
|
||||||
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
return nil, err
|
if s.storage == nil {
|
||||||
|
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refs := make([]string, 0, len(paymentRefs))
|
||||||
|
for _, ref := range paymentRefs {
|
||||||
|
clean := strings.TrimSpace(ref)
|
||||||
|
if clean == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refs = append(refs, clean)
|
||||||
|
}
|
||||||
|
if len(refs) == 0 {
|
||||||
|
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := s.storage.Documents().ListByPaymentRefs(ctx, refs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
recordByRef := map[string]*model.DocumentRecord{}
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recordByRef[record.PaymentRef] = record
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*documentsv1.DocumentMeta, 0, len(refs))
|
||||||
|
for _, ref := range refs {
|
||||||
|
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
|
||||||
|
if record := recordByRef[ref]; record != nil {
|
||||||
|
record.Normalize()
|
||||||
|
available := []model.DocumentType{model.DocumentTypeAct}
|
||||||
|
ready := make([]model.DocumentType, 0, 1)
|
||||||
|
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
|
||||||
|
ready = append(ready, model.DocumentTypeAct)
|
||||||
|
}
|
||||||
|
meta.AvailableTypes = toProtoTypes(available)
|
||||||
|
meta.ReadyTypes = toProtoTypes(ready)
|
||||||
|
}
|
||||||
|
items = append(items, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
||||||
@@ -193,7 +241,6 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
|||||||
docType = req.GetType()
|
docType = req.GetType()
|
||||||
paymentRef = strings.TrimSpace(req.GetPaymentRef())
|
paymentRef = strings.TrimSpace(req.GetPaymentRef())
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := s.logger.With(
|
logger := s.logger.With(
|
||||||
zap.String("payment_ref", paymentRef),
|
zap.String("payment_ref", paymentRef),
|
||||||
zap.String("document_type", docTypeLabel(docType)),
|
zap.String("document_type", docTypeLabel(docType)),
|
||||||
@@ -202,7 +249,6 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
|||||||
defer func() {
|
defer func() {
|
||||||
statusLabel := statusFromError(err)
|
statusLabel := statusFromError(err)
|
||||||
observeRequest("get_document", docType, statusLabel, time.Since(start))
|
observeRequest("get_document", docType, statusLabel, time.Since(start))
|
||||||
|
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
observeDocumentBytes(docType, len(resp.GetContent()))
|
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||||
}
|
}
|
||||||
@@ -211,131 +257,93 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
|||||||
if resp != nil {
|
if resp != nil {
|
||||||
contentBytes = len(resp.GetContent())
|
contentBytes = len(resp.GetContent())
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := []zap.Field{
|
fields := []zap.Field{
|
||||||
zap.String("status", statusLabel),
|
zap.String("status", statusLabel),
|
||||||
zap.Duration("duration", time.Since(start)),
|
zap.Duration("duration", time.Since(start)),
|
||||||
zap.Int("content_bytes", contentBytes),
|
zap.Int("content_bytes", contentBytes),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("GetDocument failed", append(fields, zap.Error(err))...)
|
logger.Warn("GetDocument failed", append(fields, zap.Error(err))...)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("GetDocument finished", fields...)
|
logger.Info("GetDocument finished", fields...)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_ = ctx
|
if paymentRef == "" {
|
||||||
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
err = status.Error(codes.InvalidArgument, "payment_ref is required")
|
||||||
|
return nil, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
|
||||||
start := time.Now()
|
|
||||||
organizationRef := ""
|
|
||||||
gatewayService := ""
|
|
||||||
operationRef := ""
|
|
||||||
|
|
||||||
if req != nil {
|
|
||||||
organizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
|
||||||
gatewayService = strings.TrimSpace(req.GetGatewayService())
|
|
||||||
operationRef = strings.TrimSpace(req.GetOperationRef())
|
|
||||||
}
|
}
|
||||||
|
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
|
||||||
logger := s.logger.With(
|
err = status.Error(codes.InvalidArgument, "document type is required")
|
||||||
zap.String("organization_ref", organizationRef),
|
return nil, err
|
||||||
zap.String("gateway_service", gatewayService),
|
}
|
||||||
zap.String("operation_ref", operationRef),
|
if s.storage == nil {
|
||||||
)
|
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||||
|
return nil, err
|
||||||
defer func() {
|
}
|
||||||
statusLabel := statusFromError(err)
|
if s.docStore == nil {
|
||||||
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
|
||||||
observeRequest("get_operation_document", docType, statusLabel, time.Since(start))
|
return nil, err
|
||||||
|
}
|
||||||
if resp != nil {
|
if s.template == nil {
|
||||||
observeDocumentBytes(docType, len(resp.GetContent()))
|
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
|
||||||
}
|
|
||||||
|
|
||||||
contentBytes := 0
|
|
||||||
if resp != nil {
|
|
||||||
contentBytes = len(resp.GetContent())
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := []zap.Field{
|
|
||||||
zap.String("status", statusLabel),
|
|
||||||
zap.Duration("duration", time.Since(start)),
|
|
||||||
zap.Int("content_bytes", contentBytes),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("GetOperationDocument failed", append(fields, zap.Error(err))...)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("GetOperationDocument finished", fields...)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if req == nil {
|
|
||||||
err = status.Error(codes.InvalidArgument, "request is required")
|
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if organizationRef == "" {
|
record, err := s.storage.Documents().GetByPaymentRef(ctx, paymentRef)
|
||||||
err = status.Error(codes.InvalidArgument, "organization_ref is required")
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||||
|
return nil, status.Error(codes.NotFound, "document record not found")
|
||||||
|
}
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
record.Normalize()
|
||||||
|
|
||||||
return nil, err
|
targetType := model.DocumentTypeFromProto(docType)
|
||||||
|
|
||||||
|
if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "document type not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
if gatewayService == "" {
|
if path, ok := record.StoragePaths[targetType]; ok && path != "" {
|
||||||
err = status.Error(codes.InvalidArgument, "gateway_service is required")
|
content, loadErr := s.docStore.Load(ctx, path)
|
||||||
|
if loadErr != nil {
|
||||||
return nil, err
|
return nil, status.Error(codes.Internal, loadErr.Error())
|
||||||
|
}
|
||||||
|
return &documentsv1.GetDocumentResponse{
|
||||||
|
Content: content,
|
||||||
|
Filename: documentFilename(docType, paymentRef),
|
||||||
|
MimeType: "application/pdf",
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if operationRef == "" {
|
content, hash, genErr := s.generateActPDF(record.Snapshot)
|
||||||
err = status.Error(codes.InvalidArgument, "operation_ref is required")
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot := operationSnapshotFromRequest(req)
|
|
||||||
content, _, genErr := s.generateOperationPDF(snapshot)
|
|
||||||
if genErr != nil {
|
if genErr != nil {
|
||||||
err = status.Error(codes.Internal, genErr.Error())
|
logger.Warn("Failed to generate document", zap.Error(genErr))
|
||||||
|
return nil, status.Error(codes.Internal, genErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return nil, err
|
path := documentStoragePath(paymentRef, docType)
|
||||||
|
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
|
||||||
|
logger.Warn("Failed to store document", zap.Error(saveErr))
|
||||||
|
return nil, status.Error(codes.Internal, saveErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
record.StoragePaths[targetType] = path
|
||||||
|
record.Hashes[targetType] = hash
|
||||||
|
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
|
||||||
|
logger.Warn("Failed to update document record", zap.Error(updateErr))
|
||||||
|
return nil, status.Error(codes.Internal, updateErr.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = &documentsv1.GetDocumentResponse{
|
resp = &documentsv1.GetDocumentResponse{
|
||||||
Content: content,
|
Content: content,
|
||||||
Filename: operationDocumentFilename(operationRef),
|
Filename: documentFilename(docType, paymentRef),
|
||||||
MimeType: "application/pdf",
|
MimeType: "application/pdf",
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) startDiscoveryAnnouncer() {
|
|
||||||
if s == nil || s.producer == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
announce := discovery.Announcement{
|
|
||||||
Service: mservice.BillingDocuments,
|
|
||||||
Operations: []string{discovery.OperationDocumentsGet},
|
|
||||||
InvokeURI: s.invokeURI,
|
|
||||||
Version: appversion.Create().Short(),
|
|
||||||
}
|
|
||||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.BillingDocuments, announce)
|
|
||||||
s.announcer.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
type serviceError string
|
type serviceError string
|
||||||
|
|
||||||
func (e serviceError) Error() string {
|
func (e serviceError) Error() string {
|
||||||
@@ -353,27 +361,15 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.renderPDFWithIntegrity(blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, string, error) {
|
|
||||||
return s.renderPDFWithIntegrity(buildOperationBlocks(snapshot))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
|
|
||||||
generated := renderer.Renderer{
|
generated := renderer.Renderer{
|
||||||
Issuer: s.config.Issuer,
|
Issuer: s.config.Issuer,
|
||||||
OwnerPassword: s.config.Protection.OwnerPassword,
|
OwnerPassword: s.config.Protection.OwnerPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
placeholder := strings.Repeat("0", 64)
|
placeholder := strings.Repeat("0", 64)
|
||||||
|
|
||||||
firstPass, err := generated.Render(blocks, placeholder)
|
firstPass, err := generated.Render(blocks, placeholder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
footerHash := sha256.Sum256(firstPass)
|
footerHash := sha256.Sum256(firstPass)
|
||||||
footerHex := hex.EncodeToString(footerHash[:])
|
footerHex := hex.EncodeToString(footerHash[:])
|
||||||
|
|
||||||
@@ -381,177 +377,22 @@ func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalBytes, footerHex, nil
|
return finalBytes, footerHex, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type operationSnapshot struct {
|
|
||||||
OrganizationRef string
|
|
||||||
GatewayService string
|
|
||||||
OperationRef string
|
|
||||||
PaymentRef string
|
|
||||||
OperationCode string
|
|
||||||
OperationLabel string
|
|
||||||
OperationState string
|
|
||||||
FailureCode string
|
|
||||||
FailureReason string
|
|
||||||
Amount string
|
|
||||||
Currency string
|
|
||||||
StartedAt time.Time
|
|
||||||
CompletedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest) operationSnapshot {
|
|
||||||
snapshot := operationSnapshot{
|
|
||||||
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
|
|
||||||
GatewayService: strings.TrimSpace(req.GetGatewayService()),
|
|
||||||
OperationRef: strings.TrimSpace(req.GetOperationRef()),
|
|
||||||
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
|
|
||||||
OperationCode: strings.TrimSpace(req.GetOperationCode()),
|
|
||||||
OperationLabel: strings.TrimSpace(req.GetOperationLabel()),
|
|
||||||
OperationState: strings.TrimSpace(req.GetOperationState()),
|
|
||||||
FailureCode: strings.TrimSpace(req.GetFailureCode()),
|
|
||||||
FailureReason: strings.TrimSpace(req.GetFailureReason()),
|
|
||||||
Amount: strings.TrimSpace(req.GetAmount()),
|
|
||||||
Currency: strings.TrimSpace(req.GetCurrency()),
|
|
||||||
}
|
|
||||||
|
|
||||||
if ts := req.GetStartedAtUnixMs(); ts > 0 {
|
|
||||||
snapshot.StartedAt = time.UnixMilli(ts).UTC()
|
|
||||||
}
|
|
||||||
if ts := req.GetCompletedAtUnixMs(); ts > 0 {
|
|
||||||
snapshot.CompletedAt = time.UnixMilli(ts).UTC()
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
|
|
||||||
rows := [][]string{
|
|
||||||
{"Organization", snapshot.OrganizationRef},
|
|
||||||
{"Gateway Service", snapshot.GatewayService},
|
|
||||||
{"Operation Ref", snapshot.OperationRef},
|
|
||||||
{"Payment Ref", safeValue(snapshot.PaymentRef)},
|
|
||||||
{"Code", safeValue(snapshot.OperationCode)},
|
|
||||||
{"State", safeValue(snapshot.OperationState)},
|
|
||||||
{"Label", safeValue(snapshot.OperationLabel)},
|
|
||||||
{"Started At (UTC)", formatSnapshotTime(snapshot.StartedAt)},
|
|
||||||
{"Completed At (UTC)", formatSnapshotTime(snapshot.CompletedAt)},
|
|
||||||
}
|
|
||||||
if snapshot.Amount != "" || snapshot.Currency != "" {
|
|
||||||
rows = append(rows, []string{"Amount", strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
|
|
||||||
}
|
|
||||||
|
|
||||||
blocks := []renderer.Block{
|
|
||||||
{
|
|
||||||
Tag: renderer.TagTitle,
|
|
||||||
Lines: []string{"OPERATION BILLING DOCUMENT"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Tag: renderer.TagSubtitle,
|
|
||||||
Lines: []string{"Gateway operation statement"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Tag: renderer.TagMeta,
|
|
||||||
Lines: []string{
|
|
||||||
"Document Type: Operation",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Tag: renderer.TagSection,
|
|
||||||
Lines: []string{"OPERATION DETAILS"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Tag: renderer.TagKV,
|
|
||||||
Rows: rows,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
|
|
||||||
blocks = append(blocks,
|
|
||||||
renderer.Block{Tag: renderer.TagSection, Lines: []string{"FAILURE DETAILS"}},
|
|
||||||
renderer.Block{
|
|
||||||
Tag: renderer.TagKV,
|
|
||||||
Rows: [][]string{
|
|
||||||
{"Failure Code", safeValue(snapshot.FailureCode)},
|
|
||||||
{"Failure Reason", safeValue(snapshot.FailureReason)},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatSnapshotTime(value time.Time) string {
|
|
||||||
if value.IsZero() {
|
|
||||||
return "n/a"
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeValue(value string) string {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed == "" {
|
|
||||||
return "n/a"
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationDocumentFilename(operationRef string) string {
|
|
||||||
clean := sanitizeFilenameComponent(operationRef)
|
|
||||||
if clean == "" {
|
|
||||||
clean = "operation"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("operation_%s.pdf", clean)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeFilenameComponent(value string) string {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(len(trimmed))
|
|
||||||
|
|
||||||
for _, r := range trimmed {
|
|
||||||
switch {
|
|
||||||
case r >= 'a' && r <= 'z':
|
|
||||||
b.WriteRune(r)
|
|
||||||
case r >= 'A' && r <= 'Z':
|
|
||||||
b.WriteRune(r)
|
|
||||||
case r >= '0' && r <= '9':
|
|
||||||
b.WriteRune(r)
|
|
||||||
case r == '-', r == '_':
|
|
||||||
b.WriteRune(r)
|
|
||||||
default:
|
|
||||||
b.WriteRune('_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Trim(b.String(), "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
||||||
if len(types) == 0 {
|
if len(types) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]documentsv1.DocumentType, 0, len(types))
|
result := make([]documentsv1.DocumentType, 0, len(types))
|
||||||
for _, t := range types {
|
for _, t := range types {
|
||||||
result = append(result, t.Proto())
|
result = append(result, t.Proto())
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) string {
|
func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) string {
|
||||||
suffix := "document.pdf"
|
suffix := "document.pdf"
|
||||||
|
|
||||||
switch docType {
|
switch docType {
|
||||||
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||||
suffix = "act.pdf"
|
suffix = "act.pdf"
|
||||||
@@ -559,16 +400,12 @@ func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) st
|
|||||||
suffix = "invoice.pdf"
|
suffix = "invoice.pdf"
|
||||||
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||||
suffix = "receipt.pdf"
|
suffix = "receipt.pdf"
|
||||||
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
|
||||||
// default suffix used
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.ToSlash(filepath.Join("documents", paymentRef, suffix))
|
return filepath.ToSlash(filepath.Join("documents", paymentRef, suffix))
|
||||||
}
|
}
|
||||||
|
|
||||||
func documentFilename(docType documentsv1.DocumentType, paymentRef string) string {
|
func documentFilename(docType documentsv1.DocumentType, paymentRef string) string {
|
||||||
name := "document"
|
name := "document"
|
||||||
|
|
||||||
switch docType {
|
switch docType {
|
||||||
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||||
name = "act"
|
name = "act"
|
||||||
@@ -576,9 +413,6 @@ func documentFilename(docType documentsv1.DocumentType, paymentRef string) strin
|
|||||||
name = "invoice"
|
name = "invoice"
|
||||||
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||||
name = "receipt"
|
name = "receipt"
|
||||||
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
|
||||||
// default name used
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
|
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,13 @@ import (
|
|||||||
"github.com/tech/sendico/billing/documents/storage/model"
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type stubRepo struct {
|
type stubRepo struct {
|
||||||
store storage.DocumentsStore
|
store storage.DocumentsStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubRepo) Ping(_ context.Context) error { return nil }
|
func (s *stubRepo) Ping(ctx context.Context) error { return nil }
|
||||||
func (s *stubRepo) Documents() storage.DocumentsStore { return s.store }
|
func (s *stubRepo) Documents() storage.DocumentsStore { return s.store }
|
||||||
|
|
||||||
var _ storage.Repository = (*stubRepo)(nil)
|
var _ storage.Repository = (*stubRepo)(nil)
|
||||||
@@ -30,24 +28,22 @@ type stubDocumentsStore struct {
|
|||||||
updateCalls int
|
updateCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubDocumentsStore) Create(_ context.Context, record *model.DocumentRecord) error {
|
func (s *stubDocumentsStore) Create(ctx context.Context, record *model.DocumentRecord) error {
|
||||||
s.record = record
|
s.record = record
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubDocumentsStore) Update(_ context.Context, record *model.DocumentRecord) error {
|
func (s *stubDocumentsStore) Update(ctx context.Context, record *model.DocumentRecord) error {
|
||||||
s.record = record
|
s.record = record
|
||||||
s.updateCalls++
|
s.updateCalls++
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubDocumentsStore) GetByPaymentRef(_ context.Context, _ string) (*model.DocumentRecord, error) {
|
func (s *stubDocumentsStore) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error) {
|
||||||
return s.record, nil
|
return s.record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubDocumentsStore) ListByPaymentRefs(_ context.Context, _ []string) ([]*model.DocumentRecord, error) {
|
func (s *stubDocumentsStore) ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error) {
|
||||||
return []*model.DocumentRecord{s.record}, nil
|
return []*model.DocumentRecord{s.record}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,21 +59,19 @@ func newMemDocStore() *memDocStore {
|
|||||||
return &memDocStore{data: map[string][]byte{}}
|
return &memDocStore{data: map[string][]byte{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *memDocStore) Save(_ context.Context, key string, data []byte) error {
|
func (m *memDocStore) Save(ctx context.Context, key string, data []byte) error {
|
||||||
m.saveCount++
|
m.saveCount++
|
||||||
copyData := make([]byte, len(data))
|
copyData := make([]byte, len(data))
|
||||||
copy(copyData, data)
|
copy(copyData, data)
|
||||||
m.data[key] = copyData
|
m.data[key] = copyData
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *memDocStore) Load(_ context.Context, key string) ([]byte, error) {
|
func (m *memDocStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||||
m.loadCount++
|
m.loadCount++
|
||||||
data := m.data[key]
|
data := m.data[key]
|
||||||
copyData := make([]byte, len(data))
|
copyData := make([]byte, len(data))
|
||||||
copy(copyData, data)
|
copy(copyData, data)
|
||||||
|
|
||||||
return copyData, nil
|
return copyData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +84,14 @@ type stubTemplate struct {
|
|||||||
calls int
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
|
func (s *stubTemplate) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||||
s.calls++
|
s.calls++
|
||||||
|
|
||||||
return s.blocks, nil
|
return s.blocks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
snapshot := model.ActSnapshot{
|
snapshot := model.ActSnapshot{
|
||||||
PaymentID: "PAY-123",
|
PaymentID: "PAY-123",
|
||||||
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
|
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
|
||||||
@@ -105,6 +100,14 @@ func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
|||||||
Currency: "USD",
|
Currency: "USD",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record := &model.DocumentRecord{
|
||||||
|
PaymentRef: "PAY-123",
|
||||||
|
Snapshot: snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
documentsStore := &stubDocumentsStore{record: record}
|
||||||
|
repo := &stubRepo{store: documentsStore}
|
||||||
|
store := newMemDocStore()
|
||||||
tmpl := &stubTemplate{
|
tmpl := &stubTemplate{
|
||||||
blocks: []renderer.Block{
|
blocks: []renderer.Block{
|
||||||
{Tag: renderer.TagTitle, Lines: []string{"ACT"}},
|
{Tag: renderer.TagTitle, Lines: []string{"ACT"}},
|
||||||
@@ -119,118 +122,74 @@ func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewService(zap.NewNop(), nil, nil,
|
svc := NewService(zap.NewNop(), repo, nil,
|
||||||
WithConfig(cfg),
|
WithConfig(cfg),
|
||||||
|
WithDocumentStore(store),
|
||||||
WithTemplateRenderer(tmpl),
|
WithTemplateRenderer(tmpl),
|
||||||
)
|
)
|
||||||
|
|
||||||
pdf1, hash1, err := svc.generateActPDF(snapshot)
|
resp1, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
|
||||||
|
PaymentRef: "PAY-123",
|
||||||
|
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generateActPDF first call: %v", err)
|
t.Fatalf("GetDocument first call: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(resp1.Content) == 0 {
|
||||||
if len(pdf1) == 0 {
|
|
||||||
t.Fatalf("expected content on first call")
|
t.Fatalf("expected content on first call")
|
||||||
}
|
}
|
||||||
|
|
||||||
if hash1 == "" {
|
stored := record.Hashes[model.DocumentTypeAct]
|
||||||
t.Fatalf("expected non-empty hash on first call")
|
if stored == "" {
|
||||||
|
t.Fatalf("expected stored hash")
|
||||||
}
|
}
|
||||||
|
footerHash := extractFooterHash(resp1.Content)
|
||||||
footerHash := extractFooterHash(pdf1)
|
|
||||||
|
|
||||||
if footerHash == "" {
|
if footerHash == "" {
|
||||||
t.Fatalf("expected footer hash in PDF")
|
t.Fatalf("expected footer hash in PDF")
|
||||||
}
|
}
|
||||||
|
if stored != footerHash {
|
||||||
if hash1 != footerHash {
|
t.Fatalf("stored hash mismatch: got %s", stored)
|
||||||
t.Fatalf("stored hash mismatch: got %s", hash1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf2, hash2, err := svc.generateActPDF(snapshot)
|
resp2, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
|
||||||
|
PaymentRef: "PAY-123",
|
||||||
|
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generateActPDF second call: %v", err)
|
t.Fatalf("GetDocument second call: %v", err)
|
||||||
}
|
}
|
||||||
if hash2 == "" {
|
if !bytes.Equal(resp1.Content, resp2.Content) {
|
||||||
t.Fatalf("expected non-empty hash on second call")
|
t.Fatalf("expected identical PDF bytes on second call")
|
||||||
}
|
}
|
||||||
footerHash2 := extractFooterHash(pdf2)
|
|
||||||
if footerHash2 == "" {
|
if tmpl.calls != 1 {
|
||||||
t.Fatalf("expected footer hash in second PDF")
|
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
|
||||||
}
|
}
|
||||||
if footerHash2 != hash2 {
|
if store.saveCount != 1 {
|
||||||
t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2)
|
t.Fatalf("expected document save once, got %d", store.saveCount)
|
||||||
|
}
|
||||||
|
if store.loadCount == 0 {
|
||||||
|
t.Fatalf("expected document load on second call")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractFooterHash(pdf []byte) string {
|
func extractFooterHash(pdf []byte) string {
|
||||||
prefix := []byte("Document integrity hash: ")
|
prefix := []byte("Document integrity hash: ")
|
||||||
idx := bytes.Index(pdf, prefix)
|
idx := bytes.Index(pdf, prefix)
|
||||||
|
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
start := idx + len(prefix)
|
start := idx + len(prefix)
|
||||||
|
|
||||||
end := start
|
end := start
|
||||||
|
|
||||||
for end < len(pdf) && isHexDigit(pdf[end]) {
|
for end < len(pdf) && isHexDigit(pdf[end]) {
|
||||||
end++
|
end++
|
||||||
}
|
}
|
||||||
|
|
||||||
if end-start != 64 {
|
if end-start != 64 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(pdf[start:end])
|
return string(pdf[start:end])
|
||||||
}
|
}
|
||||||
|
|
||||||
func isHexDigit(b byte) bool {
|
func isHexDigit(b byte) bool {
|
||||||
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')
|
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
|
|
||||||
svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{
|
|
||||||
Issuer: renderer.Issuer{
|
|
||||||
LegalName: "Sendico Ltd",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
|
||||||
OrganizationRef: "org-1",
|
|
||||||
GatewayService: "chain_gateway",
|
|
||||||
OperationRef: "pay-1:step-1",
|
|
||||||
PaymentRef: "pay-1",
|
|
||||||
OperationCode: "crypto.transfer",
|
|
||||||
OperationLabel: "Outbound transfer",
|
|
||||||
OperationState: "completed",
|
|
||||||
Amount: "100.50",
|
|
||||||
Currency: "USDT",
|
|
||||||
StartedAtUnixMs: time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC).UnixMilli(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetOperationDocument failed: %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.GetContent()) == 0 {
|
|
||||||
t.Fatalf("expected non-empty PDF content")
|
|
||||||
}
|
|
||||||
if got, want := resp.GetMimeType(), "application/pdf"; got != want {
|
|
||||||
t.Fatalf("mime_type mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := resp.GetFilename(), "operation_pay-1_step-1.pdf"; got != want {
|
|
||||||
t.Fatalf("filename mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetOperationDocument_RequiresOperationRef(t *testing.T) {
|
|
||||||
svc := NewService(zap.NewNop(), nil, nil)
|
|
||||||
|
|
||||||
_, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
|
||||||
OrganizationRef: "org-1",
|
|
||||||
GatewayService: "chain_gateway",
|
|
||||||
})
|
|
||||||
if status.Code(err) != codes.InvalidArgument {
|
|
||||||
t.Fatalf("expected InvalidArgument, got=%v err=%v", status.Code(err), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ func (r *templateRenderer) Render(snapshot model.ActSnapshot) ([]renderer.Block,
|
|||||||
if err := r.tpl.Execute(&buf, snapshot); err != nil {
|
if err := r.tpl.Execute(&buf, snapshot); err != nil {
|
||||||
return nil, fmt.Errorf("execute template: %w", err)
|
return nil, fmt.Errorf("execute template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderer.ParseBlocks(buf.String())
|
return renderer.ParseBlocks(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ func formatMoney(amount decimal.Decimal, currency string) string {
|
|||||||
if currency == "" {
|
if currency == "" {
|
||||||
return amount.String()
|
return amount.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s %s", amount.String(), currency)
|
return fmt.Sprintf("%s %s", amount.String(), currency)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +56,5 @@ func formatDate(t time.Time) string {
|
|||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.Format("2006-01-02")
|
return t.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package documents
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,7 +12,6 @@ import (
|
|||||||
|
|
||||||
func TestTemplateRenderer_Render(t *testing.T) {
|
func TestTemplateRenderer_Render(t *testing.T) {
|
||||||
path := filepath.Join("..", "..", "..", "templates", "acceptance.tpl")
|
path := filepath.Join("..", "..", "..", "templates", "acceptance.tpl")
|
||||||
|
|
||||||
tmpl, err := newTemplateRenderer(path)
|
tmpl, err := newTemplateRenderer(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("newTemplateRenderer: %v", err)
|
t.Fatalf("newTemplateRenderer: %v", err)
|
||||||
@@ -31,18 +29,22 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Render: %v", err)
|
t.Fatalf("Render: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(blocks) == 0 {
|
if len(blocks) == 0 {
|
||||||
t.Fatalf("expected blocks, got none")
|
t.Fatalf("expected blocks, got none")
|
||||||
}
|
}
|
||||||
|
|
||||||
title := findBlock(blocks, renderer.TagTitle)
|
title := findBlock(blocks, renderer.TagTitle)
|
||||||
|
|
||||||
if title == nil {
|
if title == nil {
|
||||||
t.Fatalf("expected title block")
|
t.Fatalf("expected title block")
|
||||||
}
|
}
|
||||||
|
foundTitle := false
|
||||||
if !slices.Contains(title.Lines, "ACT OF ACCEPTANCE OF SERVICES") {
|
for _, line := range title.Lines {
|
||||||
|
if line == "ACT OF ACCEPTANCE OF SERVICES" {
|
||||||
|
foundTitle = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundTitle {
|
||||||
t.Fatalf("expected title content not found")
|
t.Fatalf("expected title content not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,17 +52,13 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
|||||||
if kv == nil {
|
if kv == nil {
|
||||||
t.Fatalf("expected kv block")
|
t.Fatalf("expected kv block")
|
||||||
}
|
}
|
||||||
|
|
||||||
foundExecutor := false
|
foundExecutor := false
|
||||||
|
|
||||||
for _, row := range kv.Rows {
|
for _, row := range kv.Rows {
|
||||||
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
|
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
|
||||||
foundExecutor = true
|
foundExecutor = true
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !foundExecutor {
|
if !foundExecutor {
|
||||||
t.Fatalf("expected executor name in kv block")
|
t.Fatalf("expected executor name in kv block")
|
||||||
}
|
}
|
||||||
@@ -69,17 +67,13 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
|||||||
if table == nil {
|
if table == nil {
|
||||||
t.Fatalf("expected table block")
|
t.Fatalf("expected table block")
|
||||||
}
|
}
|
||||||
|
|
||||||
foundAmount := false
|
foundAmount := false
|
||||||
|
|
||||||
for _, row := range table.Rows {
|
for _, row := range table.Rows {
|
||||||
if len(row) >= 2 && row[1] == "123.45 USD" {
|
if len(row) >= 2 && row[1] == "123.45 USD" {
|
||||||
foundAmount = true
|
foundAmount = true
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !foundAmount {
|
if !foundAmount {
|
||||||
t.Fatalf("expected amount in table block")
|
t.Fatalf("expected amount in table block")
|
||||||
}
|
}
|
||||||
@@ -91,6 +85,5 @@ func findBlock(blocks []renderer.Block, tag renderer.Tag) *renderer.Block {
|
|||||||
return &blocks[i]
|
return &blocks[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64)
|
|||||||
if logoWidth > 0 {
|
if logoWidth > 0 {
|
||||||
textX = startX + logoWidth + 6
|
textX = startX + logoWidth + 6
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.SetXY(textX, startY)
|
pdf.SetXY(textX, startY)
|
||||||
pdf.SetFont("Helvetica", "B", 12)
|
pdf.SetFont("Helvetica", "B", 12)
|
||||||
pdf.CellFormat(0, 5, issuer.LegalName, "", 1, "L", false, 0, "")
|
pdf.CellFormat(0, 5, issuer.LegalName, "", 1, "L", false, 0, "")
|
||||||
@@ -40,7 +39,6 @@ func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64)
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentY := pdf.GetY()
|
currentY := pdf.GetY()
|
||||||
|
|
||||||
if logoWidth > 0 {
|
if logoWidth > 0 {
|
||||||
logoBottom := startY + logoWidth
|
logoBottom := startY + logoWidth
|
||||||
if logoBottom > currentY {
|
if logoBottom > currentY {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package renderer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -38,22 +39,18 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
|||||||
pdf.SetFooterFunc(func() {
|
pdf.SetFooterFunc(func() {
|
||||||
pdf.SetY(-15)
|
pdf.SetY(-15)
|
||||||
pdf.SetFont("Helvetica", "", 8)
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
|
footer := fmt.Sprintf("Document integrity hash: %s", footerHash)
|
||||||
footer := "Document integrity hash: " + footerHash
|
|
||||||
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
|
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
|
||||||
})
|
})
|
||||||
|
|
||||||
pdf.AddPage()
|
pdf.AddPage()
|
||||||
|
|
||||||
if _, err := drawHeader(pdf, r.Issuer, pageMarginLeft, pageMarginTop); err != nil {
|
if _, err := drawHeader(pdf, r.Issuer, pageMarginLeft, pageMarginTop); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.Ln(6)
|
pdf.Ln(6)
|
||||||
|
|
||||||
for _, block := range blocks {
|
for _, block := range blocks {
|
||||||
renderBlock(pdf, block)
|
renderBlock(pdf, block)
|
||||||
|
|
||||||
if pdf.Error() != nil {
|
if pdf.Error() != nil {
|
||||||
return nil, pdf.Error()
|
return nil, pdf.Error()
|
||||||
}
|
}
|
||||||
@@ -63,7 +60,6 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
|||||||
if err := pdf.Output(buf); err != nil {
|
if err := pdf.Output(buf); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,64 +69,47 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
pdf.Ln(6)
|
pdf.Ln(6)
|
||||||
case TagTitle:
|
case TagTitle:
|
||||||
pdf.SetFont("Helvetica", "B", 14)
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
|
|
||||||
for _, line := range block.Lines {
|
for _, line := range block.Lines {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
pdf.Ln(4)
|
pdf.Ln(4)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.CellFormat(0, 7, line, "", 1, "C", false, 0, "")
|
pdf.CellFormat(0, 7, line, "", 1, "C", false, 0, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.Ln(2)
|
pdf.Ln(2)
|
||||||
case TagSubtitle:
|
case TagSubtitle:
|
||||||
pdf.SetFont("Helvetica", "", 11)
|
pdf.SetFont("Helvetica", "", 11)
|
||||||
|
|
||||||
for _, line := range block.Lines {
|
for _, line := range block.Lines {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
pdf.Ln(3)
|
pdf.Ln(3)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.CellFormat(0, 6, line, "", 1, "C", false, 0, "")
|
pdf.CellFormat(0, 6, line, "", 1, "C", false, 0, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.Ln(2)
|
pdf.Ln(2)
|
||||||
case TagMeta:
|
case TagMeta:
|
||||||
pdf.SetFont("Helvetica", "", 9)
|
pdf.SetFont("Helvetica", "", 9)
|
||||||
|
|
||||||
for _, line := range block.Lines {
|
for _, line := range block.Lines {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
pdf.Ln(2)
|
pdf.Ln(2)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.CellFormat(0, 4.5, line, "", 1, "R", false, 0, "")
|
pdf.CellFormat(0, 4.5, line, "", 1, "R", false, 0, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.Ln(2)
|
pdf.Ln(2)
|
||||||
case TagSection:
|
case TagSection:
|
||||||
pdf.Ln(2)
|
pdf.Ln(2)
|
||||||
pdf.SetFont("Helvetica", "B", 11)
|
pdf.SetFont("Helvetica", "B", 11)
|
||||||
|
|
||||||
for _, line := range block.Lines {
|
for _, line := range block.Lines {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
pdf.Ln(3)
|
pdf.Ln(3)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.CellFormat(0, 6, line, "", 1, "L", false, 0, "")
|
pdf.CellFormat(0, 6, line, "", 1, "L", false, 0, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.Ln(1)
|
pdf.Ln(1)
|
||||||
case TagText:
|
case TagText:
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
|
||||||
text := strings.Join(block.Lines, "\n")
|
text := strings.Join(block.Lines, "\n")
|
||||||
pdf.MultiCell(0, 5, text, "", "L", false)
|
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||||
pdf.Ln(1)
|
pdf.Ln(1)
|
||||||
@@ -140,14 +119,12 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
renderTable(pdf, block)
|
renderTable(pdf, block)
|
||||||
case TagSign:
|
case TagSign:
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
|
||||||
text := strings.Join(block.Lines, "\n")
|
text := strings.Join(block.Lines, "\n")
|
||||||
pdf.MultiCell(0, 6, text, "", "L", false)
|
pdf.MultiCell(0, 6, text, "", "L", false)
|
||||||
pdf.Ln(2)
|
pdf.Ln(2)
|
||||||
default:
|
default:
|
||||||
// Unknown tag: treat as plain text for resilience.
|
// Unknown tag: treat as plain text for resilience.
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
|
||||||
text := strings.Join(block.Lines, "\n")
|
text := strings.Join(block.Lines, "\n")
|
||||||
pdf.MultiCell(0, 5, text, "", "L", false)
|
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||||
pdf.Ln(1)
|
pdf.Ln(1)
|
||||||
@@ -156,7 +133,6 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
|
|
||||||
func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
|
||||||
usable := usableWidth(pdf)
|
usable := usableWidth(pdf)
|
||||||
keyWidth := math.Round(usable * 0.35)
|
keyWidth := math.Round(usable * 0.35)
|
||||||
valueWidth := usable - keyWidth
|
valueWidth := usable - keyWidth
|
||||||
@@ -166,14 +142,11 @@ func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
if len(row) == 0 {
|
if len(row) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
key := row[0]
|
key := row[0]
|
||||||
|
|
||||||
value := ""
|
value := ""
|
||||||
if len(row) > 1 {
|
if len(row) > 1 {
|
||||||
value = row[1]
|
value = row[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
x := pdf.GetX()
|
x := pdf.GetX()
|
||||||
y := pdf.GetY()
|
y := pdf.GetY()
|
||||||
|
|
||||||
@@ -189,7 +162,6 @@ func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
|
|
||||||
pdf.SetY(maxFloat(leftY, rightY))
|
pdf.SetY(maxFloat(leftY, rightY))
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.Ln(1)
|
pdf.Ln(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +169,6 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
if len(block.Rows) == 0 {
|
if len(block.Rows) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
usable := usableWidth(pdf)
|
usable := usableWidth(pdf)
|
||||||
col1 := math.Round(usable * 0.7)
|
col1 := math.Round(usable * 0.7)
|
||||||
col2 := usable - col1
|
col2 := usable - col1
|
||||||
@@ -205,11 +176,9 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
|
|
||||||
header := block.Rows[0]
|
header := block.Rows[0]
|
||||||
pdf.SetFont("Helvetica", "B", 10)
|
pdf.SetFont("Helvetica", "B", 10)
|
||||||
|
|
||||||
if len(header) > 0 {
|
if len(header) > 0 {
|
||||||
pdf.CellFormat(col1, lineHeight, header[0], "1", 0, "L", false, 0, "")
|
pdf.CellFormat(col1, lineHeight, header[0], "1", 0, "L", false, 0, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(header) > 1 {
|
if len(header) > 1 {
|
||||||
pdf.CellFormat(col2, lineHeight, header[1], "1", 1, "R", false, 0, "")
|
pdf.CellFormat(col2, lineHeight, header[1], "1", 1, "R", false, 0, "")
|
||||||
} else {
|
} else {
|
||||||
@@ -217,19 +186,15 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
|
||||||
for _, row := range block.Rows[1:] {
|
for _, row := range block.Rows[1:] {
|
||||||
colA := ""
|
colA := ""
|
||||||
colB := ""
|
colB := ""
|
||||||
|
|
||||||
if len(row) > 0 {
|
if len(row) > 0 {
|
||||||
colA = row[0]
|
colA = row[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(row) > 1 {
|
if len(row) > 1 {
|
||||||
colB = row[1]
|
colB = row[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
x := pdf.GetX()
|
x := pdf.GetX()
|
||||||
y := pdf.GetY()
|
y := pdf.GetY()
|
||||||
pdf.MultiCell(col1, lineHeight, colA, "1", "L", false)
|
pdf.MultiCell(col1, lineHeight, colA, "1", "L", false)
|
||||||
@@ -239,14 +204,12 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
|||||||
rightY := pdf.GetY()
|
rightY := pdf.GetY()
|
||||||
pdf.SetY(maxFloat(leftY, rightY))
|
pdf.SetY(maxFloat(leftY, rightY))
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.Ln(2)
|
pdf.Ln(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func usableWidth(pdf *gofpdf.Fpdf) float64 {
|
func usableWidth(pdf *gofpdf.Fpdf) float64 {
|
||||||
pageW, _ := pdf.GetPageSize()
|
pageW, _ := pdf.GetPageSize()
|
||||||
left, _, right, _ := pdf.GetMargins()
|
left, _, right, _ := pdf.GetMargins()
|
||||||
|
|
||||||
return pageW - left - right
|
return pageW - left - right
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +217,5 @@ func maxFloat(a, b float64) float64 {
|
|||||||
if a > b {
|
if a > b {
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,11 @@ func TestRenderer_RenderContainsText(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Render: %v", err)
|
t.Fatalf("Render: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(pdfBytes) == 0 {
|
if len(pdfBytes) == 0 {
|
||||||
t.Fatalf("expected PDF bytes")
|
t.Fatalf("expected PDF bytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
|
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
|
||||||
|
|
||||||
for _, token := range checks {
|
for _, token := range checks {
|
||||||
if !containsPDFText(pdfBytes, token) {
|
if !containsPDFText(pdfBytes, token) {
|
||||||
t.Fatalf("expected PDF to contain %q", token)
|
t.Fatalf("expected PDF to contain %q", token)
|
||||||
@@ -44,29 +42,22 @@ func containsPDFText(pdfBytes []byte, text string) bool {
|
|||||||
if bytes.Contains(pdfBytes, []byte(text)) {
|
if bytes.Contains(pdfBytes, []byte(text)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
hexText := hex.EncodeToString([]byte(text))
|
hexText := hex.EncodeToString([]byte(text))
|
||||||
|
|
||||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(hexText))) {
|
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(hexText))) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if bytes.Contains(pdfBytes, []byte(strings.ToLower(hexText))) {
|
if bytes.Contains(pdfBytes, []byte(strings.ToLower(hexText))) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
utf16Bytes := encodeUTF16BE(text, false)
|
utf16Bytes := encodeUTF16BE(text, false)
|
||||||
|
|
||||||
if bytes.Contains(pdfBytes, utf16Bytes) {
|
if bytes.Contains(pdfBytes, utf16Bytes) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
utf16Hex := hex.EncodeToString(utf16Bytes)
|
utf16Hex := hex.EncodeToString(utf16Bytes)
|
||||||
|
|
||||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16Hex))) {
|
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16Hex))) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16Hex))) {
|
if bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16Hex))) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -75,33 +66,25 @@ func containsPDFText(pdfBytes []byte, text string) bool {
|
|||||||
if bytes.Contains(pdfBytes, utf16BytesBOM) {
|
if bytes.Contains(pdfBytes, utf16BytesBOM) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
utf16HexBOM := hex.EncodeToString(utf16BytesBOM)
|
utf16HexBOM := hex.EncodeToString(utf16BytesBOM)
|
||||||
|
|
||||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16HexBOM))) {
|
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16HexBOM))) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16HexBOM)))
|
return bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16HexBOM)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeUTF16BE(text string, withBOM bool) []byte {
|
func encodeUTF16BE(text string, withBOM bool) []byte {
|
||||||
encoded := utf16.Encode([]rune(text))
|
encoded := utf16.Encode([]rune(text))
|
||||||
length := len(encoded) * 2
|
length := len(encoded) * 2
|
||||||
|
|
||||||
if withBOM {
|
if withBOM {
|
||||||
length += 2
|
length += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
out := make([]byte, 0, length)
|
out := make([]byte, 0, length)
|
||||||
|
|
||||||
if withBOM {
|
if withBOM {
|
||||||
out = append(out, 0xFE, 0xFF)
|
out = append(out, 0xFE, 0xFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range encoded {
|
for _, v := range encoded {
|
||||||
out = append(out, byte(v>>8), byte(v))
|
out = append(out, byte(v>>8), byte(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ type Block struct {
|
|||||||
func ParseBlocks(input string) ([]Block, error) {
|
func ParseBlocks(input string) ([]Block, error) {
|
||||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||||
blocks := make([]Block, 0)
|
blocks := make([]Block, 0)
|
||||||
|
|
||||||
var current *Block
|
var current *Block
|
||||||
|
|
||||||
flush := func() {
|
flush := func() {
|
||||||
@@ -45,24 +44,17 @@ func ParseBlocks(input string) ([]Block, error) {
|
|||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimRight(scanner.Text(), "\r")
|
line := strings.TrimRight(scanner.Text(), "\r")
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
if strings.HasPrefix(trimmed, "#") {
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
flush()
|
flush()
|
||||||
|
|
||||||
tag := Tag(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
tag := Tag(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
||||||
|
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if tag == TagSpacer {
|
if tag == TagSpacer {
|
||||||
blocks = append(blocks, Block{Tag: TagSpacer})
|
blocks = append(blocks, Block{Tag: TagSpacer})
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
current = &Block{Tag: tag}
|
current = &Block{Tag: tag}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,19 +62,16 @@ func ParseBlocks(input string) ([]Block, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
switch current.Tag { //nolint:exhaustive // only KV and Table need row parsing
|
switch current.Tag {
|
||||||
case TagKV, TagTable:
|
case TagKV, TagTable:
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(line, "|")
|
parts := strings.Split(line, "|")
|
||||||
|
|
||||||
row := make([]string, 0, len(parts))
|
row := make([]string, 0, len(parts))
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
row = append(row, strings.TrimSpace(part))
|
row = append(row, strings.TrimSpace(part))
|
||||||
}
|
}
|
||||||
|
|
||||||
current.Rows = append(current.Rows, row)
|
current.Rows = append(current.Rows, row)
|
||||||
default:
|
default:
|
||||||
current.Lines = append(current.Lines, line)
|
current.Lines = append(current.Lines, line)
|
||||||
@@ -94,6 +83,5 @@ func ParseBlocks(input string) ([]Block, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flush()
|
flush()
|
||||||
|
|
||||||
return blocks, nil
|
return blocks, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ func DocumentTypeFromProto(t documentsv1.DocumentType) DocumentType {
|
|||||||
if name, ok := documentsv1.DocumentType_name[int32(t)]; ok {
|
if name, ok := documentsv1.DocumentType_name[int32(t)]; ok {
|
||||||
return DocumentType(name)
|
return DocumentType(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return DocumentTypeUnspecified
|
return DocumentTypeUnspecified
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,24 +36,22 @@ func (t DocumentType) Proto() documentsv1.DocumentType {
|
|||||||
if value, ok := documentsv1.DocumentType_value[string(t)]; ok {
|
if value, ok := documentsv1.DocumentType_value[string(t)]; ok {
|
||||||
return documentsv1.DocumentType(value)
|
return documentsv1.DocumentType(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
return documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActSnapshot captures the immutable data needed to generate an acceptance act.
|
// ActSnapshot captures the immutable data needed to generate an acceptance act.
|
||||||
type ActSnapshot struct {
|
type ActSnapshot struct {
|
||||||
PaymentID string `bson:"paymentId" json:"paymentId"`
|
PaymentID string `bson:"paymentId" json:"paymentId"`
|
||||||
Date time.Time `bson:"date" json:"date"`
|
Date time.Time `bson:"date" json:"date"`
|
||||||
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
|
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
|
||||||
Amount decimal.Decimal `bson:"amount" json:"amount"`
|
Amount decimal.Decimal `bson:"amount" json:"amount"`
|
||||||
Currency string `bson:"currency" json:"currency"`
|
Currency string `bson:"currency" json:"currency"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ActSnapshot) Normalize() {
|
func (s *ActSnapshot) Normalize() {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.PaymentID = strings.TrimSpace(s.PaymentID)
|
s.PaymentID = strings.TrimSpace(s.PaymentID)
|
||||||
s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName)
|
s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName)
|
||||||
s.Currency = strings.TrimSpace(s.Currency)
|
s.Currency = strings.TrimSpace(s.Currency)
|
||||||
@@ -63,25 +60,21 @@ func (s *ActSnapshot) Normalize() {
|
|||||||
// DocumentRecord stores document metadata and cached artefacts for a payment.
|
// DocumentRecord stores document metadata and cached artefacts for a payment.
|
||||||
type DocumentRecord struct {
|
type DocumentRecord struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
||||||
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
|
||||||
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
|
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
|
||||||
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
|
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
|
||||||
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *DocumentRecord) Normalize() {
|
func (r *DocumentRecord) Normalize() {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
r.PaymentRef = strings.TrimSpace(r.PaymentRef)
|
r.PaymentRef = strings.TrimSpace(r.PaymentRef)
|
||||||
r.Snapshot.Normalize()
|
r.Snapshot.Normalize()
|
||||||
|
|
||||||
if r.StoragePaths == nil {
|
if r.StoragePaths == nil {
|
||||||
r.StoragePaths = map[DocumentType]string{}
|
r.StoragePaths = map[DocumentType]string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Hashes == nil {
|
if r.Hashes == nil {
|
||||||
r.Hashes = map[DocumentType]string{}
|
r.Hashes = map[DocumentType]string{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,22 +42,18 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := result.Ping(ctx); err != nil {
|
if err := result.Ping(ctx); err != nil {
|
||||||
result.logger.Error("Mongo ping failed during store init", zap.Error(err))
|
result.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
documentsStore, err := store.NewDocuments(result.logger, database)
|
documentsStore, err := store.NewDocuments(result.logger, database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.logger.Error("Failed to initialise documents store", zap.Error(err))
|
result.logger.Error("failed to initialise documents store", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result.documents = documentsStore
|
result.documents = documentsStore
|
||||||
|
|
||||||
result.logger.Info("Billing documents MongoDB storage initialised")
|
result.logger.Info("Billing documents MongoDB storage initialised")
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,14 +38,13 @@ func NewDocuments(logger mlogger.Logger, db *mongo.Database) (*Documents, error)
|
|||||||
|
|
||||||
for _, def := range indexes {
|
for _, def := range indexes {
|
||||||
if err := repo.CreateIndex(def); err != nil {
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
logger.Error("Failed to ensure documents index", zap.Error(err), zap.String("collection", repo.Collection()))
|
logger.Error("failed to ensure documents index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
childLogger := logger.Named("documents")
|
childLogger := logger.Named("documents")
|
||||||
childLogger.Debug("Documents store initialised")
|
childLogger.Debug("documents store initialised")
|
||||||
|
|
||||||
return &Documents{
|
return &Documents{
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
@@ -57,9 +56,7 @@ func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) er
|
|||||||
if record == nil {
|
if record == nil {
|
||||||
return merrors.InvalidArgument("documentsStore: nil record")
|
return merrors.InvalidArgument("documentsStore: nil record")
|
||||||
}
|
}
|
||||||
|
|
||||||
record.Normalize()
|
record.Normalize()
|
||||||
|
|
||||||
if record.PaymentRef == "" {
|
if record.PaymentRef == "" {
|
||||||
return merrors.InvalidArgument("documentsStore: empty paymentRef")
|
return merrors.InvalidArgument("documentsStore: empty paymentRef")
|
||||||
}
|
}
|
||||||
@@ -69,12 +66,9 @@ func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) er
|
|||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
return storage.ErrDuplicateDocument
|
return storage.ErrDuplicateDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
d.logger.Debug("document record created", zap.String("payment_ref", record.PaymentRef))
|
||||||
d.logger.Debug("Document record created", zap.String("payment_ref", record.PaymentRef))
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,21 +76,17 @@ func (d *Documents) Update(ctx context.Context, record *model.DocumentRecord) er
|
|||||||
if record == nil {
|
if record == nil {
|
||||||
return merrors.InvalidArgument("documentsStore: nil record")
|
return merrors.InvalidArgument("documentsStore: nil record")
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.ID.IsZero() {
|
if record.ID.IsZero() {
|
||||||
return merrors.InvalidArgument("documentsStore: missing record id")
|
return merrors.InvalidArgument("documentsStore: missing record id")
|
||||||
}
|
}
|
||||||
|
|
||||||
record.Normalize()
|
record.Normalize()
|
||||||
record.Update()
|
record.Update()
|
||||||
if err := d.repo.Update(ctx, record); err != nil {
|
if err := d.repo.Update(ctx, record); err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
return storage.ErrDocumentNotFound
|
return storage.ErrDocumentNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,10 +101,8 @@ func (d *Documents) GetByPaymentRef(ctx context.Context, paymentRef string) (*mo
|
|||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
return nil, storage.ErrDocumentNotFound
|
return nil, storage.ErrDocumentNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity, nil
|
return entity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,34 +113,26 @@ func (d *Documents) ListByPaymentRefs(ctx context.Context, paymentRefs []string)
|
|||||||
if clean == "" {
|
if clean == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
refs = append(refs, clean)
|
refs = append(refs, clean)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(refs) == 0 {
|
if len(refs) == 0 {
|
||||||
return []*model.DocumentRecord{}, nil
|
return []*model.DocumentRecord{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
query := repository.Query().Comparison(repository.Field("paymentRef"), builder.In, refs)
|
query := repository.Query().Comparison(repository.Field("paymentRef"), builder.In, refs)
|
||||||
records := make([]*model.DocumentRecord, 0)
|
records := make([]*model.DocumentRecord, 0)
|
||||||
|
|
||||||
decoder := func(cur *mongo.Cursor) error {
|
decoder := func(cur *mongo.Cursor) error {
|
||||||
var rec model.DocumentRecord
|
var rec model.DocumentRecord
|
||||||
if err := cur.Decode(&rec); err != nil {
|
if err := cur.Decode(&rec); err != nil {
|
||||||
d.logger.Warn("Failed to decode document record", zap.Error(err))
|
d.logger.Warn("failed to decode document record", zap.Error(err))
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
records = append(records, &rec)
|
records = append(records, &rec)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
# See the dedicated "version" documentation section.
|
|
||||||
version: "2"
|
|
||||||
linters:
|
|
||||||
# Default set of linters.
|
|
||||||
# The value can be:
|
|
||||||
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
|
|
||||||
# - `all`: enables all linters by default.
|
|
||||||
# - `none`: disables all linters by default.
|
|
||||||
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
|
|
||||||
# Default: standard
|
|
||||||
default: all
|
|
||||||
# Enable specific linter.
|
|
||||||
enable:
|
|
||||||
- arangolint
|
|
||||||
- asasalint
|
|
||||||
- asciicheck
|
|
||||||
- bidichk
|
|
||||||
- bodyclose
|
|
||||||
- canonicalheader
|
|
||||||
- containedctx
|
|
||||||
- contextcheck
|
|
||||||
- copyloopvar
|
|
||||||
- cyclop
|
|
||||||
- decorder
|
|
||||||
- dogsled
|
|
||||||
- dupl
|
|
||||||
- dupword
|
|
||||||
- durationcheck
|
|
||||||
- embeddedstructfieldcheck
|
|
||||||
- err113
|
|
||||||
- errcheck
|
|
||||||
- errchkjson
|
|
||||||
- errname
|
|
||||||
- errorlint
|
|
||||||
- exhaustive
|
|
||||||
- exptostd
|
|
||||||
- fatcontext
|
|
||||||
- forbidigo
|
|
||||||
- forcetypeassert
|
|
||||||
- funcorder
|
|
||||||
- funlen
|
|
||||||
- ginkgolinter
|
|
||||||
- gocheckcompilerdirectives
|
|
||||||
- gochecknoglobals
|
|
||||||
- gochecknoinits
|
|
||||||
- gochecksumtype
|
|
||||||
- gocognit
|
|
||||||
- goconst
|
|
||||||
- gocritic
|
|
||||||
- gocyclo
|
|
||||||
- godoclint
|
|
||||||
- godot
|
|
||||||
- godox
|
|
||||||
- goheader
|
|
||||||
- gomodguard
|
|
||||||
- goprintffuncname
|
|
||||||
- gosec
|
|
||||||
- gosmopolitan
|
|
||||||
- govet
|
|
||||||
- grouper
|
|
||||||
- iface
|
|
||||||
- importas
|
|
||||||
- inamedparam
|
|
||||||
- ineffassign
|
|
||||||
- interfacebloat
|
|
||||||
- intrange
|
|
||||||
- iotamixing
|
|
||||||
- ireturn
|
|
||||||
- lll
|
|
||||||
- loggercheck
|
|
||||||
- maintidx
|
|
||||||
- makezero
|
|
||||||
- mirror
|
|
||||||
- misspell
|
|
||||||
- mnd
|
|
||||||
- modernize
|
|
||||||
- musttag
|
|
||||||
- nakedret
|
|
||||||
- nestif
|
|
||||||
- nilerr
|
|
||||||
- nilnesserr
|
|
||||||
- nilnil
|
|
||||||
- nlreturn
|
|
||||||
- noctx
|
|
||||||
- noinlineerr
|
|
||||||
- nolintlint
|
|
||||||
- nonamedreturns
|
|
||||||
- nosprintfhostport
|
|
||||||
- paralleltest
|
|
||||||
- perfsprint
|
|
||||||
- prealloc
|
|
||||||
- predeclared
|
|
||||||
- promlinter
|
|
||||||
- protogetter
|
|
||||||
- reassign
|
|
||||||
- recvcheck
|
|
||||||
- revive
|
|
||||||
- rowserrcheck
|
|
||||||
- sloglint
|
|
||||||
- spancheck
|
|
||||||
- sqlclosecheck
|
|
||||||
- staticcheck
|
|
||||||
- tagalign
|
|
||||||
- tagliatelle
|
|
||||||
- testableexamples
|
|
||||||
- testifylint
|
|
||||||
- testpackage
|
|
||||||
- thelper
|
|
||||||
- tparallel
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
- unqueryvet
|
|
||||||
- unused
|
|
||||||
- usestdlibvars
|
|
||||||
- usetesting
|
|
||||||
- varnamelen
|
|
||||||
- wastedassign
|
|
||||||
- whitespace
|
|
||||||
- wsl_v5
|
|
||||||
- zerologlint
|
|
||||||
# Disable specific linters.
|
|
||||||
disable:
|
|
||||||
- depguard
|
|
||||||
- exhaustruct
|
|
||||||
- gochecknoglobals
|
|
||||||
- gomoddirectives
|
|
||||||
- noinlineerr
|
|
||||||
- wsl
|
|
||||||
- wrapcheck
|
|
||||||
# All available settings of specific linters.
|
|
||||||
# See the dedicated "linters.settings" documentation section.
|
|
||||||
settings:
|
|
||||||
wsl_v5:
|
|
||||||
allow-first-in-block: true
|
|
||||||
allow-whole-block: false
|
|
||||||
branch-max-lines: 2
|
|
||||||
|
|
||||||
# Defines a set of rules to ignore issues.
|
|
||||||
# It does not skip the analysis, and so does not ignore "typecheck" errors.
|
|
||||||
exclusions:
|
|
||||||
# Mode of the generated files analysis.
|
|
||||||
#
|
|
||||||
# - `strict`: sources are excluded by strictly following the Go generated file convention.
|
|
||||||
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
|
|
||||||
# This line must appear before the first non-comment, non-blank text in the file.
|
|
||||||
# https://go.dev/s/generatedcode
|
|
||||||
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
|
|
||||||
# - `disable`: disable the generated files exclusion.
|
|
||||||
#
|
|
||||||
# Default: strict
|
|
||||||
generated: lax
|
|
||||||
# Log a warning if an exclusion rule is unused.
|
|
||||||
# Default: false
|
|
||||||
warn-unused: true
|
|
||||||
# Predefined exclusion rules.
|
|
||||||
# Default: []
|
|
||||||
presets:
|
|
||||||
- comments
|
|
||||||
- std-error-handling
|
|
||||||
- common-false-positives
|
|
||||||
- legacy
|
|
||||||
# Excluding configuration per-path, per-linter, per-text and per-source.
|
|
||||||
rules:
|
|
||||||
# Exclude some linters from running on tests files.
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- cyclop
|
|
||||||
- funlen
|
|
||||||
- gocyclo
|
|
||||||
- errcheck
|
|
||||||
- dupl
|
|
||||||
- gosec
|
|
||||||
# Run some linter only for test files by excluding its issues for everything else.
|
|
||||||
- path-except: _test\.go
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
# Exclude known linters from partially hard-vendored code,
|
|
||||||
# which is impossible to exclude via `nolint` comments.
|
|
||||||
# `/` will be replaced by the current OS file path separator to properly work on Windows.
|
|
||||||
- path: internal/hmac/
|
|
||||||
text: "weak cryptographic primitive"
|
|
||||||
linters:
|
|
||||||
- gosec
|
|
||||||
# Exclude some `staticcheck` messages.
|
|
||||||
- linters:
|
|
||||||
- staticcheck
|
|
||||||
text: "SA9003:"
|
|
||||||
# Exclude `lll` issues for long lines with `go:generate`.
|
|
||||||
- linters:
|
|
||||||
- lll
|
|
||||||
source: "^//go:generate "
|
|
||||||
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
|
|
||||||
# "/" will be replaced by the current OS file path separator to properly work on Windows.
|
|
||||||
# Default: []
|
|
||||||
paths: []
|
|
||||||
# Which file paths to not exclude.
|
|
||||||
# Default: []
|
|
||||||
paths-except: []
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/tech/sendico/billing/fees
|
module github.com/tech/sendico/billing/fees
|
||||||
|
|
||||||
go 1.25.7
|
go 1.25.6
|
||||||
|
|
||||||
replace github.com/tech/sendico/pkg => ../../pkg
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ require (
|
|||||||
github.com/tech/sendico/fx/oracle v0.0.0
|
github.com/tech/sendico/fx/oracle 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
|
||||||
google.golang.org/grpc v1.79.1
|
google.golang.org/grpc v1.78.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,31 +25,31 @@ require (
|
|||||||
github.com/casbin/casbin/v2 v2.135.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/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
github.com/go-chi/chi/v5 v5.2.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.3 // 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
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
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
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ 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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -57,8 +57,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.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.3/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=
|
||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
|||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
@@ -152,16 +152,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
|||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
@@ -172,15 +172,15 @@ 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
@@ -191,16 +191,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
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=
|
||||||
@@ -208,10 +208,10 @@ 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-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -24,6 +24,5 @@ func Create() version.Printer {
|
|||||||
BuildDate: BuildDate,
|
BuildDate: BuildDate,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
return vf.Create(&info)
|
return vf.Create(&info)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ type Imp struct {
|
|||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
*grpcapp.Config `yaml:",inline"`
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
Oracle OracleConfig `yaml:"oracle"`
|
||||||
Oracle OracleConfig `yaml:"oracle"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OracleConfig struct {
|
type OracleConfig struct {
|
||||||
@@ -46,7 +45,6 @@ func (c OracleConfig) dialTimeout() time.Duration {
|
|||||||
if c.DialTimeoutSecs <= 0 {
|
if c.DialTimeoutSecs <= 0 {
|
||||||
return 5 * time.Second
|
return 5 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
return time.Duration(c.DialTimeoutSecs) * time.Second
|
return time.Duration(c.DialTimeoutSecs) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +52,6 @@ func (c OracleConfig) callTimeout() time.Duration {
|
|||||||
if c.CallTimeoutSecs <= 0 {
|
if c.CallTimeoutSecs <= 0 {
|
||||||
return 3 * time.Second
|
return 3 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
return time.Duration(c.CallTimeoutSecs) * time.Second
|
return time.Duration(c.CallTimeoutSecs) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +69,9 @@ func (i *Imp) Shutdown() {
|
|||||||
if i.service != nil {
|
if i.service != nil {
|
||||||
i.service.Shutdown()
|
i.service.Shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
if i.oracleClient != nil {
|
if i.oracleClient != nil {
|
||||||
_ = i.oracleClient.Close()
|
_ = i.oracleClient.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +98,6 @@ func (i *Imp) Start() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.config = cfg
|
i.config = cfg
|
||||||
|
|
||||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
@@ -111,23 +105,22 @@ func (i *Imp) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var oracleClient oracleclient.Client
|
var oracleClient oracleclient.Client
|
||||||
|
|
||||||
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
|
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
|
||||||
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
|
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
oracleConn, err := oracleclient.New(dialCtx, oracleclient.Config{
|
oc, err := oracleclient.New(dialCtx, oracleclient.Config{
|
||||||
Address: addr,
|
Address: addr,
|
||||||
DialTimeout: cfg.Oracle.dialTimeout(),
|
DialTimeout: cfg.Oracle.dialTimeout(),
|
||||||
CallTimeout: cfg.Oracle.callTimeout(),
|
CallTimeout: cfg.Oracle.callTimeout(),
|
||||||
Insecure: cfg.Oracle.InsecureTransport,
|
Insecure: cfg.Oracle.InsecureTransport,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Warn("Failed to initialise oracle client", zap.String("address", addr), zap.Error(err))
|
i.logger.Warn("failed to initialise oracle client", zap.String("address", addr), zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
oracleClient = oracleConn
|
oracleClient = oc
|
||||||
i.oracleClient = oracleConn
|
i.oracleClient = oc
|
||||||
i.logger.Info("Connected to oracle service", zap.String("address", addr))
|
i.logger.Info("connected to oracle service", zap.String("address", addr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,16 +129,13 @@ func (i *Imp) Start() error {
|
|||||||
if oracleClient != nil {
|
if oracleClient != nil {
|
||||||
opts = append(opts, fees.WithOracleClient(oracleClient))
|
opts = append(opts, fees.WithOracleClient(oracleClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.GRPC != nil {
|
if cfg.GRPC != nil {
|
||||||
if invokeURI := cfg.GRPC.DiscoveryInvokeURI(); invokeURI != "" {
|
if invokeURI := cfg.GRPC.DiscoveryInvokeURI(); invokeURI != "" {
|
||||||
opts = append(opts, fees.WithDiscoveryInvokeURI(invokeURI))
|
opts = append(opts, fees.WithDiscoveryInvokeURI(invokeURI))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := fees.NewService(logger, repo, producer, opts...)
|
svc := fees.NewService(logger, repo, producer, opts...)
|
||||||
i.service = svc
|
i.service = svc
|
||||||
|
|
||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +143,6 @@ func (i *Imp) Start() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.app = app
|
i.app = app
|
||||||
|
|
||||||
return i.app.Start()
|
return i.app.Start()
|
||||||
@@ -163,14 +152,12 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
data, err := os.ReadFile(i.file)
|
data, err := os.ReadFile(i.file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &config{Config: &grpcapp.Config{}}
|
cfg := &config{Config: &grpcapp.Config{}}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package calculator
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"maps"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -34,7 +33,6 @@ func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
|
|||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = zap.NewNop()
|
logger = zap.NewNop()
|
||||||
}
|
}
|
||||||
|
|
||||||
return "eCalculator{
|
return "eCalculator{
|
||||||
logger: logger.Named("calculator"),
|
logger: logger.Named("calculator"),
|
||||||
oracle: oracle,
|
oracle: oracle,
|
||||||
@@ -47,10 +45,27 @@ type quoteCalculator struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||||
baseAmount, baseScale, trigger, err := validateComputeInputs(plan, intent)
|
if plan == nil {
|
||||||
if err != nil {
|
return nil, merrors.InvalidArgument("plan is required")
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
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))
|
rules := make([]model.FeeRule, len(plan.Rules))
|
||||||
copy(rules, plan.Rules)
|
copy(rules, plan.Rules)
|
||||||
@@ -58,37 +73,81 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
|
|||||||
if rules[i].Priority == rules[j].Priority {
|
if rules[i].Priority == rules[j].Priority {
|
||||||
return rules[i].RuleID < rules[j].RuleID
|
return rules[i].RuleID < rules[j].RuleID
|
||||||
}
|
}
|
||||||
|
|
||||||
return rules[i].Priority < rules[j].Priority
|
return rules[i].Priority < rules[j].Priority
|
||||||
})
|
})
|
||||||
|
|
||||||
planID := planIDFrom(plan)
|
|
||||||
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
||||||
applied := make([]*feesv1.AppliedRule, 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 {
|
for _, rule := range rules {
|
||||||
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
||||||
|
|
||||||
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||||
if calcErr != nil {
|
if calcErr != nil {
|
||||||
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
c.logger.Warn("Failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if amount.Sign() == 0 {
|
if amount.Sign() == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
currency := resolvedCurrency(intent.GetBaseAmount().GetCurrency(), rule.Currency)
|
currency := intent.GetBaseAmount().GetCurrency()
|
||||||
entrySide := resolvedEntrySide(rule)
|
if override := strings.TrimSpace(rule.Currency); override != "" {
|
||||||
|
currency = override
|
||||||
|
}
|
||||||
|
|
||||||
lines = append(lines, buildPostingLine(rule, amount, scale, currency, entrySide, planID))
|
entrySide := mapEntrySide(rule.EntrySide)
|
||||||
applied = append(applied, buildAppliedRule(rule, planID))
|
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||||
|
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
|
||||||
|
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
var fxUsed *feesv1.FXUsed
|
||||||
@@ -111,24 +170,40 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
|
|||||||
|
|
||||||
result := new(big.Rat)
|
result := new(big.Rat)
|
||||||
|
|
||||||
result, err = applyPercentage(result, baseAmount, rule.Percentage)
|
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
||||||
if err != nil {
|
percentageRat, perr := dmath.RatFromString(percentage)
|
||||||
return nil, 0, err
|
if perr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
||||||
|
}
|
||||||
|
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err = applyFixed(result, rule.FixedAmount)
|
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
||||||
if err != nil {
|
fixedRat, ferr := dmath.RatFromString(fixed)
|
||||||
return nil, 0, err
|
if ferr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
||||||
|
}
|
||||||
|
result = dmath.AddRat(result, fixedRat)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err = applyMin(result, rule.MinimumAmount)
|
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
||||||
if err != nil {
|
minRat, merr := dmath.RatFromString(minStr)
|
||||||
return nil, 0, err
|
if merr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
||||||
|
}
|
||||||
|
if dmath.CmpRat(result, minRat) < 0 {
|
||||||
|
result = new(big.Rat).Set(minRat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err = applyMax(result, rule.MaximumAmount)
|
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
||||||
if err != nil {
|
maxRat, merr := dmath.RatFromString(maxStr)
|
||||||
return nil, 0, err
|
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 {
|
if result.Sign() < 0 {
|
||||||
@@ -143,66 +218,6 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
|
|||||||
return rounded, scale, nil
|
return rounded, scale, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyPercentage(result, baseAmount *big.Rat, percentage string) (*big.Rat, error) {
|
|
||||||
if strings.TrimSpace(percentage) == "" {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
percentageRat, err := dmath.RatFromString(percentage)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid percentage")
|
|
||||||
}
|
|
||||||
|
|
||||||
return dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyFixed(result *big.Rat, fixed string) (*big.Rat, error) {
|
|
||||||
if strings.TrimSpace(fixed) == "" {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fixedRat, err := dmath.RatFromString(fixed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid fixed amount")
|
|
||||||
}
|
|
||||||
|
|
||||||
return dmath.AddRat(result, fixedRat), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyMin(result *big.Rat, minStr string) (*big.Rat, error) {
|
|
||||||
if strings.TrimSpace(minStr) == "" {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
minRat, err := dmath.RatFromString(minStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid minimum amount")
|
|
||||||
}
|
|
||||||
|
|
||||||
if dmath.CmpRat(result, minRat) < 0 {
|
|
||||||
return new(big.Rat).Set(minRat), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyMax(result *big.Rat, maxStr string) (*big.Rat, error) {
|
|
||||||
if strings.TrimSpace(maxStr) == "" {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
maxRat, err := dmath.RatFromString(maxStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid maximum amount")
|
|
||||||
}
|
|
||||||
|
|
||||||
if dmath.CmpRat(result, maxRat) > 0 {
|
|
||||||
return new(big.Rat).Set(maxRat), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
attrFxBaseCurrency = "fx_base_currency"
|
attrFxBaseCurrency = "fx_base_currency"
|
||||||
attrFxQuoteCurrency = "fx_quote_currency"
|
attrFxQuoteCurrency = "fx_quote_currency"
|
||||||
@@ -217,9 +232,7 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
|
|||||||
}
|
}
|
||||||
|
|
||||||
attrs := intent.GetAttributes()
|
attrs := intent.GetAttributes()
|
||||||
|
|
||||||
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||||
|
|
||||||
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||||
if base == "" || quote == "" {
|
if base == "" || quote == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -234,26 +247,20 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
|
|||||||
Provider: provider,
|
Provider: provider,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Warn("Fees: failed to fetch FX context", zap.Error(err))
|
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
||||||
|
|
||||||
if rateValue == "" {
|
if rateValue == "" {
|
||||||
rateValue = snapshot.Mid
|
rateValue = snapshot.Mid
|
||||||
}
|
}
|
||||||
|
|
||||||
if rateValue == "" {
|
if rateValue == "" {
|
||||||
rateValue = snapshot.Ask
|
rateValue = snapshot.Ask
|
||||||
}
|
}
|
||||||
|
|
||||||
if rateValue == "" {
|
if rateValue == "" {
|
||||||
rateValue = snapshot.Bid
|
rateValue = snapshot.Bid
|
||||||
}
|
}
|
||||||
@@ -285,19 +292,15 @@ func inferScale(amount string) uint32 {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
||||||
value = value[:idx]
|
value = value[:idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
||||||
value = value[1:]
|
value = value[1:]
|
||||||
}
|
}
|
||||||
|
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
||||||
if _, after, found := strings.Cut(value, "."); found {
|
return uint32(len(value[dot+1:]))
|
||||||
return uint32(len(after)) //nolint:gosec // decimal scale; cannot overflow
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,15 +308,12 @@ func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[s
|
|||||||
if rule.Trigger != trigger {
|
if rule.Trigger != trigger {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.EffectiveFrom.After(bookedAt) {
|
if rule.EffectiveFrom.After(bookedAt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return ruleMatchesAttributes(rule, attributes)
|
return ruleMatchesAttributes(rule, attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +325,6 @@ func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback, nil
|
return fallback, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,115 +333,17 @@ func parseScale(field, value string) (uint32, error) {
|
|||||||
if clean == "" {
|
if clean == "" {
|
||||||
return 0, merrors.InvalidArgument(field + " is empty")
|
return 0, merrors.InvalidArgument(field + " is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := strconv.ParseUint(clean, 10, 32)
|
parsed, err := strconv.ParseUint(clean, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
||||||
}
|
}
|
||||||
|
|
||||||
return uint32(parsed), nil
|
return uint32(parsed), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateComputeInputs(plan *model.FeePlan, intent *feesv1.Intent) (*big.Rat, uint32, model.Trigger, error) {
|
|
||||||
if plan == nil {
|
|
||||||
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("plan is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if intent == nil {
|
|
||||||
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("intent is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger := convertTrigger(intent.GetTrigger())
|
|
||||||
if trigger == model.TriggerUnspecified {
|
|
||||||
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("unsupported trigger")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, trigger, merrors.InvalidArgument("invalid base amount")
|
|
||||||
}
|
|
||||||
|
|
||||||
if baseAmount.Sign() < 0 {
|
|
||||||
return nil, 0, trigger, merrors.InvalidArgument("base amount cannot be negative")
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseAmount, inferScale(intent.GetBaseAmount().GetAmount()), trigger, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func planIDFrom(plan *model.FeePlan) string {
|
|
||||||
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
|
||||||
return planRef.Hex()
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolvedCurrency(baseCurrency, ruleCurrency string) string {
|
|
||||||
if override := strings.TrimSpace(ruleCurrency); override != "" {
|
|
||||||
return override
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseCurrency
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolvedEntrySide(rule model.FeeRule) accountingv1.EntrySide {
|
|
||||||
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
|
|
||||||
if side := mapEntrySide(rule.EntrySide); side != accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
|
||||||
return side
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPostingLine(rule model.FeeRule, amount *big.Rat, scale uint32, currency string, entrySide accountingv1.EntrySide, planID string) *feesv1.DerivedPostingLine {
|
|
||||||
return &feesv1.DerivedPostingLine{
|
|
||||||
LedgerAccountRef: strings.TrimSpace(rule.LedgerAccountRef),
|
|
||||||
Money: &moneyv1.Money{
|
|
||||||
Amount: dmath.FormatRat(amount, scale),
|
|
||||||
Currency: currency,
|
|
||||||
},
|
|
||||||
LineType: mapLineType(rule.LineType),
|
|
||||||
Side: entrySide,
|
|
||||||
Meta: buildRuleMeta(rule, planID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAppliedRule(rule model.FeeRule, planID string) *feesv1.AppliedRule {
|
|
||||||
return &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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildRuleMeta(rule model.FeeRule, planID string) map[string]string {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
func metadataValue(meta map[string]string, key string) string {
|
func metadataValue(meta map[string]string, key string) string {
|
||||||
if meta == nil {
|
if meta == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(meta[key])
|
return strings.TrimSpace(meta[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,10 +351,10 @@ func cloneStringMap(src map[string]string) map[string]string {
|
|||||||
if len(src) == 0 {
|
if len(src) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cloned := make(map[string]string, len(src))
|
cloned := make(map[string]string, len(src))
|
||||||
maps.Copy(cloned, src)
|
for k, v := range src {
|
||||||
|
cloned[k] = v
|
||||||
|
}
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,22 +362,18 @@ func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) boo
|
|||||||
if len(rule.AppliesTo) == 0 {
|
if len(rule.AppliesTo) == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value := range rule.AppliesTo {
|
for key, value := range rule.AppliesTo {
|
||||||
if attributes == nil {
|
if attributes == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
attrValue, ok := attributes[key]
|
attrValue, ok := attributes[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matchesAttributeValue(value, attrValue) {
|
if !matchesAttributeValue(value, attrValue) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,17 +383,16 @@ func matchesAttributeValue(expected, actual string) bool {
|
|||||||
return actual == ""
|
return actual == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
for value := range strings.SplitSeq(trimmed, ",") {
|
values := strings.Split(trimmed, ",")
|
||||||
|
for _, value := range values {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if value == "*" || value == actual {
|
if value == "*" || value == actual {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,8 +446,6 @@ func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
|||||||
|
|
||||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||||
switch trigger {
|
switch trigger {
|
||||||
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
|
|
||||||
return model.TriggerUnspecified
|
|
||||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||||
return model.TriggerCapture
|
return model.TriggerCapture
|
||||||
case feesv1.Trigger_TRIGGER_REFUND:
|
case feesv1.Trigger_TRIGGER_REFUND:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"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"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -23,19 +22,17 @@ type planFinder interface {
|
|||||||
type feeResolver struct {
|
type feeResolver struct {
|
||||||
plans storage.PlansStore
|
plans storage.PlansStore
|
||||||
finder planFinder
|
finder planFinder
|
||||||
logger mlogger.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(plans storage.PlansStore, logger mlogger.Logger) *feeResolver {
|
func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
||||||
var finder planFinder
|
var finder planFinder
|
||||||
if pf, ok := plans.(planFinder); ok {
|
if pf, ok := plans.(planFinder); ok {
|
||||||
finder = pf
|
finder = pf
|
||||||
}
|
}
|
||||||
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = zap.NewNop()
|
logger = zap.NewNop()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &feeResolver{
|
return &feeResolver{
|
||||||
plans: plans,
|
plans: plans,
|
||||||
finder: finder,
|
finder: finder,
|
||||||
@@ -43,100 +40,63 @@ func New(plans storage.PlansStore, logger mlogger.Logger) *feeResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||||
if r.plans == nil {
|
if r.plans == nil {
|
||||||
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try org-specific first if provided.
|
// Try org-specific first if provided.
|
||||||
if isOrgRef(orgRef) {
|
if orgRef != nil && !orgRef.IsZero() {
|
||||||
plan, rule, err := r.tryOrgRule(ctx, *orgRef, trigger, asOf, attrs)
|
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
|
||||||
if 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), mzap.ObjRef("org_ref", *orgRef))
|
||||||
|
return nil, nil, selErr
|
||||||
|
}
|
||||||
|
orgFields := []zap.Field{
|
||||||
|
mzap.ObjRef("org_ref", *orgRef),
|
||||||
|
zap.String("trigger", string(trigger)),
|
||||||
|
zap.Time("booked_at", at),
|
||||||
|
zap.Any("attributes", attrs),
|
||||||
|
}
|
||||||
|
orgFields = append(orgFields, zapFieldsForPlan(plan)...)
|
||||||
|
r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...)
|
||||||
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", *orgRef))
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule != nil {
|
|
||||||
return plan, rule, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := r.getGlobalPlan(ctx, asOf)
|
plan, err := r.getGlobalPlan(ctx, at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)),
|
r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)),
|
||||||
zap.Time("booked_at", asOf), zap.Any("attributes", attrs),
|
zap.Time("booked_at", at), zap.Any("attributes", attrs),
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
|
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
}
|
}
|
||||||
|
|
||||||
r.logger.Warn("Failed resolving global fee plan", zap.Error(err))
|
r.logger.Warn("Failed resolving global fee plan", zap.Error(err))
|
||||||
|
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rule, err := selectRule(plan, trigger, asOf, attrs)
|
rule, err := selectRule(plan, trigger, at, attrs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, ErrNoFeeRuleFound) {
|
if !errors.Is(err, ErrNoFeeRuleFound) {
|
||||||
r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
|
r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
globalFields := zapFieldsForPlan(plan)
|
globalFields := []zap.Field{
|
||||||
globalFields = append([]zap.Field{
|
|
||||||
zap.String("trigger", string(trigger)),
|
zap.String("trigger", string(trigger)),
|
||||||
zap.Time("booked_at", asOf),
|
zap.Time("booked_at", at),
|
||||||
zap.Any("attributes", attrs),
|
zap.Any("attributes", attrs),
|
||||||
}, globalFields...)
|
}
|
||||||
|
globalFields = append(globalFields, zapFieldsForPlan(plan)...)
|
||||||
r.logger.Debug("No matching rule in global plan", globalFields...)
|
r.logger.Debug("No matching rule in global plan", globalFields...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
r.logSelectedRule(orgRef, trigger, asOf, attrs, rule, plan)
|
selectedFields := []zap.Field{
|
||||||
|
|
||||||
return plan, rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryOrgRule attempts to find a matching rule in the org-specific plan.
|
|
||||||
// Returns (plan, rule, nil) on success, (nil, nil, nil) to signal fallthrough to global,
|
|
||||||
// or (nil, nil, err) on a hard error.
|
|
||||||
func (r *feeResolver) tryOrgRule(ctx context.Context, orgRef bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
|
||||||
plan, err := r.getOrgPlan(ctx, orgRef, asOf)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", orgRef))
|
|
||||||
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rule, selErr := selectRule(plan, trigger, asOf, attrs)
|
|
||||||
if selErr == nil {
|
|
||||||
return plan, rule, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
|
||||||
r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), mzap.ObjRef("org_ref", orgRef))
|
|
||||||
|
|
||||||
return nil, nil, selErr
|
|
||||||
}
|
|
||||||
|
|
||||||
orgFields := zapFieldsForPlan(plan)
|
|
||||||
orgFields = append([]zap.Field{
|
|
||||||
mzap.ObjRef("org_ref", orgRef),
|
|
||||||
zap.String("trigger", string(trigger)),
|
|
||||||
zap.Time("booked_at", asOf),
|
|
||||||
zap.Any("attributes", attrs),
|
|
||||||
}, orgFields...)
|
|
||||||
r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...)
|
|
||||||
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *feeResolver) logSelectedRule(orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string, rule *model.FeeRule, plan *model.FeePlan) {
|
|
||||||
fields := []zap.Field{
|
|
||||||
zap.String("trigger", string(trigger)),
|
zap.String("trigger", string(trigger)),
|
||||||
zap.Time("booked_at", at),
|
zap.Time("booked_at", at),
|
||||||
zap.Any("attributes", attrs),
|
zap.Any("attributes", attrs),
|
||||||
@@ -146,65 +106,59 @@ func (r *feeResolver) logSelectedRule(orgRef *bson.ObjectID, trigger model.Trigg
|
|||||||
zap.Time("rule_effective_from", rule.EffectiveFrom),
|
zap.Time("rule_effective_from", rule.EffectiveFrom),
|
||||||
}
|
}
|
||||||
if rule.EffectiveTo != nil {
|
if rule.EffectiveTo != nil {
|
||||||
fields = append(fields, zap.Time("rule_effective_to", *rule.EffectiveTo))
|
selectedFields = append(selectedFields, zap.Time("rule_effective_to", *rule.EffectiveTo))
|
||||||
}
|
}
|
||||||
|
if orgRef != nil && !orgRef.IsZero() {
|
||||||
if isOrgRef(orgRef) {
|
selectedFields = append(selectedFields, mzap.ObjRef("org_ref", *orgRef))
|
||||||
fields = append(fields, mzap.ObjRef("org_ref", *orgRef))
|
|
||||||
}
|
}
|
||||||
|
selectedFields = append(selectedFields, zapFieldsForPlan(plan)...)
|
||||||
|
r.logger.Debug("Selected fee rule", selectedFields...)
|
||||||
|
|
||||||
fields = append(fields, zapFieldsForPlan(plan)...)
|
return plan, rule, nil
|
||||||
r.logger.Debug("Selected fee rule", fields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isOrgRef(ref *bson.ObjectID) bool {
|
|
||||||
return ref != nil && !ref.IsZero()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
if r.finder != nil {
|
if r.finder != nil {
|
||||||
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
|
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.plans.GetActivePlan(ctx, orgRef, at)
|
return r.plans.GetActivePlan(ctx, orgRef, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *feeResolver) getGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) {
|
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
if r.finder != nil {
|
if r.finder != nil {
|
||||||
return r.finder.FindActiveGlobalPlan(ctx, asOf)
|
return r.finder.FindActiveGlobalPlan(ctx, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treat zero ObjectID as global in legacy path.
|
// Treat zero ObjectID as global in legacy path.
|
||||||
return r.plans.GetActivePlan(ctx, bson.NilObjectID, asOf)
|
return r.plans.GetActivePlan(ctx, bson.NilObjectID, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectRule(plan *model.FeePlan, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeeRule, error) {
|
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
|
||||||
if plan == nil {
|
if plan == nil {
|
||||||
return nil, merrors.NoData("fees: no applicable fee rule found")
|
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var selected *model.FeeRule
|
||||||
selected *model.FeeRule
|
var highestPriority int
|
||||||
highestPriority int
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, rule := range plan.Rules {
|
for _, rule := range plan.Rules {
|
||||||
if !ruleIsActive(rule, trigger, asOf) {
|
if rule.Trigger != trigger {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.EffectiveFrom.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if selected == nil || rule.Priority > highestPriority {
|
if selected == nil || rule.Priority > highestPriority {
|
||||||
matched := rule
|
copy := rule
|
||||||
selected = &matched
|
selected = ©
|
||||||
highestPriority = rule.Priority
|
highestPriority = rule.Priority
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.Priority == highestPriority {
|
if rule.Priority == highestPriority {
|
||||||
return nil, merrors.DataConflict("fees: conflicting fee rules")
|
return nil, merrors.DataConflict("fees: conflicting fee rules")
|
||||||
}
|
}
|
||||||
@@ -213,46 +167,25 @@ func selectRule(plan *model.FeePlan, trigger model.Trigger, asOf time.Time, attr
|
|||||||
if selected == nil {
|
if selected == nil {
|
||||||
return nil, merrors.NoData("fees: no applicable fee rule found")
|
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ruleIsActive(rule model.FeeRule, trigger model.Trigger, asOf time.Time) bool {
|
|
||||||
if rule.Trigger != trigger {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if rule.EffectiveFrom.After(asOf) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(asOf) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
|
func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
|
||||||
if len(appliesTo) == 0 {
|
if len(appliesTo) == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value := range appliesTo {
|
for key, value := range appliesTo {
|
||||||
if attrs == nil {
|
if attrs == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
attrValue, ok := attrs[key]
|
attrValue, ok := attrs[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matchesAppliesValue(value, attrValue) {
|
if !matchesAppliesValue(value, attrValue) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,17 +195,16 @@ func matchesAppliesValue(expected, actual string) bool {
|
|||||||
return actual == ""
|
return actual == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
for value := range strings.SplitSeq(trimmed, ",") {
|
values := strings.Split(trimmed, ",")
|
||||||
|
for _, value := range values {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if value == "*" || value == actual {
|
if value == "*" || value == actual {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +212,6 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
|
|||||||
if plan == nil {
|
if plan == nil {
|
||||||
return []zap.Field{zap.Bool("plan_present", false)}
|
return []zap.Field{zap.Bool("plan_present", false)}
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := []zap.Field{
|
fields := []zap.Field{
|
||||||
zap.Bool("plan_present", true),
|
zap.Bool("plan_present", true),
|
||||||
zap.Bool("plan_active", plan.Active),
|
zap.Bool("plan_active", plan.Active),
|
||||||
@@ -292,16 +223,13 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
|
|||||||
} else {
|
} else {
|
||||||
fields = append(fields, zap.Bool("plan_effective_to_set", false))
|
fields = append(fields, zap.Bool("plan_effective_to_set", false))
|
||||||
}
|
}
|
||||||
|
|
||||||
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||||
fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef))
|
fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef))
|
||||||
} else {
|
} else {
|
||||||
fields = append(fields, zap.Bool("plan_org_ref_set", false))
|
fields = append(fields, zap.Bool("plan_org_ref_set", false))
|
||||||
}
|
}
|
||||||
|
|
||||||
if plan.GetID() != nil && !plan.GetID().IsZero() {
|
if plan.GetID() != nil && !plan.GetID().IsZero() {
|
||||||
fields = append(fields, mzap.StorableRef(plan))
|
fields = append(fields, mzap.StorableRef(plan))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
globalPlan := &model.FeePlan{
|
globalPlan := &model.FeePlan{
|
||||||
@@ -28,23 +28,20 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
|||||||
resolver := New(store, zap.NewNop())
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
orgA := bson.NewObjectID()
|
orgA := bson.NewObjectID()
|
||||||
|
|
||||||
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
|
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||||
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
|
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "global_capture" {
|
if rule.RuleID != "global_capture" {
|
||||||
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
org := bson.NewObjectID()
|
org := bson.NewObjectID()
|
||||||
@@ -70,25 +67,22 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected org plan rule, got error: %v", err)
|
t.Fatalf("expected org plan rule, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "org_capture" {
|
if rule.RuleID != "org_capture" {
|
||||||
t.Fatalf("expected org rule, got %s", rule.RuleID)
|
t.Fatalf("expected org rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
otherOrg := bson.NewObjectID()
|
otherOrg := bson.NewObjectID()
|
||||||
|
|
||||||
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
|
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected global fallback for other org, got error: %v", err)
|
t.Fatalf("expected global fallback for other org, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "global_capture" {
|
if rule.RuleID != "global_capture" {
|
||||||
t.Fatalf("expected global rule, got %s", rule.RuleID)
|
t.Fatalf("expected global rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolver_SelectsHighestPriority(t *testing.T) {
|
func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
org := bson.NewObjectID()
|
org := bson.NewObjectID()
|
||||||
@@ -109,7 +103,6 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected rule resolution, got error: %v", err)
|
t.Fatalf("expected rule resolution, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "high" {
|
if rule.RuleID != "high" {
|
||||||
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
|
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
@@ -128,7 +121,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
org := bson.NewObjectID()
|
org := bson.NewObjectID()
|
||||||
@@ -159,14 +152,13 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "current" {
|
if rule.RuleID != "current" {
|
||||||
t.Fatalf("expected current global rule, got %s", rule.RuleID)
|
t.Fatalf("expected current global rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolver_AppliesToFiltering(t *testing.T) {
|
func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
@@ -184,7 +176,6 @@ func TestResolver_AppliesToFiltering(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected card rule, got error: %v", err)
|
t.Fatalf("expected card rule, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "card" {
|
if rule.RuleID != "card" {
|
||||||
t.Fatalf("expected card rule, got %s", rule.RuleID)
|
t.Fatalf("expected card rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
@@ -193,14 +184,13 @@ func TestResolver_AppliesToFiltering(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected default rule, got error: %v", err)
|
t.Fatalf("expected default rule, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "default" {
|
if rule.RuleID != "default" {
|
||||||
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
@@ -219,7 +209,6 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected list match rule, got error: %v", err)
|
t.Fatalf("expected list match rule, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "network_multi" {
|
if rule.RuleID != "network_multi" {
|
||||||
t.Fatalf("expected network list rule, got %s", rule.RuleID)
|
t.Fatalf("expected network list rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
@@ -228,7 +217,6 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected wildcard rule, got error: %v", err)
|
t.Fatalf("expected wildcard rule, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "asset_any" {
|
if rule.RuleID != "asset_any" {
|
||||||
t.Fatalf("expected asset wildcard rule, got %s", rule.RuleID)
|
t.Fatalf("expected asset wildcard rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
@@ -237,14 +225,13 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected default rule, got error: %v", err)
|
t.Fatalf("expected default rule, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rule.RuleID != "default" {
|
if rule.RuleID != "default" {
|
||||||
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
@@ -263,28 +250,28 @@ func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
org := bson.NewObjectID()
|
org := bson.NewObjectID()
|
||||||
plan1 := &model.FeePlan{
|
p1 := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
EffectiveFrom: now.Add(-time.Hour),
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
Rules: []model.FeeRule{
|
Rules: []model.FeeRule{
|
||||||
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan1.OrganizationRef = &org
|
p1.OrganizationRef = &org
|
||||||
plan2 := &model.FeePlan{
|
p2 := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
EffectiveFrom: now.Add(-30 * time.Minute),
|
EffectiveFrom: now.Add(-30 * time.Minute),
|
||||||
Rules: []model.FeeRule{
|
Rules: []model.FeeRule{
|
||||||
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan2.OrganizationRef = &org
|
p2.OrganizationRef = &org
|
||||||
|
|
||||||
store := &memoryPlansStore{plans: []*model.FeePlan{plan1, plan2}}
|
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||||
resolver := New(store, zap.NewNop())
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
|
||||||
@@ -302,83 +289,66 @@ func (m *memoryPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan,
|
|||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
if !orgRef.IsZero() {
|
if !orgRef.IsZero() {
|
||||||
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
|
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
|
||||||
return plan, nil
|
return plan, nil
|
||||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return m.FindActiveGlobalPlan(ctx, at)
|
||||||
return m.FindActiveGlobalPlan(ctx, asOf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
var matches []*model.FeePlan
|
var matches []*model.FeePlan
|
||||||
|
|
||||||
for _, plan := range m.plans {
|
for _, plan := range m.plans {
|
||||||
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !plan.Active {
|
if !plan.Active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if plan.EffectiveFrom.After(at) {
|
||||||
if plan.EffectiveFrom.After(asOf) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
matches = append(matches, plan)
|
matches = append(matches, plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
return nil, storage.ErrConflictingFeePlans
|
return nil, storage.ErrConflictingFeePlans
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches[0], nil
|
return matches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, asOf time.Time) (*model.FeePlan, error) {
|
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
var matches []*model.FeePlan
|
var matches []*model.FeePlan
|
||||||
|
|
||||||
for _, plan := range m.plans {
|
for _, plan := range m.plans {
|
||||||
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !plan.Active {
|
if !plan.Active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if plan.EffectiveFrom.After(at) {
|
||||||
if plan.EffectiveFrom.After(asOf) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
matches = append(matches, plan)
|
matches = append(matches, plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
return nil, storage.ErrConflictingFeePlans
|
return nil, storage.ErrConflictingFeePlans
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches[0], nil
|
return matches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
|
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
|
||||||
fields := logFieldsFromRequestMeta(meta)
|
fields := logFieldsFromRequestMeta(meta)
|
||||||
fields = append(fields, logFieldsFromIntent(intent)...)
|
fields = append(fields, logFieldsFromIntent(intent)...)
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,14 +19,11 @@ func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
|
|||||||
if meta == nil {
|
if meta == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make([]zap.Field, 0, 4)
|
fields := make([]zap.Field, 0, 4)
|
||||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||||
fields = append(fields, zap.String("organization_ref", org))
|
fields = append(fields, zap.String("organization_ref", org))
|
||||||
}
|
}
|
||||||
|
|
||||||
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
|
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,30 +31,24 @@ func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
|
|||||||
if intent == nil {
|
if intent == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make([]zap.Field, 0, 5)
|
fields := make([]zap.Field, 0, 5)
|
||||||
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||||
fields = append(fields, zap.String("trigger", trigger.String()))
|
fields = append(fields, zap.String("trigger", trigger.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if base := intent.GetBaseAmount(); base != nil {
|
if base := intent.GetBaseAmount(); base != nil {
|
||||||
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
|
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
|
||||||
fields = append(fields, zap.String("base_amount", amount))
|
fields = append(fields, zap.String("base_amount", amount))
|
||||||
}
|
}
|
||||||
|
|
||||||
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
|
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
|
||||||
fields = append(fields, zap.String("base_currency", currency))
|
fields = append(fields, zap.String("base_currency", currency))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
|
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
|
||||||
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
|
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if attrs := intent.GetAttributes(); len(attrs) > 0 {
|
if attrs := intent.GetAttributes(); len(attrs) > 0 {
|
||||||
fields = append(fields, zap.Int("attributes_count", len(attrs)))
|
fields = append(fields, zap.Int("attributes_count", len(attrs)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,20 +56,16 @@ func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
|
|||||||
if trace == nil {
|
if trace == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make([]zap.Field, 0, 3)
|
fields := make([]zap.Field, 0, 3)
|
||||||
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
|
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
|
||||||
fields = append(fields, zap.String("request_ref", reqRef))
|
fields = append(fields, zap.String("request_ref", reqRef))
|
||||||
}
|
}
|
||||||
|
|
||||||
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||||
fields = append(fields, zap.String("idempotency_key", idem))
|
fields = append(fields, zap.String("idempotency_key", idem))
|
||||||
}
|
}
|
||||||
|
|
||||||
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
|
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
|
||||||
fields = append(fields, zap.String("trace_ref", traceRef))
|
fields = append(fields, zap.String("trace_ref", traceRef))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,20 +73,16 @@ func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
|
|||||||
if payload == nil {
|
if payload == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make([]zap.Field, 0, 6)
|
fields := make([]zap.Field, 0, 6)
|
||||||
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
|
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
|
||||||
fields = append(fields, zap.String("organization_ref", org))
|
fields = append(fields, zap.String("organization_ref", org))
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.ExpiresAtUnixMs > 0 {
|
if payload.ExpiresAtUnixMs > 0 {
|
||||||
fields = append(fields,
|
fields = append(fields,
|
||||||
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
|
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
|
||||||
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
|
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
|
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
|
||||||
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
|
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxU
|
|||||||
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||||
triggerLabel = "TRIGGER_UNSPECIFIED"
|
triggerLabel = "TRIGGER_UNSPECIFIED"
|
||||||
}
|
}
|
||||||
|
|
||||||
fxLabel := strconv.FormatBool(fxUsed)
|
fxLabel := strconv.FormatBool(fxUsed)
|
||||||
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
|
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
|
||||||
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
|
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
|
||||||
@@ -60,16 +59,13 @@ func statusFromError(err error) string {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
st, ok := status.FromError(err)
|
st, ok := status.FromError(err)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "error"
|
return "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
code := st.Code()
|
code := st.Code()
|
||||||
if code == codes.OK {
|
if code == codes.OK {
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.ToLower(code.String())
|
return strings.ToLower(code.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,7 +22,6 @@ import (
|
|||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@@ -33,8 +33,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
feesv1.UnimplementedFeeEngineServer
|
|
||||||
|
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
producer msg.Producer
|
producer msg.Producer
|
||||||
@@ -44,6 +42,7 @@ type Service struct {
|
|||||||
resolver FeeResolver
|
resolver FeeResolver
|
||||||
announcer *discovery.Announcer
|
announcer *discovery.Announcer
|
||||||
invokeURI string
|
invokeURI string
|
||||||
|
feesv1.UnimplementedFeeEngineServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||||
@@ -53,7 +52,6 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
producer: producer,
|
producer: producer,
|
||||||
clock: clockpkg.NewSystem(),
|
clock: clockpkg.NewSystem(),
|
||||||
}
|
}
|
||||||
|
|
||||||
initMetrics()
|
initMetrics()
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
@@ -63,11 +61,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
if svc.clock == nil {
|
if svc.clock == nil {
|
||||||
svc.clock = clockpkg.NewSystem()
|
svc.clock = clockpkg.NewSystem()
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.calculator == nil {
|
if svc.calculator == nil {
|
||||||
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
|
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.resolver == nil {
|
if svc.resolver == nil {
|
||||||
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||||
}
|
}
|
||||||
@@ -87,12 +83,25 @@ func (s *Service) Shutdown() {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.announcer != nil {
|
if s.announcer != nil {
|
||||||
s.announcer.Stop()
|
s.announcer.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: "BILLING_FEES",
|
||||||
|
Operations: []string{"fee.calc"},
|
||||||
|
InvokeURI: s.invokeURI,
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
|
||||||
|
s.announcer.Start()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||||
var (
|
var (
|
||||||
meta *feesv1.RequestMeta
|
meta *feesv1.RequestMeta
|
||||||
@@ -102,29 +111,23 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
|||||||
meta = req.GetMeta()
|
meta = req.GetMeta()
|
||||||
intent = req.GetIntent()
|
intent = req.GetIntent()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||||
|
|
||||||
start := s.clock.Now()
|
start := s.clock.Now()
|
||||||
|
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
if intent != nil {
|
if intent != nil {
|
||||||
trigger = intent.GetTrigger()
|
trigger = intent.GetTrigger()
|
||||||
}
|
}
|
||||||
|
|
||||||
var fxUsed bool
|
var fxUsed bool
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
statusLabel := statusFromError(err)
|
statusLabel := statusFromError(err)
|
||||||
linesCount := 0
|
linesCount := 0
|
||||||
appliedCount := 0
|
appliedCount := 0
|
||||||
|
|
||||||
if err == nil && resp != nil {
|
if err == nil && resp != nil {
|
||||||
fxUsed = resp.GetFxUsed() != nil
|
fxUsed = resp.GetFxUsed() != nil
|
||||||
linesCount = len(resp.GetLines())
|
linesCount = len(resp.GetLines())
|
||||||
appliedCount = len(resp.GetApplied())
|
appliedCount = len(resp.GetApplied())
|
||||||
}
|
}
|
||||||
|
|
||||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||||
|
|
||||||
logFields := []zap.Field{
|
logFields := []zap.Field{
|
||||||
@@ -137,10 +140,8 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
|||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
|
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("QuoteFees finished", logFields...)
|
logger.Info("QuoteFees finished", logFields...)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -154,14 +155,12 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
|||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
||||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
lines, applied, fxResult, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
|
lines, applied, fx, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
|
||||||
if computeErr != nil {
|
if computeErr != nil {
|
||||||
err = computeErr
|
err = computeErr
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +168,8 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
|||||||
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
Applied: applied,
|
Applied: applied,
|
||||||
FxUsed: fxResult,
|
FxUsed: fx,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,17 +182,48 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
meta = req.GetMeta()
|
meta = req.GetMeta()
|
||||||
intent = req.GetIntent()
|
intent = req.GetIntent()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||||
|
|
||||||
start := s.clock.Now()
|
start := s.clock.Now()
|
||||||
|
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
if intent != nil {
|
if intent != nil {
|
||||||
trigger = intent.GetTrigger()
|
trigger = intent.GetTrigger()
|
||||||
}
|
}
|
||||||
|
var (
|
||||||
|
fxUsed bool
|
||||||
|
expiresAt time.Time
|
||||||
|
)
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
linesCount := 0
|
||||||
|
appliedCount := 0
|
||||||
|
if err == nil && resp != nil {
|
||||||
|
fxUsed = resp.GetFxUsed() != nil
|
||||||
|
linesCount = len(resp.GetLines())
|
||||||
|
appliedCount = len(resp.GetApplied())
|
||||||
|
if ts := resp.GetExpiresAt(); ts != nil {
|
||||||
|
expiresAt = ts.AsTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
||||||
|
|
||||||
defer func() { s.observePrecomputeFees(logger, err, resp, trigger, start) }()
|
logFields := []zap.Field{
|
||||||
|
zap.String("status", statusLabel),
|
||||||
|
zap.Duration("duration", time.Since(start)),
|
||||||
|
zap.Bool("fx_used", fxUsed),
|
||||||
|
zap.String("trigger", trigger.String()),
|
||||||
|
zap.Int("lines", linesCount),
|
||||||
|
zap.Int("applied_rules", appliedCount),
|
||||||
|
}
|
||||||
|
if !expiresAt.IsZero() {
|
||||||
|
logFields = append(logFields, zap.Time("expires_at", expiresAt))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("PrecomputeFees finished", logFields...)
|
||||||
|
}()
|
||||||
|
|
||||||
logger.Debug("PrecomputeFees request received")
|
logger.Debug("PrecomputeFees request received")
|
||||||
|
|
||||||
@@ -208,14 +237,12 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
||||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
|
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
|
||||||
if computeErr != nil {
|
if computeErr != nil {
|
||||||
err = computeErr
|
err = computeErr
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,8 +250,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = 60000
|
ttl = 60000
|
||||||
}
|
}
|
||||||
|
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
|
||||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
|
||||||
|
|
||||||
payload := feeQuoteTokenPayload{
|
payload := feeQuoteTokenPayload{
|
||||||
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||||
@@ -235,9 +261,8 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
|
|
||||||
var token string
|
var token string
|
||||||
if token, err = encodeTokenPayload(payload); err != nil {
|
if token, err = encodeTokenPayload(payload); err != nil {
|
||||||
logger.Warn("Failed to encode fee quote token", zap.Error(err))
|
logger.Warn("failed to encode fee quote token", zap.Error(err))
|
||||||
err = status.Error(codes.Internal, "failed to encode fee quote token")
|
err = status.Error(codes.Internal, "failed to encode fee quote token")
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,9 +272,8 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
ExpiresAt: timestamppb.New(expiresAt),
|
ExpiresAt: timestamppb.New(expiresAt),
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
Applied: applied,
|
Applied: applied,
|
||||||
FxUsed: fxResult,
|
FxUsed: fx,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,23 +282,49 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
|||||||
if req != nil {
|
if req != nil {
|
||||||
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
|
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := s.logger.With(zap.Int("token_length", tokenLen))
|
logger := s.logger.With(zap.Int("token_length", tokenLen))
|
||||||
|
|
||||||
start := s.clock.Now()
|
start := s.clock.Now()
|
||||||
|
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
|
var (
|
||||||
|
fxUsed bool
|
||||||
|
resultReason string
|
||||||
|
)
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
if err == nil && resp != nil {
|
||||||
|
if !resp.GetValid() {
|
||||||
|
statusLabel = "invalid"
|
||||||
|
}
|
||||||
|
fxUsed = resp.GetFxUsed() != nil
|
||||||
|
if resp.GetIntent() != nil {
|
||||||
|
trigger = resp.GetIntent().GetTrigger()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
|
||||||
|
|
||||||
var resultReason string
|
logFields := []zap.Field{
|
||||||
|
zap.String("status", statusLabel),
|
||||||
defer func() { s.observeValidateFeeToken(logger, err, resp, trigger, resultReason, start) }()
|
zap.Duration("duration", time.Since(start)),
|
||||||
|
zap.Bool("fx_used", fxUsed),
|
||||||
|
zap.String("trigger", trigger.String()),
|
||||||
|
zap.Bool("valid", resp != nil && resp.GetValid()),
|
||||||
|
}
|
||||||
|
if resultReason != "" {
|
||||||
|
logFields = append(logFields, zap.String("reason", resultReason))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("ValidateFeeToken finished", logFields...)
|
||||||
|
}()
|
||||||
|
|
||||||
logger.Debug("ValidateFeeToken request received")
|
logger.Debug("ValidateFeeToken request received")
|
||||||
|
|
||||||
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||||
resultReason = "missing_token"
|
resultReason = "missing_token"
|
||||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,11 +333,8 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
|||||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||||
if decodeErr != nil {
|
if decodeErr != nil {
|
||||||
resultReason = "invalid_token"
|
resultReason = "invalid_token"
|
||||||
|
logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
|
||||||
logger.Warn("Failed to decode fee quote token", zap.Error(decodeErr))
|
|
||||||
|
|
||||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,29 +346,22 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
|||||||
|
|
||||||
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||||
resultReason = "expired"
|
resultReason = "expired"
|
||||||
|
logger.Info("fee quote token expired")
|
||||||
logger.Info("Fee quote token expired")
|
|
||||||
|
|
||||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
|
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
resultReason = "invalid_token"
|
resultReason = "invalid_token"
|
||||||
|
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
|
||||||
logger.Warn("Token contained invalid organization reference", zap.Error(parseErr))
|
|
||||||
|
|
||||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
|
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
|
||||||
if computeErr != nil {
|
if computeErr != nil {
|
||||||
err = computeErr
|
err = computeErr
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,9 +371,8 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
|||||||
Intent: payload.Intent,
|
Intent: payload.Intent,
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
Applied: applied,
|
Applied: applied,
|
||||||
FxUsed: fxResult,
|
FxUsed: fx,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,31 +380,24 @@ func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
return status.Error(codes.InvalidArgument, "request is required")
|
return status.Error(codes.InvalidArgument, "request is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
|
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
|
||||||
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
|
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetIntent() == nil {
|
if req.GetIntent() == nil {
|
||||||
return status.Error(codes.InvalidArgument, "intent is required")
|
return status.Error(codes.InvalidArgument, "intent is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||||
return status.Error(codes.InvalidArgument, "intent.trigger is required")
|
return status.Error(codes.InvalidArgument, "intent.trigger is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetIntent().GetBaseAmount() == nil {
|
if req.GetIntent().GetBaseAmount() == nil {
|
||||||
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
|
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
|
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
|
||||||
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
|
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
|
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
|
||||||
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
|
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +405,6 @@ func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) e
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
return status.Error(codes.InvalidArgument, "request is required")
|
return status.Error(codes.InvalidArgument, "request is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
|
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,13 +413,17 @@ func (s *Service) computeQuote(ctx context.Context, orgRef bson.ObjectID, intent
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, _ *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, _ *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
||||||
bookedAt := resolvedBookedAt(intent, now)
|
bookedAt := now
|
||||||
|
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
|
||||||
logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)}
|
bookedAt = intent.GetBookedAt().AsTime()
|
||||||
if !orgRef.IsZero() {
|
|
||||||
logFields = append(logFields, mzap.ObjRef("organization_ref", orgRef))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logFields := []zap.Field{
|
||||||
|
zap.Time("booked_at_used", bookedAt),
|
||||||
|
}
|
||||||
|
if !orgRef.IsZero() {
|
||||||
|
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
|
||||||
|
}
|
||||||
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
||||||
logFields = append(logFields, logFieldsFromTrace(trace)...)
|
logFields = append(logFields, logFieldsFromTrace(trace)...)
|
||||||
logger := s.logger.With(logFields...)
|
logger := s.logger.With(logFields...)
|
||||||
@@ -401,13 +436,22 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
|
|||||||
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
|
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
|
s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
|
||||||
|
switch {
|
||||||
return nil, nil, nil, mapResolveError(err)
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee rule not found: %s", err.Error()))
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee rules: %s", err.Error()))
|
||||||
|
case errors.Is(err, storage.ErrConflictingFeePlans):
|
||||||
|
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee plans: %s", err.Error()))
|
||||||
|
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||||
|
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee plan not found: %s", err.Error()))
|
||||||
|
default:
|
||||||
|
return nil, nil, nil, status.Error(codes.Internal, fmt.Sprintf("failed to resolve fee rule: %s", err.Error()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
originalRules := plan.Rules
|
originalRules := plan.Rules
|
||||||
plan.Rules = []model.FeeRule{*rule}
|
plan.Rules = []model.FeeRule{*rule}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
plan.Rules = originalRules
|
plan.Rules = originalRules
|
||||||
}()
|
}()
|
||||||
@@ -417,116 +461,13 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
|
|||||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
||||||
}
|
}
|
||||||
|
logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
||||||
logger.Warn("Failed to compute fee quote", zap.Error(calcErr))
|
|
||||||
|
|
||||||
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Lines, result.Applied, result.FxUsed, nil
|
return result.Lines, result.Applied, result.FxUsed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvedBookedAt(intent *feesv1.Intent, now time.Time) time.Time {
|
|
||||||
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
|
|
||||||
return intent.GetBookedAt().AsTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
return now
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapResolveError(err error) error {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
|
||||||
return status.Error(codes.NotFound, "fee rule not found: "+err.Error())
|
|
||||||
case errors.Is(err, merrors.ErrDataConflict):
|
|
||||||
return status.Error(codes.FailedPrecondition, "conflicting fee rules: "+err.Error())
|
|
||||||
case errors.Is(err, storage.ErrConflictingFeePlans):
|
|
||||||
return status.Error(codes.FailedPrecondition, "conflicting fee plans: "+err.Error())
|
|
||||||
case errors.Is(err, storage.ErrFeePlanNotFound):
|
|
||||||
return status.Error(codes.NotFound, "fee plan not found: "+err.Error())
|
|
||||||
default:
|
|
||||||
return status.Error(codes.Internal, "failed to resolve fee rule: "+err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) observePrecomputeFees(logger mlogger.Logger, err error, resp *feesv1.PrecomputeFeesResponse, trigger feesv1.Trigger, start time.Time) {
|
|
||||||
statusLabel := statusFromError(err)
|
|
||||||
fxUsed := false
|
|
||||||
linesCount := 0
|
|
||||||
appliedCount := 0
|
|
||||||
|
|
||||||
var expiresAt time.Time
|
|
||||||
|
|
||||||
if err == nil && resp != nil {
|
|
||||||
fxUsed = resp.GetFxUsed() != nil
|
|
||||||
linesCount = len(resp.GetLines())
|
|
||||||
appliedCount = len(resp.GetApplied())
|
|
||||||
|
|
||||||
if ts := resp.GetExpiresAt(); ts != nil {
|
|
||||||
expiresAt = ts.AsTime()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
|
||||||
|
|
||||||
logFields := []zap.Field{
|
|
||||||
zap.String("status", statusLabel),
|
|
||||||
zap.Duration("duration", time.Since(start)),
|
|
||||||
zap.Bool("fx_used", fxUsed),
|
|
||||||
zap.String("trigger", trigger.String()),
|
|
||||||
zap.Int("lines", linesCount),
|
|
||||||
zap.Int("applied_rules", appliedCount),
|
|
||||||
}
|
|
||||||
if !expiresAt.IsZero() {
|
|
||||||
logFields = append(logFields, zap.Time("expires_at", expiresAt))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("PrecomputeFees finished", logFields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) observeValidateFeeToken(logger mlogger.Logger, err error, resp *feesv1.ValidateFeeTokenResponse, trigger feesv1.Trigger, resultReason string, start time.Time) {
|
|
||||||
statusLabel := statusFromError(err)
|
|
||||||
fxUsed := false
|
|
||||||
|
|
||||||
if err == nil && resp != nil {
|
|
||||||
if !resp.GetValid() {
|
|
||||||
statusLabel = "invalid"
|
|
||||||
}
|
|
||||||
|
|
||||||
fxUsed = resp.GetFxUsed() != nil
|
|
||||||
if resp.GetIntent() != nil {
|
|
||||||
trigger = resp.GetIntent().GetTrigger()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
|
|
||||||
|
|
||||||
logFields := []zap.Field{
|
|
||||||
zap.String("status", statusLabel),
|
|
||||||
zap.Duration("duration", time.Since(start)),
|
|
||||||
zap.Bool("fx_used", fxUsed),
|
|
||||||
zap.String("trigger", trigger.String()),
|
|
||||||
zap.Bool("valid", resp != nil && resp.GetValid()),
|
|
||||||
}
|
|
||||||
if resultReason != "" {
|
|
||||||
logFields = append(logFields, zap.String("reason", resultReason))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("ValidateFeeToken finished", logFields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
type feeQuoteTokenPayload struct {
|
type feeQuoteTokenPayload struct {
|
||||||
OrganizationRef string `json:"organization_ref"`
|
OrganizationRef string `json:"organization_ref"`
|
||||||
Intent *feesv1.Intent `json:"intent"`
|
Intent *feesv1.Intent `json:"intent"`
|
||||||
@@ -539,36 +480,17 @@ func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", merrors.Internal("fees: failed to serialize token payload")
|
return "", merrors.Internal("fees: failed to serialize token payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
return base64.StdEncoding.EncodeToString(data), nil
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
|
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
|
||||||
var payload feeQuoteTokenPayload
|
var payload feeQuoteTokenPayload
|
||||||
|
|
||||||
data, err := base64.StdEncoding.DecodeString(token)
|
data, err := base64.StdEncoding.DecodeString(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return payload, merrors.InvalidArgument("fees: invalid token encoding")
|
return payload, merrors.InvalidArgument("fees: invalid token encoding")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &payload); err != nil {
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
return payload, merrors.InvalidArgument("fees: invalid token payload")
|
return payload, merrors.InvalidArgument("fees: invalid token payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) startDiscoveryAnnouncer() {
|
|
||||||
if s == nil || s.producer == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
announce := discovery.Announcement{
|
|
||||||
Service: mservice.BillingFees,
|
|
||||||
Operations: []string{discovery.OperationFeeCalc},
|
|
||||||
InvokeURI: s.invokeURI,
|
|
||||||
Version: appversion.Create().Short(),
|
|
||||||
}
|
|
||||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.FeePlans, announce)
|
|
||||||
s.announcer.Start()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
||||||
orgRef := bson.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
@@ -93,11 +93,9 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
|||||||
if got := line.GetMoney().GetAmount(); got != "3.20" {
|
if got := line.GetMoney().GetAmount(); got != "3.20" {
|
||||||
t.Fatalf("expected fee amount 3.20, got %s", got)
|
t.Fatalf("expected fee amount 3.20, got %s", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.GetMoney().GetCurrency() != "USD" {
|
if line.GetMoney().GetCurrency() != "USD" {
|
||||||
t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
|
t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.GetLedgerAccountRef() != "acct:fees" {
|
if line.GetLedgerAccountRef() != "acct:fees" {
|
||||||
t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef())
|
t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef())
|
||||||
}
|
}
|
||||||
@@ -113,18 +111,16 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
|||||||
if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" {
|
if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" {
|
||||||
t.Fatalf("applied rule metadata mismatch: %+v", applied)
|
t.Fatalf("applied rule metadata mismatch: %+v", applied)
|
||||||
}
|
}
|
||||||
|
|
||||||
if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
|
if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
|
||||||
t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
|
t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
|
||||||
}
|
}
|
||||||
|
|
||||||
if applied.GetParameters()["scale"] != "2" {
|
if applied.GetParameters()["scale"] != "2" {
|
||||||
t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
|
t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
||||||
orgRef := bson.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
@@ -193,23 +189,20 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QuoteFees returned error: %v", err)
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.GetLines()) != 1 {
|
if len(resp.GetLines()) != 1 {
|
||||||
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
|
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
|
||||||
}
|
}
|
||||||
|
|
||||||
line := resp.GetLines()[0]
|
line := resp.GetLines()[0]
|
||||||
if line.GetLedgerAccountRef() != "acct:base" {
|
if line.GetLedgerAccountRef() != "acct:base" {
|
||||||
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
|
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.GetMoney().GetAmount() != "5.00" {
|
if line.GetMoney().GetAmount() != "5.00" {
|
||||||
t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
|
t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteFees_RoundingDown(t *testing.T) {
|
func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
||||||
orgRef := bson.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
@@ -256,18 +249,16 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QuoteFees returned error: %v", err)
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.GetLines()) != 1 {
|
if len(resp.GetLines()) != 1 {
|
||||||
t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
|
t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
|
if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
|
||||||
t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
|
t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
||||||
orgRef := bson.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
@@ -325,26 +316,22 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QuoteFees returned error: %v", err)
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !calc.called {
|
if !calc.called {
|
||||||
t.Fatalf("expected calculator to be invoked")
|
t.Fatalf("expected calculator to be invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
if calc.gotPlan != plan {
|
if calc.gotPlan != plan {
|
||||||
t.Fatalf("expected calculator to receive plan pointer")
|
t.Fatalf("expected calculator to receive plan pointer")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.GetLines()) != len(result.Lines) {
|
if len(resp.GetLines()) != len(result.Lines) {
|
||||||
t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
|
t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
|
if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
|
||||||
t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
|
t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Helper()
|
||||||
|
|
||||||
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||||
orgRef := bson.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
@@ -369,7 +356,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
|||||||
plan.OrganizationRef = &orgRef
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
fakeOracle := &oracleclient.Fake{
|
fakeOracle := &oracleclient.Fake{
|
||||||
LatestRateFn: func(_ context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||||
return &oracleclient.RateSnapshot{
|
return &oracleclient.RateSnapshot{
|
||||||
Pair: req.Pair,
|
Pair: req.Pair,
|
||||||
Mid: "1.2300",
|
Mid: "1.2300",
|
||||||
@@ -412,12 +399,10 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
|||||||
if resp.GetFxUsed() == nil {
|
if resp.GetFxUsed() == nil {
|
||||||
t.Fatalf("expected FxUsed to be populated")
|
t.Fatalf("expected FxUsed to be populated")
|
||||||
}
|
}
|
||||||
|
|
||||||
fx := resp.GetFxUsed()
|
fx := resp.GetFxUsed()
|
||||||
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
|
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
|
||||||
t.Fatalf("unexpected FxUsed payload: %+v", fx)
|
t.Fatalf("unexpected FxUsed payload: %+v", fx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
|
if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
|
||||||
t.Fatalf("unexpected currency pair: %+v", fx.GetPair())
|
t.Fatalf("unexpected currency pair: %+v", fx.GetPair())
|
||||||
}
|
}
|
||||||
@@ -452,59 +437,49 @@ func (s *stubPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan, er
|
|||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
if !orgRef.IsZero() {
|
if !orgRef.IsZero() {
|
||||||
if plan, err := s.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
|
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
|
||||||
return plan, nil
|
return plan, nil
|
||||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return s.FindActiveGlobalPlan(context.Background(), at)
|
||||||
return s.FindActiveGlobalPlan(ctx, asOf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
if s.plan == nil {
|
if s.plan == nil {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
|
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.plan.Active {
|
if !s.plan.Active {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
if s.plan.EffectiveFrom.After(at) {
|
||||||
if s.plan.EffectiveFrom.After(asOf) {
|
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
|
||||||
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(asOf) {
|
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.plan, nil
|
return s.plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, asOf time.Time) (*model.FeePlan, error) {
|
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
if s.globalPlan == nil {
|
if s.globalPlan == nil {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.globalPlan.Active {
|
if !s.globalPlan.Active {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
if s.globalPlan.EffectiveFrom.After(at) {
|
||||||
if s.globalPlan.EffectiveFrom.After(asOf) {
|
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
|
||||||
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(asOf) {
|
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.globalPlan, nil
|
return s.globalPlan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,10 +509,8 @@ func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *fees
|
|||||||
s.called = true
|
s.called = true
|
||||||
s.gotPlan = plan
|
s.gotPlan = plan
|
||||||
s.bookedAt = bookedAt
|
s.bookedAt = bookedAt
|
||||||
|
|
||||||
if s.err != nil {
|
if s.err != nil {
|
||||||
return nil, s.err
|
return nil, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.result, nil
|
return s.result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
|
|
||||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||||
switch trigger {
|
switch trigger {
|
||||||
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
|
|
||||||
return model.TriggerUnspecified
|
|
||||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||||
return model.TriggerCapture
|
return model.TriggerCapture
|
||||||
case feesv1.Trigger_TRIGGER_REFUND:
|
case feesv1.Trigger_TRIGGER_REFUND:
|
||||||
|
|||||||
@@ -28,13 +28,12 @@ const (
|
|||||||
type FeePlan struct {
|
type FeePlan struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
model.Describable `bson:",inline" json:",inline"`
|
model.Describable `bson:",inline" json:",inline"`
|
||||||
|
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
|
||||||
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
|
Active bool `bson:"active" json:"active"`
|
||||||
Active bool `bson:"active" json:"active"`
|
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"`
|
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collection implements storable.Storable.
|
// Collection implements storable.Storable.
|
||||||
@@ -44,21 +43,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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,22 +43,18 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := result.Ping(ctx); err != nil {
|
if err := result.Ping(ctx); err != nil {
|
||||||
result.logger.Error("Mongo ping failed during store init", zap.Error(err))
|
result.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
plansStore, err := store.NewPlans(result.logger, database)
|
plansStore, err := store.NewPlans(result.logger, database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.logger.Error("Failed to initialise plans store", zap.Error(err))
|
result.logger.Error("failed to initialise plans store", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result.plans = plansStore
|
result.plans = plansStore
|
||||||
|
|
||||||
result.logger.Info("Billing fees MongoDB storage initialised")
|
result.logger.Info("Billing fees MongoDB storage initialised")
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ type plansStore struct {
|
|||||||
repo repository.Repository
|
repo repository.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxActivePlanResults = 2
|
|
||||||
|
|
||||||
// NewPlans constructs a Mongo-backed PlansStore.
|
// NewPlans constructs a Mongo-backed PlansStore.
|
||||||
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
|
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
|
||||||
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
|
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
|
||||||
@@ -42,8 +40,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := repo.CreateIndex(orgIndex); err != nil {
|
if err := repo.CreateIndex(orgIndex); err != nil {
|
||||||
logger.Error("Failed to ensure fee plan organization index", zap.Error(err))
|
logger.Error("failed to ensure fee plan organization index", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +53,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
Unique: true,
|
Unique: true,
|
||||||
}
|
}
|
||||||
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
||||||
logger.Error("Failed to ensure fee plan uniqueness index", zap.Error(err))
|
logger.Error("failed to ensure fee plan uniqueness index", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +67,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := repo.CreateIndex(activeIndex); err != nil {
|
if err := repo.CreateIndex(activeIndex); err != nil {
|
||||||
logger.Warn("Failed to ensure fee plan active index", zap.Error(err))
|
logger.Warn("failed to ensure fee plan active index", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &plansStore{
|
return &plansStore{
|
||||||
@@ -84,7 +80,6 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
|||||||
if err := validatePlan(plan); err != nil {
|
if err := validatePlan(plan); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,12 +88,9 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
|||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
return storage.ErrDuplicateFeePlan
|
return storage.ErrDuplicateFeePlan
|
||||||
}
|
}
|
||||||
|
p.logger.Warn("failed to create fee plan", zap.Error(err))
|
||||||
p.logger.Warn("Failed to create fee plan", zap.Error(err))
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,21 +98,17 @@ 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 {
|
if err := validatePlan(plan); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||||
return err
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,83 +116,72 @@ func (p *plansStore) Get(ctx context.Context, planRef bson.ObjectID) (*model.Fee
|
|||||||
if planRef.IsZero() {
|
if planRef.IsZero() {
|
||||||
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
|
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &model.FeePlan{}
|
result := &model.FeePlan{}
|
||||||
if err := p.repo.Get(ctx, planRef, result); err != nil {
|
if err := p.repo.Get(ctx, planRef, result); err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
|
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
|
||||||
if orgRef.IsZero() {
|
if orgRef.IsZero() {
|
||||||
return p.FindActiveGlobalPlan(ctx, asOf)
|
return p.FindActiveGlobalPlan(ctx, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := p.FindActiveOrgPlan(ctx, orgRef, asOf)
|
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return plan, nil
|
return plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
return p.FindActiveGlobalPlan(ctx, asOf)
|
return p.FindActiveGlobalPlan(ctx, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef bson.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)
|
query := repository.Query().Filter(repository.OrgField(), orgRef)
|
||||||
|
return p.findActivePlan(ctx, query, at)
|
||||||
return p.findActivePlan(ctx, query, asOf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) {
|
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
globalQuery := repository.Query().Or(
|
globalQuery := repository.Query().Or(
|
||||||
repository.Exists(repository.OrgField(), false),
|
repository.Exists(repository.OrgField(), false),
|
||||||
repository.Query().Filter(repository.OrgField(), nil),
|
repository.Query().Filter(repository.OrgField(), nil),
|
||||||
)
|
)
|
||||||
|
return p.findActivePlan(ctx, globalQuery, at)
|
||||||
return p.findActivePlan(ctx, globalQuery, asOf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ storage.PlansStore = (*plansStore)(nil)
|
var _ storage.PlansStore = (*plansStore)(nil)
|
||||||
|
|
||||||
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, asOf time.Time) (*model.FeePlan, error) {
|
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
|
||||||
limit := int64(maxActivePlanResults)
|
limit := int64(2)
|
||||||
query := orgQuery.
|
query := orgQuery.
|
||||||
Filter(repository.Field("active"), true).
|
Filter(repository.Field("active"), true).
|
||||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, asOf).
|
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
||||||
Sort(repository.Field("effectiveFrom"), false).
|
Sort(repository.Field("effectiveFrom"), false).
|
||||||
Limit(&limit)
|
Limit(&limit)
|
||||||
|
|
||||||
query = query.And(
|
query = query.And(
|
||||||
repository.Query().Or(
|
repository.Query().Or(
|
||||||
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
||||||
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, asOf),
|
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, at),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
var plans []*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
|
||||||
}
|
}
|
||||||
|
|
||||||
plans = append(plans, target)
|
plans = append(plans, target)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,18 +189,15 @@ func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query,
|
|||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(plans) == 0 {
|
if len(plans) == 0 {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(plans) > 1 {
|
if len(plans) > 1 {
|
||||||
return nil, storage.ErrConflictingFeePlans
|
return nil, storage.ErrConflictingFeePlans
|
||||||
}
|
}
|
||||||
|
|
||||||
return plans[0], nil
|
return plans[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,61 +205,44 @@ func validatePlan(plan *model.FeePlan) error {
|
|||||||
if plan == nil {
|
if plan == nil {
|
||||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(plan.Rules) == 0 {
|
if len(plan.Rules) == 0 {
|
||||||
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
|
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
|
||||||
}
|
}
|
||||||
|
|
||||||
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
|
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
|
||||||
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
|
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure unique priority per (trigger, appliesTo) combination.
|
// Ensure unique priority per (trigger, appliesTo) combination.
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
for _, rule := range plan.Rules {
|
for _, rule := range plan.Rules {
|
||||||
if err := validateRule(rule); err != nil {
|
if strings.TrimSpace(rule.Percentage) != "" {
|
||||||
return err
|
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)
|
appliesKey := normalizeAppliesTo(rule.AppliesTo)
|
||||||
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
|
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
|
||||||
|
|
||||||
if _, ok := seen[priorityKey]; ok {
|
if _, ok := seen[priorityKey]; ok {
|
||||||
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
|
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
|
||||||
}
|
}
|
||||||
|
|
||||||
seen[priorityKey] = struct{}{}
|
seen[priorityKey] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateRule(rule model.FeeRule) error {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,19 +250,15 @@ func normalizeAppliesTo(applies map[string]string) string {
|
|||||||
if len(applies) == 0 {
|
if len(applies) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := make([]string, 0, len(applies))
|
keys := make([]string, 0, len(applies))
|
||||||
for k := range applies {
|
for k := range applies {
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
|
|
||||||
parts := make([]string, 0, len(keys))
|
parts := make([]string, 0, len(keys))
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
parts = append(parts, k+"="+normalizeAppliesToValue(applies[k]))
|
parts = append(parts, k+"="+normalizeAppliesToValue(applies[k]))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(parts, ",")
|
return strings.Join(parts, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,37 +272,28 @@ func normalizeAppliesToValue(value string) string {
|
|||||||
seen := make(map[string]struct{}, len(values))
|
seen := make(map[string]struct{}, len(values))
|
||||||
normalized := make([]string, 0, len(values))
|
normalized := make([]string, 0, len(values))
|
||||||
hasWildcard := false
|
hasWildcard := false
|
||||||
|
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if value == "*" {
|
if value == "*" {
|
||||||
hasWildcard = true
|
hasWildcard = true
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := seen[value]; ok {
|
if _, ok := seen[value]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
seen[value] = struct{}{}
|
seen[value] = struct{}{}
|
||||||
normalized = append(normalized, value)
|
normalized = append(normalized, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasWildcard {
|
if hasWildcard {
|
||||||
return "*"
|
return "*"
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(normalized) == 0 {
|
if len(normalized) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(normalized)
|
sort.Strings(normalized)
|
||||||
|
|
||||||
return strings.Join(normalized, ",")
|
return strings.Join(normalized, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +302,7 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgQuery builder.Query
|
orgQuery := repository.Query()
|
||||||
if plan.OrganizationRef.IsZero() {
|
if plan.OrganizationRef.IsZero() {
|
||||||
orgQuery = repository.Query().Or(
|
orgQuery = repository.Query().Or(
|
||||||
repository.Exists(repository.OrgField(), false),
|
repository.Exists(repository.OrgField(), false),
|
||||||
@@ -370,7 +314,6 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
|
|||||||
|
|
||||||
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
newFrom := plan.EffectiveFrom
|
newFrom := plan.EffectiveFrom
|
||||||
|
|
||||||
newTo := maxTime
|
newTo := maxTime
|
||||||
if plan.EffectiveTo != nil {
|
if plan.EffectiveTo != nil {
|
||||||
newTo = *plan.EffectiveTo
|
newTo = *plan.EffectiveTo
|
||||||
@@ -392,10 +335,8 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
|
|||||||
query = query.Limit(&limit)
|
query = query.Limit(&limit)
|
||||||
|
|
||||||
var overlapFound bool
|
var overlapFound bool
|
||||||
|
decoder := func(cursor *mongo.Cursor) error {
|
||||||
decoder := func(_ *mongo.Cursor) error {
|
|
||||||
overlapFound = true
|
overlapFound = true
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,13 +344,10 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
|
|||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if overlapFound {
|
if overlapFound {
|
||||||
return storage.ErrConflictingFeePlans
|
return storage.ErrConflictingFeePlans
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
# See the dedicated "version" documentation section.
|
|
||||||
version: "2"
|
|
||||||
linters:
|
|
||||||
# Default set of linters.
|
|
||||||
# The value can be:
|
|
||||||
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
|
|
||||||
# - `all`: enables all linters by default.
|
|
||||||
# - `none`: disables all linters by default.
|
|
||||||
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
|
|
||||||
# Default: standard
|
|
||||||
default: all
|
|
||||||
# Enable specific linter.
|
|
||||||
enable:
|
|
||||||
- arangolint
|
|
||||||
- asasalint
|
|
||||||
- asciicheck
|
|
||||||
- bidichk
|
|
||||||
- bodyclose
|
|
||||||
- canonicalheader
|
|
||||||
- containedctx
|
|
||||||
- contextcheck
|
|
||||||
- copyloopvar
|
|
||||||
- cyclop
|
|
||||||
- decorder
|
|
||||||
- dogsled
|
|
||||||
- dupl
|
|
||||||
- dupword
|
|
||||||
- durationcheck
|
|
||||||
- embeddedstructfieldcheck
|
|
||||||
- err113
|
|
||||||
- errcheck
|
|
||||||
- errchkjson
|
|
||||||
- errname
|
|
||||||
- errorlint
|
|
||||||
- exhaustive
|
|
||||||
- exptostd
|
|
||||||
- fatcontext
|
|
||||||
- forbidigo
|
|
||||||
- forcetypeassert
|
|
||||||
- funcorder
|
|
||||||
- funlen
|
|
||||||
- ginkgolinter
|
|
||||||
- gocheckcompilerdirectives
|
|
||||||
- gochecknoglobals
|
|
||||||
- gochecknoinits
|
|
||||||
- gochecksumtype
|
|
||||||
- gocognit
|
|
||||||
- goconst
|
|
||||||
- gocritic
|
|
||||||
- gocyclo
|
|
||||||
- godoclint
|
|
||||||
- godot
|
|
||||||
- godox
|
|
||||||
- goheader
|
|
||||||
- gomodguard
|
|
||||||
- goprintffuncname
|
|
||||||
- gosec
|
|
||||||
- gosmopolitan
|
|
||||||
- govet
|
|
||||||
- grouper
|
|
||||||
- iface
|
|
||||||
- importas
|
|
||||||
- inamedparam
|
|
||||||
- ineffassign
|
|
||||||
- interfacebloat
|
|
||||||
- intrange
|
|
||||||
- iotamixing
|
|
||||||
- ireturn
|
|
||||||
- lll
|
|
||||||
- loggercheck
|
|
||||||
- maintidx
|
|
||||||
- makezero
|
|
||||||
- mirror
|
|
||||||
- misspell
|
|
||||||
- mnd
|
|
||||||
- modernize
|
|
||||||
- musttag
|
|
||||||
- nakedret
|
|
||||||
- nestif
|
|
||||||
- nilerr
|
|
||||||
- nilnesserr
|
|
||||||
- nilnil
|
|
||||||
- nlreturn
|
|
||||||
- noctx
|
|
||||||
- noinlineerr
|
|
||||||
- nolintlint
|
|
||||||
- nonamedreturns
|
|
||||||
- nosprintfhostport
|
|
||||||
- paralleltest
|
|
||||||
- perfsprint
|
|
||||||
- prealloc
|
|
||||||
- predeclared
|
|
||||||
- promlinter
|
|
||||||
- protogetter
|
|
||||||
- reassign
|
|
||||||
- recvcheck
|
|
||||||
- revive
|
|
||||||
- rowserrcheck
|
|
||||||
- sloglint
|
|
||||||
- spancheck
|
|
||||||
- sqlclosecheck
|
|
||||||
- staticcheck
|
|
||||||
- tagalign
|
|
||||||
- tagliatelle
|
|
||||||
- testableexamples
|
|
||||||
- testifylint
|
|
||||||
- testpackage
|
|
||||||
- thelper
|
|
||||||
- tparallel
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
- unqueryvet
|
|
||||||
- unused
|
|
||||||
- usestdlibvars
|
|
||||||
- usetesting
|
|
||||||
- varnamelen
|
|
||||||
- wastedassign
|
|
||||||
- whitespace
|
|
||||||
- wsl_v5
|
|
||||||
- zerologlint
|
|
||||||
# Disable specific linters.
|
|
||||||
disable:
|
|
||||||
- depguard
|
|
||||||
- exhaustruct
|
|
||||||
- gochecknoglobals
|
|
||||||
- gomoddirectives
|
|
||||||
- wsl
|
|
||||||
- wrapcheck
|
|
||||||
# All available settings of specific linters.
|
|
||||||
# See the dedicated "linters.settings" documentation section.
|
|
||||||
settings:
|
|
||||||
wsl_v5:
|
|
||||||
allow-first-in-block: true
|
|
||||||
allow-whole-block: false
|
|
||||||
branch-max-lines: 2
|
|
||||||
|
|
||||||
# Defines a set of rules to ignore issues.
|
|
||||||
# It does not skip the analysis, and so does not ignore "typecheck" errors.
|
|
||||||
exclusions:
|
|
||||||
# Mode of the generated files analysis.
|
|
||||||
#
|
|
||||||
# - `strict`: sources are excluded by strictly following the Go generated file convention.
|
|
||||||
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
|
|
||||||
# This line must appear before the first non-comment, non-blank text in the file.
|
|
||||||
# https://go.dev/s/generatedcode
|
|
||||||
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
|
|
||||||
# - `disable`: disable the generated files exclusion.
|
|
||||||
#
|
|
||||||
# Default: strict
|
|
||||||
generated: lax
|
|
||||||
# Log a warning if an exclusion rule is unused.
|
|
||||||
# Default: false
|
|
||||||
warn-unused: true
|
|
||||||
# Predefined exclusion rules.
|
|
||||||
# Default: []
|
|
||||||
presets:
|
|
||||||
- comments
|
|
||||||
- std-error-handling
|
|
||||||
- common-false-positives
|
|
||||||
- legacy
|
|
||||||
# Excluding configuration per-path, per-linter, per-text and per-source.
|
|
||||||
rules:
|
|
||||||
# Exclude some linters from running on tests files.
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- funlen
|
|
||||||
- gocyclo
|
|
||||||
- errcheck
|
|
||||||
- dupl
|
|
||||||
- gosec
|
|
||||||
# Run some linter only for test files by excluding its issues for everything else.
|
|
||||||
- path-except: _test\.go
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
# Exclude known linters from partially hard-vendored code,
|
|
||||||
# which is impossible to exclude via `nolint` comments.
|
|
||||||
# `/` will be replaced by the current OS file path separator to properly work on Windows.
|
|
||||||
- path: internal/hmac/
|
|
||||||
text: "weak cryptographic primitive"
|
|
||||||
linters:
|
|
||||||
- gosec
|
|
||||||
# Exclude some `staticcheck` messages.
|
|
||||||
- linters:
|
|
||||||
- staticcheck
|
|
||||||
text: "SA9003:"
|
|
||||||
# Exclude `lll` issues for long lines with `go:generate`.
|
|
||||||
- linters:
|
|
||||||
- lll
|
|
||||||
source: "^//go:generate "
|
|
||||||
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
|
|
||||||
# "/" will be replaced by the current OS file path separator to properly work on Windows.
|
|
||||||
# Default: []
|
|
||||||
paths: []
|
|
||||||
# Which file paths to not exclude.
|
|
||||||
# Default: []
|
|
||||||
paths-except: []
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
module github.com/tech/sendico/discovery
|
module github.com/tech/sendico/discovery
|
||||||
|
|
||||||
go 1.25.7
|
go 1.25.6
|
||||||
|
|
||||||
replace github.com/tech/sendico/pkg => ../pkg
|
replace github.com/tech/sendico/pkg => ../pkg
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.4
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
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
|
||||||
@@ -20,17 +20,17 @@ require (
|
|||||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.3 // 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
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
@@ -38,12 +38,12 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.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/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
|
||||||
google.golang.org/grpc v1.79.1 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ 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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -57,8 +57,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.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.3/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=
|
||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
|||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
@@ -152,16 +152,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
|||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
@@ -172,15 +172,15 @@ 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
@@ -191,16 +191,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
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=
|
||||||
@@ -208,10 +208,10 @@ 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-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package appversion exposes build-time version information for the discovery service.
|
|
||||||
package appversion
|
package appversion
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,9 +14,8 @@ var (
|
|||||||
BuildDate string
|
BuildDate string
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create returns a version printer populated with the compile-time build metadata.
|
func Create() version.Printer {
|
||||||
func Create() version.Printer { //nolint:ireturn // factory returns interface by design
|
vi := version.Info{
|
||||||
info := version.Info{
|
|
||||||
Program: "Sendico Discovery Service",
|
Program: "Sendico Discovery Service",
|
||||||
Revision: Revision,
|
Revision: Revision,
|
||||||
Branch: Branch,
|
Branch: Branch,
|
||||||
@@ -25,6 +23,5 @@ func Create() version.Printer { //nolint:ireturn // factory returns interface by
|
|||||||
BuildDate: BuildDate,
|
BuildDate: BuildDate,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}
|
}
|
||||||
|
return vf.Create(&vi)
|
||||||
return vf.Create(&info)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package serverimp contains the concrete discovery server implementation.
|
|
||||||
package serverimp
|
package serverimp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,10 +10,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const defaultMetricsAddress = ":9405"
|
||||||
defaultMetricsAddress = ":9405"
|
|
||||||
defaultShutdownTimeoutSeconds = 15
|
|
||||||
)
|
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
|
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
|
||||||
@@ -28,28 +24,24 @@ type metricsConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type registryConfig struct {
|
type registryConfig struct {
|
||||||
KVTTLSeconds *int `yaml:"kv_ttl_seconds"` //nolint:tagliatelle // matches config file format
|
KVTTLSeconds *int `yaml:"kv_ttl_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Imp) loadConfig() (*config, error) {
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
data, err := os.ReadFile(i.file)
|
data, err := os.ReadFile(i.file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
return nil, err //nolint:wrapcheck
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &config{}
|
cfg := &config{}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
err = yaml.Unmarshal(data, cfg)
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
return nil, err //nolint:wrapcheck
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Runtime == nil {
|
if cfg.Runtime == nil {
|
||||||
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: defaultShutdownTimeoutSeconds}
|
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" {
|
if cfg.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" {
|
||||||
|
|||||||
@@ -14,51 +14,43 @@ import (
|
|||||||
|
|
||||||
func (i *Imp) startDiscovery(cfg *config) error {
|
func (i *Imp) startDiscovery(cfg *config) error {
|
||||||
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
|
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
|
||||||
//nolint:wrapcheck
|
|
||||||
return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging")
|
return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging")
|
||||||
}
|
}
|
||||||
|
|
||||||
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
|
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err //nolint:wrapcheck
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
||||||
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
|
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
|
||||||
|
|
||||||
registry := discovery.NewRegistry()
|
registry := discovery.NewRegistry()
|
||||||
|
|
||||||
var registryOpts []discovery.RegistryOption
|
var registryOpts []discovery.RegistryOption
|
||||||
|
|
||||||
if cfg.Registry != nil && cfg.Registry.KVTTLSeconds != nil {
|
if cfg.Registry != nil && cfg.Registry.KVTTLSeconds != nil {
|
||||||
ttlSeconds := *cfg.Registry.KVTTLSeconds
|
ttlSeconds := *cfg.Registry.KVTTLSeconds
|
||||||
if ttlSeconds < 0 {
|
if ttlSeconds < 0 {
|
||||||
i.logger.Warn("Discovery registry TTL is negative, disabling TTL", zap.Int("ttl_seconds", ttlSeconds))
|
i.logger.Warn("Discovery registry TTL is negative, disabling TTL", zap.Int("ttl_seconds", ttlSeconds))
|
||||||
ttlSeconds = 0
|
ttlSeconds = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
registryOpts = append(registryOpts, discovery.WithRegistryKVTTL(time.Duration(ttlSeconds)*time.Second))
|
registryOpts = append(registryOpts, discovery.WithRegistryKVTTL(time.Duration(ttlSeconds)*time.Second))
|
||||||
}
|
}
|
||||||
|
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery), registryOpts...)
|
||||||
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, mservice.Discovery, registryOpts...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err //nolint:wrapcheck
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.Start()
|
svc.Start()
|
||||||
i.registrySvc = svc
|
i.registrySvc = svc
|
||||||
|
|
||||||
announce := discovery.Announcement{
|
announce := discovery.Announcement{
|
||||||
Service: mservice.Discovery,
|
Service: "DISCOVERY",
|
||||||
InstanceID: discovery.InstanceID(),
|
InstanceID: discovery.InstanceID(),
|
||||||
Operations: []string{discovery.OperationDiscoveryLookup},
|
Operations: []string{"discovery.lookup"},
|
||||||
Version: appversion.Create().Short(),
|
Version: appversion.Create().Short(),
|
||||||
}
|
}
|
||||||
i.announcer = discovery.NewAnnouncer(i.logger, producer, mservice.Discovery, announce)
|
i.announcer = discovery.NewAnnouncer(i.logger, producer, string(mservice.Discovery), announce)
|
||||||
i.announcer.Start()
|
i.announcer.Start()
|
||||||
|
|
||||||
i.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
i.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,12 +58,10 @@ func (i *Imp) stopDiscovery() {
|
|||||||
if i == nil {
|
if i == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if i.announcer != nil {
|
if i.announcer != nil {
|
||||||
i.announcer.Stop()
|
i.announcer.Stop()
|
||||||
i.announcer = nil
|
i.announcer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if i.registrySvc != nil {
|
if i.registrySvc != nil {
|
||||||
i.registrySvc.Stop()
|
i.registrySvc.Stop()
|
||||||
i.registrySvc = nil
|
i.registrySvc = nil
|
||||||
|
|||||||
@@ -15,30 +15,22 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const readHeaderTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
func (i *Imp) startMetrics(cfg *metricsConfig) {
|
func (i *Imp) startMetrics(cfg *metricsConfig) {
|
||||||
if i == nil {
|
if i == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
address := ""
|
address := ""
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
address = strings.TrimSpace(cfg.Address)
|
address = strings.TrimSpace(cfg.Address)
|
||||||
}
|
}
|
||||||
|
|
||||||
if address == "" {
|
if address == "" {
|
||||||
i.logger.Info("Metrics endpoint disabled")
|
i.logger.Info("Metrics endpoint disabled")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lc := net.ListenConfig{}
|
listener, err := net.Listen("tcp", address)
|
||||||
|
|
||||||
listener, err := lc.Listen(context.Background(), "tcp", address)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err))
|
i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +38,7 @@ func (i *Imp) startMetrics(cfg *metricsConfig) {
|
|||||||
router.Handle("/metrics", promhttp.Handler())
|
router.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
var healthRouter routers.Health
|
var healthRouter routers.Health
|
||||||
|
if hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, ""); err != nil {
|
||||||
hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, "")
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Warn("Failed to initialise health router", zap.Error(err))
|
i.logger.Warn("Failed to initialise health router", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
hr.SetStatus(health.SSStarting)
|
hr.SetStatus(health.SSStarting)
|
||||||
@@ -59,16 +49,13 @@ func (i *Imp) startMetrics(cfg *metricsConfig) {
|
|||||||
i.metricsSrv = &http.Server{
|
i.metricsSrv = &http.Server{
|
||||||
Addr: address,
|
Addr: address,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
ReadHeaderTimeout: readHeaderTimeout,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
|
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
|
||||||
|
if err := i.metricsSrv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
serveErr := i.metricsSrv.Serve(listener)
|
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
|
||||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
|
||||||
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(serveErr))
|
|
||||||
|
|
||||||
if healthRouter != nil {
|
if healthRouter != nil {
|
||||||
healthRouter.SetStatus(health.SSTerminating)
|
healthRouter.SetStatus(health.SSTerminating)
|
||||||
}
|
}
|
||||||
@@ -82,18 +69,14 @@ func (i *Imp) shutdownMetrics(ctx context.Context) {
|
|||||||
i.metricsHealth.Finish()
|
i.metricsHealth.Finish()
|
||||||
i.metricsHealth = nil
|
i.metricsHealth = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if i.metricsSrv == nil {
|
if i.metricsSrv == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := i.metricsSrv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
err := i.metricsSrv.Shutdown(ctx)
|
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
i.logger.Warn("Failed to stop metrics server", zap.Error(err))
|
i.logger.Warn("Failed to stop metrics server", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
i.logger.Info("Metrics server stopped")
|
i.logger.Info("Metrics server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
i.metricsSrv = nil
|
i.metricsSrv = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +84,5 @@ func (i *Imp) setMetricsStatus(status health.ServiceStatus) {
|
|||||||
if i == nil || i.metricsHealth == nil {
|
if i == nil || i.metricsHealth == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
i.metricsHealth.SetStatus(status)
|
i.metricsHealth.SetStatus(status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultShutdownTimeout = 15 * time.Second
|
|
||||||
|
|
||||||
// Create returns a new server implementation configured with the given logger, config file, and debug flag.
|
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
return &Imp{
|
return &Imp{
|
||||||
logger: logger.Named("server"),
|
logger: logger.Named("server"),
|
||||||
@@ -21,7 +18,6 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start loads configuration, starts metrics and the discovery registry, then blocks until stopped.
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
i.initStopChannels()
|
i.initStopChannels()
|
||||||
defer i.closeDone()
|
defer i.closeDone()
|
||||||
@@ -32,37 +28,29 @@ func (i *Imp) Start() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.config = cfg
|
i.config = cfg
|
||||||
|
|
||||||
messagingDriver := "none"
|
messagingDriver := "none"
|
||||||
if cfg.Messaging != nil {
|
if cfg.Messaging != nil {
|
||||||
messagingDriver = string(cfg.Messaging.Driver)
|
messagingDriver = string(cfg.Messaging.Driver)
|
||||||
}
|
}
|
||||||
|
|
||||||
metricsAddress := ""
|
metricsAddress := ""
|
||||||
if cfg.Metrics != nil {
|
if cfg.Metrics != nil {
|
||||||
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
|
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metricsAddress == "" {
|
if metricsAddress == "" {
|
||||||
metricsAddress = "disabled"
|
metricsAddress = "disabled"
|
||||||
}
|
}
|
||||||
|
i.logger.Info("Discovery config loaded", zap.String("messaging_driver", messagingDriver), zap.String("metrics_address", metricsAddress))
|
||||||
i.logger.Info("Discovery config loaded",
|
|
||||||
zap.String("messaging_driver", messagingDriver),
|
|
||||||
zap.String("metrics_address", metricsAddress))
|
|
||||||
|
|
||||||
i.startMetrics(cfg.Metrics)
|
i.startMetrics(cfg.Metrics)
|
||||||
|
|
||||||
err = i.startDiscovery(cfg)
|
if err := i.startDiscovery(cfg); err != nil {
|
||||||
if err != nil {
|
|
||||||
i.stopDiscovery()
|
i.stopDiscovery()
|
||||||
i.setMetricsStatus(health.SSTerminating)
|
i.setMetricsStatus(health.SSTerminating)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout())
|
ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout())
|
||||||
i.shutdownMetrics(ctx)
|
i.shutdownMetrics(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +59,9 @@ func (i *Imp) Start() error {
|
|||||||
|
|
||||||
<-i.stopCh
|
<-i.stopCh
|
||||||
i.logger.Info("Discovery service stop signal received")
|
i.logger.Info("Discovery service stop signal received")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown gracefully stops the discovery service and its metrics server.
|
|
||||||
func (i *Imp) Shutdown() {
|
func (i *Imp) Shutdown() {
|
||||||
timeout := i.shutdownTimeout()
|
timeout := i.shutdownTimeout()
|
||||||
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
|
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
|
||||||
@@ -86,7 +72,6 @@ func (i *Imp) Shutdown() {
|
|||||||
if i.doneCh != nil {
|
if i.doneCh != nil {
|
||||||
<-i.doneCh
|
<-i.doneCh
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
i.shutdownMetrics(ctx)
|
i.shutdownMetrics(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -98,7 +83,6 @@ func (i *Imp) initStopChannels() {
|
|||||||
if i.stopCh == nil {
|
if i.stopCh == nil {
|
||||||
i.stopCh = make(chan struct{})
|
i.stopCh = make(chan struct{})
|
||||||
}
|
}
|
||||||
|
|
||||||
if i.doneCh == nil {
|
if i.doneCh == nil {
|
||||||
i.doneCh = make(chan struct{})
|
i.doneCh = make(chan struct{})
|
||||||
}
|
}
|
||||||
@@ -124,6 +108,5 @@ func (i *Imp) shutdownTimeout() time.Duration {
|
|||||||
if i.config != nil && i.config.Runtime != nil {
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
return i.config.Runtime.ShutdownTimeout()
|
return i.config.Runtime.ShutdownTimeout()
|
||||||
}
|
}
|
||||||
|
return 15 * time.Second
|
||||||
return defaultShutdownTimeout
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Imp is the concrete implementation of the discovery server application.
|
|
||||||
type Imp struct {
|
type Imp struct {
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
file string
|
file string
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package server provides the discovery service application factory.
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,9 +6,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/server"
|
"github.com/tech/sendico/pkg/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create initialises and returns a new discovery server application.
|
|
||||||
//
|
|
||||||
//nolint:ireturn // factory returns interface by design
|
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
return serverimp.Create(logger, file, debug) //nolint:wrapcheck
|
return serverimp.Create(logger, file, debug)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package main is the entry point for the discovery service.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,9 +8,8 @@ import (
|
|||||||
smain "github.com/tech/sendico/pkg/server/main"
|
smain "github.com/tech/sendico/pkg/server/main"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:ireturn // factory returns interface by design
|
|
||||||
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
return si.Create(logger, file, debug) //nolint:wrapcheck
|
return si.Create(logger, file, debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,84 +0,0 @@
|
|||||||
package srequest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPaymentIntentValidate_AcceptsBaseIntentWithoutFX(t *testing.T) {
|
|
||||||
intent := mustValidBaseIntent(t)
|
|
||||||
|
|
||||||
if err := intent.Validate(); err != nil {
|
|
||||||
t.Fatalf("unexpected validation error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentIntentValidate_RejectsFXWithoutPair(t *testing.T) {
|
|
||||||
intent := mustValidBaseIntent(t)
|
|
||||||
intent.FX = &FXIntent{
|
|
||||||
Side: FXSideSellBaseBuyQuote,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := intent.Validate(); err == nil {
|
|
||||||
t.Fatalf("expected validation error for missing fx pair")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentIntentValidate_RejectsInvalidFXSide(t *testing.T) {
|
|
||||||
intent := mustValidBaseIntent(t)
|
|
||||||
intent.FX = &FXIntent{
|
|
||||||
Pair: &CurrencyPair{
|
|
||||||
Base: "USDT",
|
|
||||||
Quote: "RUB",
|
|
||||||
},
|
|
||||||
Side: FXSide("wrong"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := intent.Validate(); err == nil {
|
|
||||||
t.Fatalf("expected validation error for invalid fx side")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentIntentValidate_AcceptsValidFX(t *testing.T) {
|
|
||||||
intent := mustValidBaseIntent(t)
|
|
||||||
intent.FX = &FXIntent{
|
|
||||||
Pair: &CurrencyPair{
|
|
||||||
Base: "USDT",
|
|
||||||
Quote: "RUB",
|
|
||||||
},
|
|
||||||
Side: FXSideSellBaseBuyQuote,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := intent.Validate(); err != nil {
|
|
||||||
t.Fatalf("unexpected validation error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustValidBaseIntent(t *testing.T) *PaymentIntent {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
source, err := NewManagedWalletEndpointDTO(ManagedWalletEndpoint{ManagedWalletRef: "mw-src"}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build source endpoint: %v", err)
|
|
||||||
}
|
|
||||||
destination, err := NewCardEndpointDTO(CardEndpoint{
|
|
||||||
Pan: "2200700142860161",
|
|
||||||
FirstName: "Jane",
|
|
||||||
LastName: "Doe",
|
|
||||||
ExpMonth: 2,
|
|
||||||
ExpYear: 2030,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build destination endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PaymentIntent{
|
|
||||||
Kind: PaymentKindPayout,
|
|
||||||
Source: &source,
|
|
||||||
Destination: &destination,
|
|
||||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
|
||||||
SettlementMode: SettlementModeFixSource,
|
|
||||||
FeeTreatment: FeeTreatmentAddToSource,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package srequest
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateQuoteIdempotency(t *testing.T) {
|
|
||||||
t.Run("non-preview requires idempotency key", func(t *testing.T) {
|
|
||||||
if err := validateQuoteIdempotency(false, ""); err == nil {
|
|
||||||
t.Fatalf("expected error for empty idempotency key")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preview rejects idempotency key", func(t *testing.T) {
|
|
||||||
if err := validateQuoteIdempotency(true, "idem-1"); err == nil {
|
|
||||||
t.Fatalf("expected error when preview request has idempotency key")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preview accepts empty idempotency key", func(t *testing.T) {
|
|
||||||
if err := validateQuoteIdempotency(true, ""); err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("non-preview accepts idempotency key", func(t *testing.T) {
|
|
||||||
if err := validateQuoteIdempotency(false, "idem-1"); err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitiatePaymentsValidate(t *testing.T) {
|
|
||||||
t.Run("accepts quoteRef", func(t *testing.T) {
|
|
||||||
req := &InitiatePayments{
|
|
||||||
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
|
|
||||||
QuoteRef: " quote-1 ",
|
|
||||||
}
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if got, want := req.QuoteRef, "quote-1"; got != want {
|
|
||||||
t.Fatalf("quoteRef mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects missing quoteRef", func(t *testing.T) {
|
|
||||||
req := &InitiatePayments{
|
|
||||||
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
|
|
||||||
}
|
|
||||||
if err := req.Validate(); err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package sresponse
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type callbackWriteResponse struct {
|
|
||||||
AccessToken TokenData `json:"accessToken"`
|
|
||||||
Callbacks []model.Callback `json:"callbacks"`
|
|
||||||
GeneratedSigningSecret string `json:"generatedSigningSecret,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Callback(
|
|
||||||
logger mlogger.Logger,
|
|
||||||
callback *model.Callback,
|
|
||||||
accessToken *TokenData,
|
|
||||||
generatedSecret string,
|
|
||||||
created bool,
|
|
||||||
) http.HandlerFunc {
|
|
||||||
resp := callbackWriteResponse{
|
|
||||||
AccessToken: *accessToken,
|
|
||||||
Callbacks: []model.Callback{*callback},
|
|
||||||
GeneratedSigningSecret: generatedSecret,
|
|
||||||
}
|
|
||||||
if created {
|
|
||||||
return response.Created(logger, resp)
|
|
||||||
}
|
|
||||||
return response.Ok(logger, resp)
|
|
||||||
}
|
|
||||||
@@ -1,562 +0,0 @@
|
|||||||
package sresponse
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
|
||||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
|
||||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
|
||||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
|
||||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
|
||||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FeeLine struct {
|
|
||||||
LedgerAccountRef string `json:"ledgerAccountRef,omitempty"`
|
|
||||||
Amount *paymenttypes.Money `json:"amount,omitempty"`
|
|
||||||
LineType string `json:"lineType,omitempty"`
|
|
||||||
Side string `json:"side,omitempty"`
|
|
||||||
Meta map[string]string `json:"meta,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FxQuote struct {
|
|
||||||
QuoteRef string `json:"quoteRef,omitempty"`
|
|
||||||
BaseCurrency string `json:"baseCurrency,omitempty"`
|
|
||||||
QuoteCurrency string `json:"quoteCurrency,omitempty"`
|
|
||||||
Side string `json:"side,omitempty"`
|
|
||||||
Price string `json:"price,omitempty"`
|
|
||||||
BaseAmount *paymenttypes.Money `json:"baseAmount,omitempty"`
|
|
||||||
QuoteAmount *paymenttypes.Money `json:"quoteAmount,omitempty"`
|
|
||||||
ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"`
|
|
||||||
PricedAtUnixMs int64 `json:"pricedAtUnixMs,omitempty"`
|
|
||||||
Provider string `json:"provider,omitempty"`
|
|
||||||
RateRef string `json:"rateRef,omitempty"`
|
|
||||||
Firm bool `json:"firm,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentQuote struct {
|
|
||||||
QuoteRef string `json:"quoteRef,omitempty"`
|
|
||||||
IntentRef string `json:"intentRef,omitempty"`
|
|
||||||
Amounts *QuoteAmounts `json:"amounts,omitempty"`
|
|
||||||
Fees *QuoteFees `json:"fees,omitempty"`
|
|
||||||
FxQuote *FxQuote `json:"fxQuote,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuoteAmounts struct {
|
|
||||||
SourcePrincipal *paymenttypes.Money `json:"sourcePrincipal,omitempty"`
|
|
||||||
SourceDebitTotal *paymenttypes.Money `json:"sourceDebitTotal,omitempty"`
|
|
||||||
DestinationSettlement *paymenttypes.Money `json:"destinationSettlement,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuoteFees struct {
|
|
||||||
Lines []FeeLine `json:"lines,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentQuotes struct {
|
|
||||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
||||||
QuoteRef string `json:"quoteRef,omitempty"`
|
|
||||||
Items []PaymentQuote `json:"items,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Payment struct {
|
|
||||||
PaymentRef string `json:"paymentRef,omitempty"`
|
|
||||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
||||||
State string `json:"state,omitempty"`
|
|
||||||
Comment string `json:"comment,omitempty"`
|
|
||||||
FailureCode string `json:"failureCode,omitempty"`
|
|
||||||
FailureReason string `json:"failureReason,omitempty"`
|
|
||||||
Operations []PaymentOperation `json:"operations,omitempty"`
|
|
||||||
LastQuote *PaymentQuote `json:"lastQuote,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
||||||
Meta map[string]string `json:"meta,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentOperation struct {
|
|
||||||
StepRef string `json:"stepRef,omitempty"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
State string `json:"state,omitempty"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
Amount *paymenttypes.Money `json:"amount,omitempty"`
|
|
||||||
ConvertedAmount *paymenttypes.Money `json:"convertedAmount,omitempty"`
|
|
||||||
OperationRef string `json:"operationRef,omitempty"`
|
|
||||||
Gateway string `json:"gateway,omitempty"`
|
|
||||||
FailureCode string `json:"failureCode,omitempty"`
|
|
||||||
FailureReason string `json:"failureReason,omitempty"`
|
|
||||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
|
||||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type paymentQuoteResponse struct {
|
|
||||||
authResponse `json:",inline"`
|
|
||||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
||||||
Quote *PaymentQuote `json:"quote"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type paymentQuotesResponse struct {
|
|
||||||
authResponse `json:",inline"`
|
|
||||||
Quote *PaymentQuotes `json:"quote"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type paymentsResponse struct {
|
|
||||||
authResponse `json:",inline"`
|
|
||||||
Payments []Payment `json:"payments"`
|
|
||||||
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type paymentResponse struct {
|
|
||||||
authResponse `json:",inline"`
|
|
||||||
Payment *Payment `json:"payment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentQuote wraps a payment quote with refreshed access token.
|
|
||||||
func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *quotationv2.PaymentQuote, token *TokenData) http.HandlerFunc {
|
|
||||||
return response.Ok(logger, paymentQuoteResponse{
|
|
||||||
Quote: toPaymentQuote(quote),
|
|
||||||
IdempotencyKey: idempotencyKey,
|
|
||||||
authResponse: authResponse{AccessToken: *token},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentQuotes wraps batch quotes with refreshed access token.
|
|
||||||
func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv2.QuotePaymentsResponse, token *TokenData) http.HandlerFunc {
|
|
||||||
return response.Ok(logger, paymentQuotesResponse{
|
|
||||||
Quote: toPaymentQuotes(resp),
|
|
||||||
authResponse: authResponse{AccessToken: *token},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payments wraps a list of payments with refreshed access token.
|
|
||||||
func PaymentsResponse(logger mlogger.Logger, payments []*orchestrationv2.Payment, token *TokenData) http.HandlerFunc {
|
|
||||||
return response.Ok(logger, paymentsResponse{
|
|
||||||
Payments: toPayments(payments),
|
|
||||||
authResponse: authResponse{AccessToken: *token},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentsList wraps a list of payments with refreshed access token and pagination data.
|
|
||||||
func PaymentsListResponse(logger mlogger.Logger, resp *orchestrationv2.ListPaymentsResponse, token *TokenData) http.HandlerFunc {
|
|
||||||
return response.Ok(logger, paymentsResponse{
|
|
||||||
Payments: toPayments(resp.GetPayments()),
|
|
||||||
Page: resp.GetPage(),
|
|
||||||
authResponse: authResponse{AccessToken: *token},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payment wraps a payment with refreshed access token.
|
|
||||||
func PaymentResponse(logger mlogger.Logger, payment *orchestrationv2.Payment, token *TokenData) http.HandlerFunc {
|
|
||||||
return response.Ok(logger, paymentResponse{
|
|
||||||
Payment: toPayment(payment),
|
|
||||||
authResponse: authResponse{AccessToken: *token},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine {
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]FeeLine, 0, len(lines))
|
|
||||||
for _, line := range lines {
|
|
||||||
if line == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, FeeLine{
|
|
||||||
LedgerAccountRef: line.GetLedgerAccountRef(),
|
|
||||||
Amount: toMoney(line.GetMoney()),
|
|
||||||
LineType: enumJSONName(line.GetLineType().String()),
|
|
||||||
Side: enumJSONName(line.GetSide().String()),
|
|
||||||
Meta: line.GetMeta(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func toFxQuote(q *oraclev1.Quote) *FxQuote {
|
|
||||||
if q == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pair := q.GetPair()
|
|
||||||
pricedAtUnixMs := int64(0)
|
|
||||||
if ts := q.GetPricedAt(); ts != nil {
|
|
||||||
pricedAtUnixMs = ts.AsTime().UnixMilli()
|
|
||||||
}
|
|
||||||
base := ""
|
|
||||||
quote := ""
|
|
||||||
if pair != nil {
|
|
||||||
base = pair.GetBase()
|
|
||||||
quote = pair.GetQuote()
|
|
||||||
}
|
|
||||||
return &FxQuote{
|
|
||||||
QuoteRef: q.GetQuoteRef(),
|
|
||||||
BaseCurrency: base,
|
|
||||||
QuoteCurrency: quote,
|
|
||||||
Side: enumJSONName(q.GetSide().String()),
|
|
||||||
Price: q.GetPrice().GetValue(),
|
|
||||||
BaseAmount: toMoney(q.GetBaseAmount()),
|
|
||||||
QuoteAmount: toMoney(q.GetQuoteAmount()),
|
|
||||||
ExpiresAtUnixMs: q.GetExpiresAtUnixMs(),
|
|
||||||
PricedAtUnixMs: pricedAtUnixMs,
|
|
||||||
Provider: q.GetProvider(),
|
|
||||||
RateRef: q.GetRateRef(),
|
|
||||||
Firm: q.GetFirm(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote {
|
|
||||||
if q == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
amounts := toQuoteAmounts(q)
|
|
||||||
fees := toQuoteFees(q.GetFeeLines())
|
|
||||||
return &PaymentQuote{
|
|
||||||
QuoteRef: q.GetQuoteRef(),
|
|
||||||
IntentRef: strings.TrimSpace(q.GetIntentRef()),
|
|
||||||
Amounts: amounts,
|
|
||||||
Fees: fees,
|
|
||||||
FxQuote: toFxQuote(q.GetFxQuote()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPaymentQuotes(resp *quotationv2.QuotePaymentsResponse) *PaymentQuotes {
|
|
||||||
if resp == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
items := make([]PaymentQuote, 0, len(resp.GetQuotes()))
|
|
||||||
for _, quote := range resp.GetQuotes() {
|
|
||||||
if dto := toPaymentQuote(quote); dto != nil {
|
|
||||||
items = append(items, *dto)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = nil
|
|
||||||
}
|
|
||||||
return &PaymentQuotes{
|
|
||||||
IdempotencyKey: resp.GetIdempotencyKey(),
|
|
||||||
QuoteRef: resp.GetQuoteRef(),
|
|
||||||
Items: items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toQuoteAmounts(q *quotationv2.PaymentQuote) *QuoteAmounts {
|
|
||||||
if q == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
amounts := &QuoteAmounts{
|
|
||||||
SourcePrincipal: toMoney(q.GetTransferPrincipalAmount()),
|
|
||||||
SourceDebitTotal: toMoney(q.GetPayerTotalDebitAmount()),
|
|
||||||
DestinationSettlement: toMoney(q.GetDestinationAmount()),
|
|
||||||
}
|
|
||||||
if amounts.SourcePrincipal == nil && amounts.SourceDebitTotal == nil && amounts.DestinationSettlement == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return amounts
|
|
||||||
}
|
|
||||||
|
|
||||||
func toQuoteFees(lines []*feesv1.DerivedPostingLine) *QuoteFees {
|
|
||||||
feeLines := toFeeLines(lines)
|
|
||||||
if len(feeLines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &QuoteFees{Lines: feeLines}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPayments(items []*orchestrationv2.Payment) []Payment {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]Payment, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
if p := toPayment(item); p != nil {
|
|
||||||
result = append(result, *p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPayment(p *orchestrationv2.Payment) *Payment {
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
|
|
||||||
failureCode, failureReason := firstFailure(operations)
|
|
||||||
return &Payment{
|
|
||||||
PaymentRef: p.GetPaymentRef(),
|
|
||||||
State: enumJSONName(p.GetState().String()),
|
|
||||||
Comment: strings.TrimSpace(p.GetIntentSnapshot().GetComment()),
|
|
||||||
FailureCode: failureCode,
|
|
||||||
FailureReason: failureReason,
|
|
||||||
Operations: operations,
|
|
||||||
LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
|
|
||||||
CreatedAt: timestampAsTime(p.GetCreatedAt()),
|
|
||||||
Meta: paymentMeta(p),
|
|
||||||
IdempotencyKey: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstFailure(operations []PaymentOperation) (string, string) {
|
|
||||||
for _, op := range operations {
|
|
||||||
if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(op.FailureCode), strings.TrimSpace(op.FailureReason)
|
|
||||||
}
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) []PaymentOperation {
|
|
||||||
if len(steps) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ops := make([]PaymentOperation, 0, len(steps))
|
|
||||||
for _, step := range steps {
|
|
||||||
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ops = append(ops, toPaymentOperation(step, quote))
|
|
||||||
}
|
|
||||||
if len(ops) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ops
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPaymentOperation(step *orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) PaymentOperation {
|
|
||||||
operationRef, gateway := operationRefAndGateway(step.GetStepCode(), step.GetRefs())
|
|
||||||
amount, convertedAmount := operationAmounts(step.GetStepCode(), quote)
|
|
||||||
op := PaymentOperation{
|
|
||||||
StepRef: step.GetStepRef(),
|
|
||||||
Code: step.GetStepCode(),
|
|
||||||
State: enumJSONName(step.GetState().String()),
|
|
||||||
Label: strings.TrimSpace(step.GetUserLabel()),
|
|
||||||
Amount: amount,
|
|
||||||
ConvertedAmount: convertedAmount,
|
|
||||||
OperationRef: operationRef,
|
|
||||||
Gateway: string(gateway),
|
|
||||||
StartedAt: timestampAsTime(step.GetStartedAt()),
|
|
||||||
CompletedAt: timestampAsTime(step.GetCompletedAt()),
|
|
||||||
}
|
|
||||||
failure := step.GetFailure()
|
|
||||||
if failure == nil {
|
|
||||||
return op
|
|
||||||
}
|
|
||||||
op.FailureCode = enumJSONName(failure.GetCategory().String())
|
|
||||||
op.FailureReason = strings.TrimSpace(failure.GetMessage())
|
|
||||||
if op.FailureReason == "" {
|
|
||||||
op.FailureReason = strings.TrimSpace(failure.GetCode())
|
|
||||||
}
|
|
||||||
return op
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationAmounts(stepCode string, quote *quotationv2.PaymentQuote) (*paymenttypes.Money, *paymenttypes.Money) {
|
|
||||||
if quote == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
operation := stepOperationToken(stepCode)
|
|
||||||
|
|
||||||
primary := firstValidMoney(
|
|
||||||
toMoney(quote.GetDestinationAmount()),
|
|
||||||
toMoney(quote.GetTransferPrincipalAmount()),
|
|
||||||
toMoney(quote.GetPayerTotalDebitAmount()),
|
|
||||||
)
|
|
||||||
if operation != "fx_convert" {
|
|
||||||
return primary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
base := firstValidMoney(
|
|
||||||
toMoney(quote.GetTransferPrincipalAmount()),
|
|
||||||
toMoney(quote.GetPayerTotalDebitAmount()),
|
|
||||||
toMoney(quote.GetFxQuote().GetBaseAmount()),
|
|
||||||
)
|
|
||||||
quoteAmount := firstValidMoney(
|
|
||||||
toMoney(quote.GetDestinationAmount()),
|
|
||||||
toMoney(quote.GetFxQuote().GetQuoteAmount()),
|
|
||||||
)
|
|
||||||
return base, quoteAmount
|
|
||||||
}
|
|
||||||
|
|
||||||
func stepOperationToken(stepCode string) string {
|
|
||||||
parts := strings.Split(strings.ToLower(strings.TrimSpace(stepCode)), ".")
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[len(parts)-1])
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstValidMoney(values ...*paymenttypes.Money) *paymenttypes.Money {
|
|
||||||
for _, value := range values {
|
|
||||||
if value == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(value.GetAmount()) == "" || strings.TrimSpace(value.GetCurrency()) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
externalRefKindOperation = "operation_ref"
|
|
||||||
)
|
|
||||||
|
|
||||||
func operationRefAndGateway(stepCode string, refs []*orchestrationv2.ExternalReference) (string, mservice.Type) {
|
|
||||||
var (
|
|
||||||
operationRef string
|
|
||||||
gateway mservice.Type
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, ref := range refs {
|
|
||||||
if ref == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
kind := strings.ToLower(strings.TrimSpace(ref.GetKind()))
|
|
||||||
value := strings.TrimSpace(ref.GetRef())
|
|
||||||
candidateGateway := inferGatewayType(ref.GetGatewayInstanceId(), ref.GetRail(), stepCode)
|
|
||||||
|
|
||||||
if kind == externalRefKindOperation && operationRef == "" && value != "" {
|
|
||||||
operationRef = value
|
|
||||||
}
|
|
||||||
if gateway == "" && candidateGateway != "" {
|
|
||||||
gateway = candidateGateway
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if gateway == "" {
|
|
||||||
gateway = inferGatewayType("", gatewayv1.Rail_RAIL_UNSPECIFIED, stepCode)
|
|
||||||
}
|
|
||||||
return operationRef, gateway
|
|
||||||
}
|
|
||||||
|
|
||||||
func inferGatewayType(gatewayInstanceID string, rail gatewayv1.Rail, stepCode string) mservice.Type {
|
|
||||||
if gateway := gatewayTypeFromInstanceID(gatewayInstanceID); gateway != "" {
|
|
||||||
return gateway
|
|
||||||
}
|
|
||||||
if gateway := gatewayTypeFromRail(rail); gateway != "" {
|
|
||||||
return gateway
|
|
||||||
}
|
|
||||||
return gatewayTypeFromStepCode(stepCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func gatewayTypeFromInstanceID(raw string) mservice.Type {
|
|
||||||
value := strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
if value == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
switch mservice.Type(value) {
|
|
||||||
case mservice.ChainGateway, mservice.TronGateway, mservice.MntxGateway, mservice.PaymentGateway, mservice.TgSettle, mservice.Ledger:
|
|
||||||
return mservice.Type(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.Contains(value, "ledger"):
|
|
||||||
return mservice.Ledger
|
|
||||||
case strings.Contains(value, "tgsettle"):
|
|
||||||
return mservice.TgSettle
|
|
||||||
case strings.Contains(value, "payment_gateway"),
|
|
||||||
strings.Contains(value, "settlement"),
|
|
||||||
strings.Contains(value, "onramp"),
|
|
||||||
strings.Contains(value, "offramp"):
|
|
||||||
return mservice.PaymentGateway
|
|
||||||
case strings.Contains(value, "mntx"), strings.Contains(value, "mcards"):
|
|
||||||
return mservice.MntxGateway
|
|
||||||
case strings.Contains(value, "tron"):
|
|
||||||
return mservice.TronGateway
|
|
||||||
case strings.Contains(value, "chain"), strings.Contains(value, "crypto"):
|
|
||||||
return mservice.ChainGateway
|
|
||||||
case strings.Contains(value, "card"):
|
|
||||||
return mservice.MntxGateway
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func gatewayTypeFromRail(rail gatewayv1.Rail) mservice.Type {
|
|
||||||
switch rail {
|
|
||||||
case gatewayv1.Rail_RAIL_LEDGER:
|
|
||||||
return mservice.Ledger
|
|
||||||
case gatewayv1.Rail_RAIL_CARD:
|
|
||||||
return mservice.MntxGateway
|
|
||||||
case gatewayv1.Rail_RAIL_SETTLEMENT, gatewayv1.Rail_RAIL_ONRAMP, gatewayv1.Rail_RAIL_OFFRAMP:
|
|
||||||
return mservice.PaymentGateway
|
|
||||||
case gatewayv1.Rail_RAIL_CRYPTO:
|
|
||||||
return mservice.ChainGateway
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func gatewayTypeFromStepCode(stepCode string) mservice.Type {
|
|
||||||
code := strings.ToLower(strings.TrimSpace(stepCode))
|
|
||||||
switch {
|
|
||||||
case strings.Contains(code, "ledger"):
|
|
||||||
return mservice.Ledger
|
|
||||||
case strings.Contains(code, "card_payout"), strings.Contains(code, ".card."):
|
|
||||||
return mservice.MntxGateway
|
|
||||||
case strings.Contains(code, "provider_settlement"),
|
|
||||||
strings.Contains(code, "settlement"),
|
|
||||||
strings.Contains(code, "fx_convert"),
|
|
||||||
strings.Contains(code, "onramp"),
|
|
||||||
strings.Contains(code, "offramp"):
|
|
||||||
return mservice.PaymentGateway
|
|
||||||
case strings.Contains(code, "crypto"), strings.Contains(code, "chain"):
|
|
||||||
return mservice.ChainGateway
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool {
|
|
||||||
switch visibility {
|
|
||||||
case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
|
|
||||||
orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
|
|
||||||
orchestrationv2.ReportVisibility_REPORT_VISIBILITY_AUDIT:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func paymentMeta(p *orchestrationv2.Payment) map[string]string {
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
meta := make(map[string]string)
|
|
||||||
if quotationRef := strings.TrimSpace(p.GetQuotationRef()); quotationRef != "" {
|
|
||||||
meta["quotationRef"] = quotationRef
|
|
||||||
}
|
|
||||||
if clientPaymentRef := strings.TrimSpace(p.GetClientPaymentRef()); clientPaymentRef != "" {
|
|
||||||
meta["clientPaymentRef"] = clientPaymentRef
|
|
||||||
}
|
|
||||||
if version := p.GetVersion(); version > 0 {
|
|
||||||
meta["version"] = strconv.FormatUint(version, 10)
|
|
||||||
}
|
|
||||||
if len(meta) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
func timestampAsTime(ts *timestamppb.Timestamp) time.Time {
|
|
||||||
if ts == nil {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
return ts.AsTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
func enumJSONName(value string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(value))
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
package sresponse
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
|
||||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
|
||||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
|
|
||||||
steps := []*orchestrationv2.StepExecution{
|
|
||||||
{
|
|
||||||
StepRef: "hidden",
|
|
||||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
StepRef: "user",
|
|
||||||
StepCode: "hop.4.card_payout.send",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING,
|
|
||||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
StepRef: "unspecified",
|
|
||||||
StepCode: "hop.4.card_payout.observe",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
|
||||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_UNSPECIFIED,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
StepRef: "backoffice",
|
|
||||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ops := toUserVisibleOperations(steps, nil)
|
|
||||||
if len(ops) != 2 {
|
|
||||||
t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
|
|
||||||
}
|
|
||||||
if got, want := ops[0].StepRef, "user"; got != want {
|
|
||||||
t.Fatalf("first operation step_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := ops[1].StepRef, "unspecified"; got != want {
|
|
||||||
t.Fatalf("second operation step_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentFailureUsesVisibleOperationsOnly(t *testing.T) {
|
|
||||||
dto := toPayment(&orchestrationv2.Payment{
|
|
||||||
PaymentRef: "pay-1",
|
|
||||||
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED,
|
|
||||||
StepExecutions: []*orchestrationv2.StepExecution{
|
|
||||||
{
|
|
||||||
StepRef: "hidden_failed",
|
|
||||||
StepCode: "edge.1_2.ledger.debit",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
|
|
||||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
|
|
||||||
Failure: &orchestrationv2.Failure{
|
|
||||||
Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER,
|
|
||||||
Message: "internal hold release failure",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
StepRef: "user_failed",
|
|
||||||
StepCode: "hop.4.card_payout.send",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
|
|
||||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_USER,
|
|
||||||
Failure: &orchestrationv2.Failure{
|
|
||||||
Category: sharedv1.PaymentFailureCode_FAILURE_CHAIN,
|
|
||||||
Message: "card declined",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if dto == nil {
|
|
||||||
t.Fatal("expected non-nil payment dto")
|
|
||||||
}
|
|
||||||
if got, want := dto.FailureCode, "failure_chain"; got != want {
|
|
||||||
t.Fatalf("failure_code mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := dto.FailureReason, "card declined"; got != want {
|
|
||||||
t.Fatalf("failure_reason mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if len(dto.Operations) != 1 {
|
|
||||||
t.Fatalf("operations count mismatch: got=%d want=1", len(dto.Operations))
|
|
||||||
}
|
|
||||||
if got, want := dto.Operations[0].StepRef, "user_failed"; got != want {
|
|
||||||
t.Fatalf("visible operation mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentIgnoresHiddenFailures(t *testing.T) {
|
|
||||||
dto := toPayment(&orchestrationv2.Payment{
|
|
||||||
PaymentRef: "pay-2",
|
|
||||||
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED,
|
|
||||||
StepExecutions: []*orchestrationv2.StepExecution{
|
|
||||||
{
|
|
||||||
StepRef: "hidden_failed",
|
|
||||||
StepCode: "edge.1_2.ledger.release",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED,
|
|
||||||
ReportVisibility: orchestrationv2.ReportVisibility_REPORT_VISIBILITY_BACKOFFICE,
|
|
||||||
Failure: &orchestrationv2.Failure{
|
|
||||||
Category: sharedv1.PaymentFailureCode_FAILURE_LEDGER,
|
|
||||||
Message: "backoffice only failure",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if dto == nil {
|
|
||||||
t.Fatal("expected non-nil payment dto")
|
|
||||||
}
|
|
||||||
if got := dto.FailureCode; got != "" {
|
|
||||||
t.Fatalf("expected empty failure_code, got=%q", got)
|
|
||||||
}
|
|
||||||
if got := dto.FailureReason; got != "" {
|
|
||||||
t.Fatalf("expected empty failure_reason, got=%q", got)
|
|
||||||
}
|
|
||||||
if len(dto.Operations) != 0 {
|
|
||||||
t.Fatalf("expected no visible operations, got=%d", len(dto.Operations))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentMapsIntentComment(t *testing.T) {
|
|
||||||
dto := toPayment(&orchestrationv2.Payment{
|
|
||||||
PaymentRef: "pay-3",
|
|
||||||
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED,
|
|
||||||
IntentSnapshot: "ationv2.QuoteIntent{
|
|
||||||
Comment: " invoice-7 ",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if dto == nil {
|
|
||||||
t.Fatal("expected non-nil payment dto")
|
|
||||||
}
|
|
||||||
if got, want := dto.Comment, "invoice-7"; got != want {
|
|
||||||
t.Fatalf("comment mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
|
|
||||||
dto := toPaymentQuote("ationv2.PaymentQuote{
|
|
||||||
QuoteRef: "quote-1",
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
})
|
|
||||||
if dto == nil {
|
|
||||||
t.Fatal("expected non-nil quote dto")
|
|
||||||
}
|
|
||||||
if got, want := dto.QuoteRef, "quote-1"; got != want {
|
|
||||||
t.Fatalf("quote_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := dto.IntentRef, "intent-1"; got != want {
|
|
||||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentOperation_MapsOperationRefAndGateway(t *testing.T) {
|
|
||||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
|
||||||
StepRef: "step-1",
|
|
||||||
StepCode: "hop.4.card_payout.send",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
|
||||||
Refs: []*orchestrationv2.ExternalReference{
|
|
||||||
{
|
|
||||||
Rail: gatewayv1.Rail_RAIL_CARD,
|
|
||||||
GatewayInstanceId: "mcards",
|
|
||||||
Kind: "operation_ref",
|
|
||||||
Ref: "op-123",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
if got, want := op.OperationRef, "op-123"; got != want {
|
|
||||||
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := op.Gateway, "mntx_gateway"; got != want {
|
|
||||||
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentOperation_InfersGatewayFromStepCode(t *testing.T) {
|
|
||||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
|
||||||
StepRef: "step-2",
|
|
||||||
StepCode: "edge.1_2.ledger.debit",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
if got := op.OperationRef; got != "" {
|
|
||||||
t.Fatalf("expected empty operation_ref, got=%q", got)
|
|
||||||
}
|
|
||||||
if got, want := op.Gateway, "ledger"; got != want {
|
|
||||||
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentOperation_DoesNotFallbackToCardPayoutRef(t *testing.T) {
|
|
||||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
|
||||||
StepRef: "step-3",
|
|
||||||
StepCode: "hop.4.card_payout.send",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
|
||||||
Refs: []*orchestrationv2.ExternalReference{
|
|
||||||
{
|
|
||||||
Rail: gatewayv1.Rail_RAIL_CARD,
|
|
||||||
GatewayInstanceId: "mcards",
|
|
||||||
Kind: "card_payout_ref",
|
|
||||||
Ref: "payout-123",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
if got := op.OperationRef; got != "" {
|
|
||||||
t.Fatalf("expected empty operation_ref, got=%q", got)
|
|
||||||
}
|
|
||||||
if got, want := op.Gateway, "mntx_gateway"; got != want {
|
|
||||||
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentOperation_MapsAmount(t *testing.T) {
|
|
||||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
|
||||||
StepRef: "step-4",
|
|
||||||
StepCode: "hop.4.card_payout.send",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
|
||||||
}, "ationv2.PaymentQuote{
|
|
||||||
TransferPrincipalAmount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
|
|
||||||
DestinationAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
|
|
||||||
})
|
|
||||||
|
|
||||||
if op.Amount == nil {
|
|
||||||
t.Fatal("expected amount to be mapped")
|
|
||||||
}
|
|
||||||
if got, want := op.Amount.Amount, "100.00"; got != want {
|
|
||||||
t.Fatalf("amount.value mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := op.Amount.Currency, "EUR"; got != want {
|
|
||||||
t.Fatalf("amount.currency mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got := op.ConvertedAmount; got != nil {
|
|
||||||
t.Fatalf("expected no converted_amount for non-fx operation, got=%+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentOperation_MapsFxTwoAmounts(t *testing.T) {
|
|
||||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
|
||||||
StepRef: "step-5",
|
|
||||||
StepCode: "hop.2.settlement.fx_convert",
|
|
||||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
|
||||||
}, "ationv2.PaymentQuote{
|
|
||||||
TransferPrincipalAmount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
|
|
||||||
DestinationAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
|
|
||||||
})
|
|
||||||
|
|
||||||
if op.Amount == nil {
|
|
||||||
t.Fatal("expected fx base amount to be mapped")
|
|
||||||
}
|
|
||||||
if got, want := op.Amount.Amount, "110.00"; got != want {
|
|
||||||
t.Fatalf("base amount.value mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := op.Amount.Currency, "USDT"; got != want {
|
|
||||||
t.Fatalf("base amount.currency mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if op.ConvertedAmount == nil {
|
|
||||||
t.Fatal("expected fx converted amount to be mapped")
|
|
||||||
}
|
|
||||||
if got, want := op.ConvertedAmount.Amount, "100.00"; got != want {
|
|
||||||
t.Fatalf("converted amount.value mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := op.ConvertedAmount.Currency, "EUR"; got != want {
|
|
||||||
t.Fatalf("converted amount.currency mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package callbacks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
"github.com/tech/sendico/server/interface/api"
|
|
||||||
"github.com/tech/sendico/server/internal/server/callbacksimp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Create(a api.API) (mservice.MicroService, error) {
|
|
||||||
return callbacksimp.CreateAPI(a)
|
|
||||||
}
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
package apiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/url"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/discovery"
|
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
eapi "github.com/tech/sendico/server/interface/api"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
discoveryBootstrapTimeout = 3 * time.Second
|
|
||||||
discoveryBootstrapSender = "server_bootstrap"
|
|
||||||
defaultClientDialTimeoutSecs = 5
|
|
||||||
defaultClientCallTimeoutSecs = 5
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ledgerDiscoveryServiceNames = []string{
|
|
||||||
"LEDGER",
|
|
||||||
string(mservice.Ledger),
|
|
||||||
}
|
|
||||||
paymentOrchestratorDiscoveryServiceNames = []string{
|
|
||||||
"PAYMENTS_ORCHESTRATOR",
|
|
||||||
string(mservice.PaymentOrchestrator),
|
|
||||||
}
|
|
||||||
paymentQuotationDiscoveryServiceNames = []string{
|
|
||||||
"PAYMENTS_QUOTATION",
|
|
||||||
"PAYMENTS_QUOTE",
|
|
||||||
"PAYMENT_QUOTATION",
|
|
||||||
"payment_quotation",
|
|
||||||
}
|
|
||||||
paymentMethodsDiscoveryServiceNames = []string{
|
|
||||||
"PAYMENTS_METHODS",
|
|
||||||
"PAYMENT_METHODS",
|
|
||||||
string(mservice.PaymentMethods),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type discoveryEndpoint struct {
|
|
||||||
address string
|
|
||||||
insecure bool
|
|
||||||
raw string
|
|
||||||
}
|
|
||||||
|
|
||||||
type serviceSelection struct {
|
|
||||||
service discovery.ServiceSummary
|
|
||||||
endpoint discoveryEndpoint
|
|
||||||
opMatch bool
|
|
||||||
nameRank int
|
|
||||||
}
|
|
||||||
|
|
||||||
type gatewaySelection struct {
|
|
||||||
gateway discovery.GatewaySummary
|
|
||||||
endpoint discoveryEndpoint
|
|
||||||
networkMatch bool
|
|
||||||
opMatch bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveServiceAddressesFromDiscovery looks up downstream service addresses once
|
|
||||||
// during startup and applies them to the runtime config.
|
|
||||||
func (a *APIImp) resolveServiceAddressesFromDiscovery() {
|
|
||||||
if a == nil || a.config == nil || a.config.Mw == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msgCfg := a.config.Mw.Messaging
|
|
||||||
if msgCfg.Driver == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger := a.logger.Named("discovery_bootstrap")
|
|
||||||
broker, err := msg.CreateMessagingBroker(logger.Named("bus"), &msgCfg)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("Failed to create discovery bootstrap broker", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := discovery.NewClient(logger, broker, nil, discoveryBootstrapSender)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("Failed to create discovery bootstrap client", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), discoveryBootstrapTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
lookup, err := client.Lookup(ctx)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("Failed to fetch discovery registry during startup", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.resolveChainGatewayAddress(lookup.Gateways)
|
|
||||||
orchestratorFound, orchestratorEndpoint := a.resolvePaymentOrchestratorAddress(lookup.Services)
|
|
||||||
a.resolveLedgerAddress(lookup.Services)
|
|
||||||
a.resolvePaymentQuotationAddress(lookup.Services, orchestratorFound, orchestratorEndpoint)
|
|
||||||
a.resolvePaymentMethodsAddress(lookup.Services)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *APIImp) resolveChainGatewayAddress(gateways []discovery.GatewaySummary) {
|
|
||||||
cfg := a.config.ChainGateway
|
|
||||||
if cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint, selected, ok := selectGatewayEndpoint(
|
|
||||||
gateways,
|
|
||||||
cfg.DefaultAsset.Chain,
|
|
||||||
[]string{discovery.OperationBalanceRead},
|
|
||||||
)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Address = endpoint.address
|
|
||||||
cfg.Insecure = endpoint.insecure
|
|
||||||
ensureTimeoutsChainGateway(cfg)
|
|
||||||
|
|
||||||
a.logger.Info("Resolved chain gateway address from discovery",
|
|
||||||
zap.String("rail", selected.Rail),
|
|
||||||
zap.String("gateway_id", selected.ID),
|
|
||||||
zap.String("network", selected.Network),
|
|
||||||
zap.String("invoke_uri", endpoint.raw),
|
|
||||||
zap.String("address", endpoint.address),
|
|
||||||
zap.Bool("insecure", endpoint.insecure))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *APIImp) resolveLedgerAddress(services []discovery.ServiceSummary) {
|
|
||||||
endpoint, selected, ok := selectServiceEndpoint(
|
|
||||||
services,
|
|
||||||
ledgerDiscoveryServiceNames,
|
|
||||||
discovery.LedgerServiceOperations(),
|
|
||||||
)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := ensureLedgerConfig(a.config)
|
|
||||||
cfg.Address = endpoint.address
|
|
||||||
cfg.Insecure = endpoint.insecure
|
|
||||||
ensureTimeoutsLedger(cfg)
|
|
||||||
|
|
||||||
a.logger.Info("Resolved ledger address from discovery",
|
|
||||||
zap.String("service", selected.Service),
|
|
||||||
zap.String("service_id", selected.ID),
|
|
||||||
zap.String("instance_id", selected.InstanceID),
|
|
||||||
zap.String("invoke_uri", endpoint.raw),
|
|
||||||
zap.String("address", endpoint.address),
|
|
||||||
zap.Bool("insecure", endpoint.insecure))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *APIImp) resolvePaymentOrchestratorAddress(services []discovery.ServiceSummary) (bool, discoveryEndpoint) {
|
|
||||||
endpoint, selected, ok := selectServiceEndpoint(
|
|
||||||
services,
|
|
||||||
paymentOrchestratorDiscoveryServiceNames,
|
|
||||||
[]string{discovery.OperationPaymentInitiate},
|
|
||||||
)
|
|
||||||
if !ok {
|
|
||||||
return false, discoveryEndpoint{}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := ensurePaymentOrchestratorConfig(a.config)
|
|
||||||
cfg.Address = endpoint.address
|
|
||||||
cfg.Insecure = endpoint.insecure
|
|
||||||
ensureTimeoutsPayment(cfg)
|
|
||||||
|
|
||||||
a.logger.Info("Resolved payment orchestrator address from discovery",
|
|
||||||
zap.String("service", selected.Service),
|
|
||||||
zap.String("service_id", selected.ID),
|
|
||||||
zap.String("instance_id", selected.InstanceID),
|
|
||||||
zap.String("invoke_uri", endpoint.raw),
|
|
||||||
zap.String("address", endpoint.address),
|
|
||||||
zap.Bool("insecure", endpoint.insecure))
|
|
||||||
|
|
||||||
return true, endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *APIImp) resolvePaymentQuotationAddress(services []discovery.ServiceSummary, orchestratorFound bool, orchestratorEndpoint discoveryEndpoint) {
|
|
||||||
endpoint, selected, ok := selectServiceEndpoint(
|
|
||||||
services,
|
|
||||||
paymentQuotationDiscoveryServiceNames,
|
|
||||||
[]string{discovery.OperationPaymentQuote},
|
|
||||||
)
|
|
||||||
if !ok {
|
|
||||||
cfg := a.config.PaymentQuotation
|
|
||||||
if cfg != nil && strings.TrimSpace(cfg.Address) != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !orchestratorFound {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Fall back to orchestrator endpoint when quotation service is not announced.
|
|
||||||
endpoint = orchestratorEndpoint
|
|
||||||
selected = discovery.ServiceSummary{Service: "PAYMENTS_ORCHESTRATOR"}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := ensurePaymentQuotationConfig(a.config)
|
|
||||||
cfg.Address = endpoint.address
|
|
||||||
cfg.Insecure = endpoint.insecure
|
|
||||||
ensureTimeoutsPayment(cfg)
|
|
||||||
|
|
||||||
a.logger.Info("Resolved payment quotation address from discovery",
|
|
||||||
zap.String("service", selected.Service),
|
|
||||||
zap.String("service_id", selected.ID),
|
|
||||||
zap.String("instance_id", selected.InstanceID),
|
|
||||||
zap.String("invoke_uri", endpoint.raw),
|
|
||||||
zap.String("address", endpoint.address),
|
|
||||||
zap.Bool("insecure", endpoint.insecure))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *APIImp) resolvePaymentMethodsAddress(services []discovery.ServiceSummary) {
|
|
||||||
endpoint, selected, ok := selectServiceEndpoint(
|
|
||||||
services,
|
|
||||||
paymentMethodsDiscoveryServiceNames,
|
|
||||||
[]string{discovery.OperationPaymentMethodsRead},
|
|
||||||
)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := ensurePaymentMethodsConfig(a.config)
|
|
||||||
cfg.Address = endpoint.address
|
|
||||||
cfg.Insecure = endpoint.insecure
|
|
||||||
ensureTimeoutsPayment(cfg)
|
|
||||||
|
|
||||||
a.logger.Info("Resolved payment methods address from discovery",
|
|
||||||
zap.String("service", selected.Service),
|
|
||||||
zap.String("service_id", selected.ID),
|
|
||||||
zap.String("instance_id", selected.InstanceID),
|
|
||||||
zap.String("invoke_uri", endpoint.raw),
|
|
||||||
zap.String("address", endpoint.address),
|
|
||||||
zap.Bool("insecure", endpoint.insecure))
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectServiceEndpoint(services []discovery.ServiceSummary, serviceNames []string, requiredOps []string) (discoveryEndpoint, discovery.ServiceSummary, bool) {
|
|
||||||
selections := make([]serviceSelection, 0)
|
|
||||||
for _, svc := range services {
|
|
||||||
if !svc.Healthy {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(svc.InvokeURI) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nameRank, ok := serviceRank(svc.Service, serviceNames)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
endpoint, err := parseDiscoveryInvokeURI(svc.InvokeURI)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
selections = append(selections, serviceSelection{
|
|
||||||
service: svc,
|
|
||||||
endpoint: endpoint,
|
|
||||||
opMatch: discovery.HasAnyOperation(svc.Ops, requiredOps),
|
|
||||||
nameRank: nameRank,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(selections) == 0 {
|
|
||||||
return discoveryEndpoint{}, discovery.ServiceSummary{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(selections, func(i, j int) bool {
|
|
||||||
if selections[i].opMatch != selections[j].opMatch {
|
|
||||||
return selections[i].opMatch
|
|
||||||
}
|
|
||||||
if selections[i].nameRank != selections[j].nameRank {
|
|
||||||
return selections[i].nameRank < selections[j].nameRank
|
|
||||||
}
|
|
||||||
if selections[i].service.ID != selections[j].service.ID {
|
|
||||||
return selections[i].service.ID < selections[j].service.ID
|
|
||||||
}
|
|
||||||
return selections[i].service.InstanceID < selections[j].service.InstanceID
|
|
||||||
})
|
|
||||||
|
|
||||||
selected := selections[0]
|
|
||||||
return selected.endpoint, selected.service, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork string, requiredOps []string) (discoveryEndpoint, discovery.GatewaySummary, bool) {
|
|
||||||
preferredNetwork = strings.TrimSpace(preferredNetwork)
|
|
||||||
selections := make([]gatewaySelection, 0)
|
|
||||||
|
|
||||||
for _, gateway := range gateways {
|
|
||||||
if !gateway.Healthy {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discovery.RailCrypto) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(gateway.InvokeURI) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
endpoint, err := parseDiscoveryInvokeURI(gateway.InvokeURI)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
selections = append(selections, gatewaySelection{
|
|
||||||
gateway: gateway,
|
|
||||||
endpoint: endpoint,
|
|
||||||
networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork),
|
|
||||||
opMatch: discovery.HasAnyOperation(gateway.Ops, requiredOps),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(selections) == 0 {
|
|
||||||
return discoveryEndpoint{}, discovery.GatewaySummary{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(selections, func(i, j int) bool {
|
|
||||||
if selections[i].networkMatch != selections[j].networkMatch {
|
|
||||||
return selections[i].networkMatch
|
|
||||||
}
|
|
||||||
if selections[i].opMatch != selections[j].opMatch {
|
|
||||||
return selections[i].opMatch
|
|
||||||
}
|
|
||||||
if selections[i].gateway.RoutingPriority != selections[j].gateway.RoutingPriority {
|
|
||||||
return selections[i].gateway.RoutingPriority > selections[j].gateway.RoutingPriority
|
|
||||||
}
|
|
||||||
if selections[i].gateway.ID != selections[j].gateway.ID {
|
|
||||||
return selections[i].gateway.ID < selections[j].gateway.ID
|
|
||||||
}
|
|
||||||
return selections[i].gateway.InstanceID < selections[j].gateway.InstanceID
|
|
||||||
})
|
|
||||||
|
|
||||||
selected := selections[0]
|
|
||||||
return selected.endpoint, selected.gateway, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return discoveryEndpoint{}, fmt.Errorf("Invoke uri is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without a scheme we expect a plain host:port target.
|
|
||||||
if !strings.Contains(raw, "://") {
|
|
||||||
if _, _, err := net.SplitHostPort(raw); err != nil {
|
|
||||||
return discoveryEndpoint{}, fmt.Errorf("Invoke uri must include host:port: %w", err)
|
|
||||||
}
|
|
||||||
return discoveryEndpoint{
|
|
||||||
address: raw,
|
|
||||||
insecure: true,
|
|
||||||
raw: raw,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(raw)
|
|
||||||
if err != nil {
|
|
||||||
return discoveryEndpoint{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
|
||||||
case "grpc":
|
|
||||||
address := strings.TrimSpace(parsed.Host)
|
|
||||||
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
|
|
||||||
return discoveryEndpoint{}, fmt.Errorf("Grpc invoke uri must include host:port: %w", splitErr)
|
|
||||||
}
|
|
||||||
return discoveryEndpoint{
|
|
||||||
address: address,
|
|
||||||
insecure: true,
|
|
||||||
raw: raw,
|
|
||||||
}, nil
|
|
||||||
case "grpcs":
|
|
||||||
address := strings.TrimSpace(parsed.Host)
|
|
||||||
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
|
|
||||||
return discoveryEndpoint{}, fmt.Errorf("Grpcs invoke uri must include host:port: %w", splitErr)
|
|
||||||
}
|
|
||||||
return discoveryEndpoint{
|
|
||||||
address: address,
|
|
||||||
insecure: false,
|
|
||||||
raw: raw,
|
|
||||||
}, nil
|
|
||||||
case "dns", "passthrough":
|
|
||||||
// gRPC resolver targets such as dns:///service:port.
|
|
||||||
return discoveryEndpoint{
|
|
||||||
address: raw,
|
|
||||||
insecure: true,
|
|
||||||
raw: raw,
|
|
||||||
}, nil
|
|
||||||
default:
|
|
||||||
return discoveryEndpoint{}, fmt.Errorf("Unsupported invoke uri scheme: %s", parsed.Scheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func serviceRank(service string, names []string) (int, bool) {
|
|
||||||
service = strings.TrimSpace(service)
|
|
||||||
if service == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
for i, name := range names {
|
|
||||||
if strings.EqualFold(service, strings.TrimSpace(name)) {
|
|
||||||
return i, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureLedgerConfig(cfg *eapi.Config) *eapi.LedgerConfig {
|
|
||||||
if cfg == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if cfg.Ledger == nil {
|
|
||||||
cfg.Ledger = &eapi.LedgerConfig{}
|
|
||||||
}
|
|
||||||
return cfg.Ledger
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePaymentOrchestratorConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
|
|
||||||
if cfg == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if cfg.PaymentOrchestrator == nil {
|
|
||||||
cfg.PaymentOrchestrator = &eapi.PaymentOrchestratorConfig{}
|
|
||||||
}
|
|
||||||
return cfg.PaymentOrchestrator
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePaymentQuotationConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
|
|
||||||
if cfg == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if cfg.PaymentQuotation == nil {
|
|
||||||
cfg.PaymentQuotation = &eapi.PaymentOrchestratorConfig{}
|
|
||||||
}
|
|
||||||
return cfg.PaymentQuotation
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePaymentMethodsConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
|
|
||||||
if cfg == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if cfg.PaymentMethods == nil {
|
|
||||||
cfg.PaymentMethods = &eapi.PaymentOrchestratorConfig{}
|
|
||||||
}
|
|
||||||
return cfg.PaymentMethods
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureTimeoutsLedger(cfg *eapi.LedgerConfig) {
|
|
||||||
if cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cfg.DialTimeoutSeconds <= 0 {
|
|
||||||
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
|
|
||||||
}
|
|
||||||
if cfg.CallTimeoutSeconds <= 0 {
|
|
||||||
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureTimeoutsChainGateway(cfg *eapi.ChainGatewayConfig) {
|
|
||||||
if cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cfg.DialTimeoutSeconds <= 0 {
|
|
||||||
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
|
|
||||||
}
|
|
||||||
if cfg.CallTimeoutSeconds <= 0 {
|
|
||||||
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureTimeoutsPayment(cfg *eapi.PaymentOrchestratorConfig) {
|
|
||||||
if cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cfg.DialTimeoutSeconds <= 0 {
|
|
||||||
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
|
|
||||||
}
|
|
||||||
if cfg.CallTimeoutSeconds <= 0 {
|
|
||||||
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package apiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/discovery"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseDiscoveryInvokeURI(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
raw string
|
|
||||||
address string
|
|
||||||
insecure bool
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "host_port",
|
|
||||||
raw: "ledger:50052",
|
|
||||||
address: "ledger:50052",
|
|
||||||
insecure: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "grpc_scheme",
|
|
||||||
raw: "grpc://payments-orchestrator:50062",
|
|
||||||
address: "payments-orchestrator:50062",
|
|
||||||
insecure: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "grpcs_scheme",
|
|
||||||
raw: "grpcs://payments-orchestrator:50062",
|
|
||||||
address: "payments-orchestrator:50062",
|
|
||||||
insecure: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dns_scheme",
|
|
||||||
raw: "dns:///ledger:50052",
|
|
||||||
address: "dns:///ledger:50052",
|
|
||||||
insecure: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid",
|
|
||||||
raw: "ledger",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
endpoint, err := parseDiscoveryInvokeURI(tc.raw)
|
|
||||||
if tc.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error for %q", tc.raw)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseDiscoveryInvokeURI(%q) failed: %v", tc.raw, err)
|
|
||||||
}
|
|
||||||
if endpoint.address != tc.address {
|
|
||||||
t.Fatalf("expected address %q, got %q", tc.address, endpoint.address)
|
|
||||||
}
|
|
||||||
if endpoint.insecure != tc.insecure {
|
|
||||||
t.Fatalf("expected insecure %t, got %t", tc.insecure, endpoint.insecure)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectServiceEndpointPrefersRequiredOperation(t *testing.T) {
|
|
||||||
services := []discovery.ServiceSummary{
|
|
||||||
{
|
|
||||||
ID: "candidate-without-op",
|
|
||||||
Service: "LEDGER",
|
|
||||||
Healthy: true,
|
|
||||||
InvokeURI: "ledger-2:50052",
|
|
||||||
Ops: []string{"balance.read"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "candidate-with-op",
|
|
||||||
Service: "LEDGER",
|
|
||||||
Healthy: true,
|
|
||||||
InvokeURI: "ledger-1:50052",
|
|
||||||
Ops: []string{"ledger.debit"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint, selected, ok := selectServiceEndpoint(services, []string{"LEDGER"}, []string{"ledger.debit"})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected service endpoint to be selected")
|
|
||||||
}
|
|
||||||
if selected.ID != "candidate-with-op" {
|
|
||||||
t.Fatalf("expected candidate-with-op, got %s", selected.ID)
|
|
||||||
}
|
|
||||||
if endpoint.address != "ledger-1:50052" {
|
|
||||||
t.Fatalf("expected address ledger-1:50052, got %s", endpoint.address)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectGatewayEndpointPrefersNetworkAndOperation(t *testing.T) {
|
|
||||||
gateways := []discovery.GatewaySummary{
|
|
||||||
{
|
|
||||||
ID: "high-priority-no-op",
|
|
||||||
Rail: "CRYPTO",
|
|
||||||
Network: "TRON_NILE",
|
|
||||||
Healthy: true,
|
|
||||||
InvokeURI: "gw-high:50053",
|
|
||||||
RoutingPriority: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "low-priority-with-op",
|
|
||||||
Rail: "CRYPTO",
|
|
||||||
Network: "TRON_NILE",
|
|
||||||
Healthy: true,
|
|
||||||
InvokeURI: "gw-low:50053",
|
|
||||||
Ops: []string{"balance.read"},
|
|
||||||
RoutingPriority: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "different-network",
|
|
||||||
Rail: "CRYPTO",
|
|
||||||
Network: "ARBITRUM_ONE",
|
|
||||||
Healthy: true,
|
|
||||||
InvokeURI: "gw-other:50053",
|
|
||||||
Ops: []string{"balance.read"},
|
|
||||||
RoutingPriority: 100,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint, selected, ok := selectGatewayEndpoint(gateways, "TRON_NILE", []string{"balance.read"})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected gateway endpoint to be selected")
|
|
||||||
}
|
|
||||||
if selected.ID != "low-priority-with-op" {
|
|
||||||
t.Fatalf("expected low-priority-with-op, got %s", selected.ID)
|
|
||||||
}
|
|
||||||
if endpoint.address != "gw-low:50053" {
|
|
||||||
t.Fatalf("expected address gw-low:50053, got %s", endpoint.address)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package ipguard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClientIP resolves caller IP from request remote address.
|
|
||||||
// The service relies on trusted proxy middleware to normalize RemoteAddr.
|
|
||||||
func ClientIP(r *http.Request) net.IP {
|
|
||||||
if r == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
raw := strings.TrimSpace(r.RemoteAddr)
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if ip := net.ParseIP(raw); ip != nil {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
host, _, err := net.SplitHostPort(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return net.ParseIP(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCIDRs(raw []string) ([]*net.IPNet, error) {
|
|
||||||
blocks := make([]*net.IPNet, 0, len(raw))
|
|
||||||
for _, item := range raw {
|
|
||||||
clean := strings.TrimSpace(item)
|
|
||||||
if clean == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, block, err := net.ParseCIDR(clean)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
blocks = append(blocks, block)
|
|
||||||
}
|
|
||||||
return blocks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowed reports whether clientIP is allowed by configured CIDRs.
|
|
||||||
// Empty CIDR list means unrestricted access.
|
|
||||||
func Allowed(clientIP net.IP, cidrs []string) (bool, error) {
|
|
||||||
blocks, err := parseCIDRs(cidrs)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if len(blocks) == 0 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if clientIP == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
for _, block := range blocks {
|
|
||||||
if block.Contains(clientIP) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package ipguard
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClientIP(t *testing.T) {
|
|
||||||
t.Run("extracts host from remote addr", func(t *testing.T) {
|
|
||||||
req := &http.Request{RemoteAddr: "10.1.2.3:1234"}
|
|
||||||
ip := ClientIP(req)
|
|
||||||
if ip == nil || ip.String() != "10.1.2.3" {
|
|
||||||
t.Fatalf("unexpected ip: %v", ip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("supports plain ip", func(t *testing.T) {
|
|
||||||
req := &http.Request{RemoteAddr: "8.8.8.8"}
|
|
||||||
ip := ClientIP(req)
|
|
||||||
if ip == nil || ip.String() != "8.8.8.8" {
|
|
||||||
t.Fatalf("unexpected ip: %v", ip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid remote addr", func(t *testing.T) {
|
|
||||||
req := &http.Request{RemoteAddr: "invalid"}
|
|
||||||
if ip := ClientIP(req); ip != nil {
|
|
||||||
t.Fatalf("expected nil ip, got %v", ip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowed(t *testing.T) {
|
|
||||||
clientIP := net.ParseIP("10.1.2.3")
|
|
||||||
if clientIP == nil {
|
|
||||||
t.Fatal("failed to parse test ip")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("allows when cidr matches", func(t *testing.T) {
|
|
||||||
allowed, err := Allowed(clientIP, []string{"10.0.0.0/8"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
t.Fatal("expected allowed")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("denies when cidr does not match", func(t *testing.T) {
|
|
||||||
allowed, err := Allowed(clientIP, []string{"192.168.0.0/16"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if allowed {
|
|
||||||
t.Fatal("expected denied")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("allows when cidr list is empty", func(t *testing.T) {
|
|
||||||
allowed, err := Allowed(clientIP, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
t.Fatal("expected allowed")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid cidr fails", func(t *testing.T) {
|
|
||||||
_, err := Allowed(clientIP, []string{"not-a-cidr"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nil client ip denied when cidrs configured", func(t *testing.T) {
|
|
||||||
allowed, err := Allowed(nil, []string{"10.0.0.0/8"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if allowed {
|
|
||||||
t.Fatal("expected denied")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
package routers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
"github.com/tech/sendico/pkg/mutil/mask"
|
|
||||||
"github.com/tech/sendico/server/interface/api/srequest"
|
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
||||||
"github.com/tech/sendico/server/internal/api/routers/ipguard"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const pendingLoginTTLMinutes = 10
|
|
||||||
const apiLoginGrantType = "password"
|
|
||||||
const apiLoginClientAuthMethod = "client_secret_post"
|
|
||||||
|
|
||||||
func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*model.Account, http.HandlerFunc) {
|
|
||||||
// Get the account database entry
|
|
||||||
trimmedLogin := strings.TrimSpace(req.Login)
|
|
||||||
account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin))
|
|
||||||
if errors.Is(err, merrors.ErrNoData) || (account == nil) {
|
|
||||||
pr.logger.Debug("User not found while logging in", zap.Error(err), zap.String("login", req.Login))
|
|
||||||
return nil, response.Unauthorized(pr.logger, pr.service, "user not found")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
pr.logger.Warn("Failed to query user with email", zap.Error(err), zap.String("login", req.Login))
|
|
||||||
return nil, response.Internal(pr.logger, pr.service, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !account.IsActive() {
|
|
||||||
return nil, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !account.MatchPassword(req.Password) {
|
|
||||||
return nil, response.Unauthorized(pr.logger, pr.service, "password does not match")
|
|
||||||
}
|
|
||||||
|
|
||||||
return account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pr *PublicRouter) respondPendingLogin(account *model.Account) http.HandlerFunc {
|
|
||||||
pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes)
|
|
||||||
if err != nil {
|
|
||||||
pr.logger.Warn("Failed to generate pending token", zap.Error(err))
|
|
||||||
return response.Internal(pr.logger, pr.service, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login))
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasGrantType(grants []string, target string) bool {
|
|
||||||
for _, grant := range grants {
|
|
||||||
if strings.EqualFold(strings.TrimSpace(grant), target) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, client *model.Client) http.HandlerFunc {
|
|
||||||
if client == nil {
|
|
||||||
pr.logger.Info("Client not found, rejecting authorization", zap.String("client_id", clientID))
|
|
||||||
return response.Unauthorized(pr.logger, pr.service, "client not found")
|
|
||||||
}
|
|
||||||
clientIP := ipguard.ClientIP(r)
|
|
||||||
allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs)
|
|
||||||
if err != nil {
|
|
||||||
pr.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID))
|
|
||||||
return response.Forbidden(pr.logger, pr.service, "client_ip_policy_invalid", "client ip policy is invalid")
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
rawIP := ""
|
|
||||||
if clientIP != nil {
|
|
||||||
rawIP = clientIP.String()
|
|
||||||
}
|
|
||||||
pr.logger.Warn("Client IP policy denied request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP))
|
|
||||||
return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pr *PublicRouter) validateAPIClient(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc {
|
|
||||||
client, err := pr.rtdb.GetClient(ctx, req.ClientID)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) || client == nil {
|
|
||||||
pr.logger.Debug("API login rejected: client not found", zap.String("client_id", req.ClientID))
|
|
||||||
return response.Unauthorized(pr.logger, pr.service, "client not found")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
pr.logger.Warn("API login rejected: failed to load client", zap.Error(err), zap.String("client_id", req.ClientID))
|
|
||||||
return response.Internal(pr.logger, pr.service, err)
|
|
||||||
}
|
|
||||||
if client.IsRevoked {
|
|
||||||
return response.Forbidden(pr.logger, pr.service, "client_revoked", "client has been revoked")
|
|
||||||
}
|
|
||||||
if !hasGrantType(client.GrantTypes, apiLoginGrantType) {
|
|
||||||
return response.Forbidden(pr.logger, pr.service, "client_grant_not_allowed", "client does not allow password grant")
|
|
||||||
}
|
|
||||||
method := strings.ToLower(strings.TrimSpace(client.TokenEndpointAuthMethod))
|
|
||||||
if method == "" {
|
|
||||||
method = apiLoginClientAuthMethod
|
|
||||||
}
|
|
||||||
if method != apiLoginClientAuthMethod {
|
|
||||||
return response.Forbidden(pr.logger, pr.service, "client_auth_method_unsupported", "unsupported client auth method")
|
|
||||||
}
|
|
||||||
|
|
||||||
storedSecret := strings.TrimSpace(client.ClientSecret)
|
|
||||||
if storedSecret == "" {
|
|
||||||
return response.Forbidden(pr.logger, pr.service, "client_secret_missing", "client secret is not configured")
|
|
||||||
}
|
|
||||||
if subtle.ConstantTimeCompare([]byte(storedSecret), []byte(req.ClientSecret)) != 1 {
|
|
||||||
pr.logger.Debug("API login rejected: invalid client secret", zap.String("client_id", req.ClientID))
|
|
||||||
return response.Unauthorized(pr.logger, pr.service, "invalid client secret")
|
|
||||||
}
|
|
||||||
if client.AccountRef != nil {
|
|
||||||
accountRef := account.GetID()
|
|
||||||
if accountRef == nil || *client.AccountRef != *accountRef {
|
|
||||||
return response.Forbidden(pr.logger, pr.service, "client_account_mismatch", "client is bound to another account")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if h := pr.validateClientIPPolicy(r, req.ClientID, client); h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pr *PublicRouter) respondAPILogin(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc {
|
|
||||||
if req.ClientID == "" || req.DeviceID == "" {
|
|
||||||
return response.BadRequest(pr.logger, pr.service, "missing_session", "session identifier is required")
|
|
||||||
}
|
|
||||||
accessToken, err := pr.imp.CreateAccessTokenForClient(account, req.ClientID)
|
|
||||||
if err != nil {
|
|
||||||
pr.logger.Warn("Failed to generate access token for API login", zap.Error(err))
|
|
||||||
return response.Internal(pr.logger, pr.service, err)
|
|
||||||
}
|
|
||||||
return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account, &accessToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.HandlerFunc) {
|
|
||||||
var req srequest.Login
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
logger.Info("Failed to decode login request", zap.Error(err))
|
|
||||||
return nil, response.BadPayload(logger, mservice.Accounts, err)
|
|
||||||
}
|
|
||||||
req.Login = strings.TrimSpace(req.Login)
|
|
||||||
req.Password = strings.TrimSpace(req.Password)
|
|
||||||
req.ClientID = strings.TrimSpace(req.ClientID)
|
|
||||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
|
||||||
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
|
|
||||||
|
|
||||||
if req.Login == "" {
|
|
||||||
return nil, response.BadRequest(logger, mservice.Accounts, "email_missing", "login request has no user name")
|
|
||||||
}
|
|
||||||
if req.Password == "" {
|
|
||||||
return nil, response.BadRequest(logger, mservice.Accounts, "password_missing", "login request has no password")
|
|
||||||
}
|
|
||||||
return &req, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
|
||||||
// TODO: add rate check
|
|
||||||
req, h := decodeLogin(r, a.logger)
|
|
||||||
if h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
account, h := a.authenticateAccount(r.Context(), req)
|
|
||||||
if h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
return a.respondPendingLogin(account)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PublicRouter) apiLogin(r *http.Request) http.HandlerFunc {
|
|
||||||
req, h := decodeLogin(r, a.logger)
|
|
||||||
if h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
if req.ClientID == "" {
|
|
||||||
return response.BadRequest(a.logger, mservice.Accounts, "client_id_missing", "clientId is required")
|
|
||||||
}
|
|
||||||
if req.ClientSecret == "" {
|
|
||||||
return response.BadRequest(a.logger, mservice.Accounts, "client_secret_missing", "clientSecret is required")
|
|
||||||
}
|
|
||||||
account, h := a.authenticateAccount(r.Context(), req)
|
|
||||||
if h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
if h = a.validateAPIClient(r.Context(), r, req, account); h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
return a.respondAPILogin(r.Context(), r, req, account)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package mutil
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/db/verification"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MapTokenErrorToResponse(logger mlogger.Logger, service mservice.Type, err error) http.HandlerFunc {
|
|
||||||
if errors.Is(err, verification.ErrTokenNotFound) {
|
|
||||||
logger.Debug("Verification token not found during consume", zap.Error(err))
|
|
||||||
return response.NotFound(logger, service, "No account found associated with given verifcation token")
|
|
||||||
}
|
|
||||||
if errors.Is(err, verification.ErrTokenExpired) {
|
|
||||||
logger.Debug("Verification token expired during consume", zap.Error(err))
|
|
||||||
return response.Gone(logger, service, "token_expired", "verification token has expired")
|
|
||||||
}
|
|
||||||
if errors.Is(err, verification.ErrTokenAlreadyUsed) {
|
|
||||||
logger.Debug("Verification token already used during consume", zap.Error(err))
|
|
||||||
return response.DataConflict(logger, service, "verification token has already been used")
|
|
||||||
}
|
|
||||||
if errors.Is(err, verification.ErrTokenAttemptsExceeded) {
|
|
||||||
logger.Debug("Verification token attempts exceeded", zap.Error(err))
|
|
||||||
return response.Forbidden(logger, service, "code_attempts_exceeded", "verification token has already been used")
|
|
||||||
}
|
|
||||||
if errors.Is(err, verification.ErrCooldownActive) {
|
|
||||||
logger.Debug("Cooldown is still active", zap.Error(err))
|
|
||||||
return response.TooManyRequests(logger, service, "verification token can't be generated yet, cooldown is still active")
|
|
||||||
}
|
|
||||||
if errors.Is(err, verification.ErrIdempotencyConflict) {
|
|
||||||
logger.Debug("Verification idempotency key conflict", zap.Error(err))
|
|
||||||
return response.DataConflict(logger, service, "verification request was already processed")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("Unexpected error during token verification", zap.Error(err))
|
|
||||||
return response.Auto(logger, service, err)
|
|
||||||
}
|
|
||||||
logger.Debug("No token verification error found")
|
|
||||||
return response.Success(logger)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package accountapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a *AccountAPI) verify(r *http.Request) http.HandlerFunc {
|
|
||||||
// Validate user input
|
|
||||||
token := mutil.GetToken(r)
|
|
||||||
// Get user
|
|
||||||
ctx := r.Context()
|
|
||||||
// Delete verification token to confirm account
|
|
||||||
t, err := a.vdb.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, token)
|
|
||||||
if err != nil {
|
|
||||||
a.logger.Debug("Failed to consume verification token", zap.Error(err))
|
|
||||||
return a.mapTokenErrorToResponse(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.Purpose != model.PurposeAccountActivation {
|
|
||||||
a.logger.Warn("Invalid token purpose", zap.String("expected", string(model.PurposeAccountActivation)), zap.String("actual", string(t.Purpose)))
|
|
||||||
return response.DataConflict(a.logger, a.Name(), "Invalid token purpose")
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.Account
|
|
||||||
if err := a.db.Get(ctx, t.AccountRef, &user); err != nil {
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
a.logger.Debug("Verified user not found", zap.Error(err))
|
|
||||||
return response.NotFound(a.logger, a.Name(), "User not found")
|
|
||||||
}
|
|
||||||
a.logger.Warn("Failed to fetch account", zap.Error(err))
|
|
||||||
return response.Internal(a.logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Status = model.AccountActive
|
|
||||||
if err = a.db.Update(ctx, &user); err != nil {
|
|
||||||
a.logger.Warn("Failed to save account while verifying account", zap.Error(err))
|
|
||||||
return response.Internal(a.logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
if err := a.sendAccountVerificationCompletedNotification(&user); err != nil {
|
|
||||||
a.logger.Warn("Failed to enqueue account verification notification", zap.Error(err), zap.String("email", user.Login))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Send verification confirmation email
|
|
||||||
return response.Success(a.logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AccountAPI) resendVerificationMail(r *http.Request) http.HandlerFunc {
|
|
||||||
return a.sendVerificationMail(r, getID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AccountAPI) resendVerification(r *http.Request) http.HandlerFunc {
|
|
||||||
return a.sendVerificationMail(r, getEmail)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package accountapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
eapi "github.com/tech/sendico/server/interface/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuildGatewayAsset_PreservesContractAddressCase(t *testing.T) {
|
|
||||||
asset, err := buildGatewayAsset(eapi.ChainGatewayAssetConfig{
|
|
||||||
Chain: "TRON_MAINNET",
|
|
||||||
TokenSymbol: "usdt",
|
|
||||||
ContractAddress: "TR7NhQjeKQxGTCi8q8ZY4pL8otSzgjLj6T",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "USDT", asset.GetTokenSymbol())
|
|
||||||
require.Equal(t, "TR7NhQjeKQxGTCi8q8ZY4pL8otSzgjLj6T", asset.GetContractAddress())
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package accountapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
|
||||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
|
||||||
"github.com/tech/sendico/server/interface/api/srequest"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type stubLedgerAccountClient struct {
|
|
||||||
createReq *ledgerv1.CreateAccountRequest
|
|
||||||
createResp *ledgerv1.CreateAccountResponse
|
|
||||||
createErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubLedgerAccountClient) CreateAccount(_ context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
|
|
||||||
s.createReq = req
|
|
||||||
return s.createResp, s.createErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubLedgerAccountClient) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenOrgLedgerAccount(t *testing.T) {
|
|
||||||
t.Run("creates operating ledger account", func(t *testing.T) {
|
|
||||||
desc := " Main org ledger account "
|
|
||||||
sr := &srequest.Signup{
|
|
||||||
Account: model.AccountData{
|
|
||||||
LoginData: model.LoginData{
|
|
||||||
UserDataBase: model.UserDataBase{
|
|
||||||
Login: "owner@example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
LedgerWallet: model.Describable{
|
|
||||||
Name: " Primary Ledger ",
|
|
||||||
Description: &desc,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
org := &model.Organization{}
|
|
||||||
org.SetID(bson.NewObjectID())
|
|
||||||
|
|
||||||
ledgerStub := &stubLedgerAccountClient{
|
|
||||||
createResp: &ledgerv1.CreateAccountResponse{
|
|
||||||
Account: &ledgerv1.LedgerAccount{LedgerAccountRef: bson.NewObjectID().Hex()},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
api := &AccountAPI{
|
|
||||||
logger: zap.NewNop(),
|
|
||||||
ledgerClient: ledgerStub,
|
|
||||||
chainAsset: &chainv1.Asset{
|
|
||||||
TokenSymbol: " usdt ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := api.openOrgLedgerAccount(context.Background(), org, sr)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
if assert.NotNil(t, ledgerStub.createReq) {
|
|
||||||
assert.Equal(t, org.ID.Hex(), ledgerStub.createReq.GetOrganizationRef())
|
|
||||||
assert.Equal(t, "RUB", ledgerStub.createReq.GetCurrency())
|
|
||||||
assert.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, ledgerStub.createReq.GetAccountType())
|
|
||||||
assert.Equal(t, ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, ledgerStub.createReq.GetStatus())
|
|
||||||
assert.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, ledgerStub.createReq.GetRole())
|
|
||||||
assert.Equal(t, map[string]string{
|
|
||||||
"source": "signup",
|
|
||||||
"login": "owner@example.com",
|
|
||||||
}, ledgerStub.createReq.GetMetadata())
|
|
||||||
if assert.NotNil(t, ledgerStub.createReq.GetDescribable()) {
|
|
||||||
assert.Equal(t, "Primary Ledger", ledgerStub.createReq.GetDescribable().GetName())
|
|
||||||
if assert.NotNil(t, ledgerStub.createReq.GetDescribable().Description) {
|
|
||||||
assert.Equal(t, "Main org ledger account", ledgerStub.createReq.GetDescribable().GetDescription())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("fails when ledger client is missing", func(t *testing.T) {
|
|
||||||
api := &AccountAPI{
|
|
||||||
logger: zap.NewNop(),
|
|
||||||
chainAsset: &chainv1.Asset{
|
|
||||||
TokenSymbol: "USDT",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := api.openOrgLedgerAccount(context.Background(), &model.Organization{}, &srequest.Signup{})
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.True(t, errors.Is(err, merrors.ErrInternal))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("fails when ledger response has empty reference", func(t *testing.T) {
|
|
||||||
ledgerStub := &stubLedgerAccountClient{
|
|
||||||
createResp: &ledgerv1.CreateAccountResponse{},
|
|
||||||
}
|
|
||||||
api := &AccountAPI{
|
|
||||||
logger: zap.NewNop(),
|
|
||||||
ledgerClient: ledgerStub,
|
|
||||||
chainAsset: &chainv1.Asset{
|
|
||||||
TokenSymbol: "USDT",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := api.openOrgLedgerAccount(context.Background(), &model.Organization{}, &srequest.Signup{})
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.True(t, errors.Is(err, merrors.ErrInternal))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package accountapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/db/verification"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a *AccountAPI) mapTokenErrorToResponse(err error) http.HandlerFunc {
|
|
||||||
if errors.Is(err, verification.ErrTokenNotFound) {
|
|
||||||
a.logger.Debug("Verification token not found during consume", zap.Error(err))
|
|
||||||
return response.NotFound(a.logger, a.Name(), "No account found associated with given verifcation token")
|
|
||||||
}
|
|
||||||
if errors.Is(err, verification.ErrTokenExpired) {
|
|
||||||
a.logger.Debug("Verification token expired during consume", zap.Error(err))
|
|
||||||
return response.Gone(a.logger, a.Name(), "token_expired", "verification token has expired")
|
|
||||||
}
|
|
||||||
if errors.Is(err, verification.ErrTokenAlreadyUsed) {
|
|
||||||
a.logger.Debug("Verification token already used during consume", zap.Error(err))
|
|
||||||
return response.DataConflict(a.logger, a.Name(), "verification token has already been used")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
a.logger.Warn("Uenxpected error during token verification", zap.Error(err))
|
|
||||||
return response.Auto(a.logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
a.logger.Debug("No token verification error found")
|
|
||||||
return response.Success(a.logger)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package callbacksimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
||||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a *CallbacksAPI) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
organizationRef, err := a.Oph.GetRef(r)
|
|
||||||
if err != nil {
|
|
||||||
a.Logger.Warn("Failed to parse organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
|
||||||
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var callback model.Callback
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
|
|
||||||
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
|
|
||||||
return response.BadPayload(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutation, err := a.normalizeAndPrepare(r.Context(), &callback, organizationRef, "", true)
|
|
||||||
if err != nil {
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
|
|
||||||
if err := a.DB.Create(ctx, *account.GetID(), organizationRef, &callback); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := a.applySigningSecretMutation(ctx, *account.GetID(), *callback.GetID(), mutation); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}); err != nil {
|
|
||||||
a.Logger.Warn("Failed to create callback transaction", zap.Error(err))
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.callbackResponse(&callback, accessToken, mutation.Generated, true)
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
package callbacksimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
||||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type signingSecretMutation struct {
|
|
||||||
SetSecretRef string
|
|
||||||
Clear bool
|
|
||||||
Generated string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CallbacksAPI) rotateSecret(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
callbackRef, err := a.Cph.GetRef(r)
|
|
||||||
if err != nil {
|
|
||||||
a.Logger.Warn("Failed to parse callback reference", zap.Error(err), mutil.PLog(a.Cph, r))
|
|
||||||
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var callback model.Callback
|
|
||||||
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &callback); err != nil {
|
|
||||||
a.Logger.Warn("Failed to fetch callback for secret rotation", zap.Error(err))
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if callback.RetryPolicy.SigningMode != model.CallbackSigningModeHMACSHA256 {
|
|
||||||
return response.BadRequest(a.Logger, a.Name(), "invalid_signing_mode", "rotate-secret is available only for hmac_sha256 callbacks")
|
|
||||||
}
|
|
||||||
|
|
||||||
secretRef, generatedSecret, err := a.secrets.Provision(r.Context(), callback.OrganizationRef, callbackRef)
|
|
||||||
if err != nil {
|
|
||||||
a.Logger.Warn("Failed to rotate callback signing secret", zap.Error(err))
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
if err := a.db.SetSigningSecretRef(r.Context(), *account.GetID(), callbackRef, secretRef); err != nil {
|
|
||||||
a.Logger.Warn("Failed to persist rotated callback signing secret reference", zap.Error(err))
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.callbackResponse(&callback, accessToken, generatedSecret, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CallbacksAPI) normalizeAndPrepare(
|
|
||||||
ctx context.Context,
|
|
||||||
callback *model.Callback,
|
|
||||||
organizationRef bson.ObjectID,
|
|
||||||
existingSecretRef string,
|
|
||||||
allowSecretGeneration bool,
|
|
||||||
) (signingSecretMutation, error) {
|
|
||||||
if callback == nil {
|
|
||||||
return signingSecretMutation{}, merrors.InvalidArgument("callback payload is required")
|
|
||||||
}
|
|
||||||
if organizationRef.IsZero() {
|
|
||||||
return signingSecretMutation{}, merrors.InvalidArgument("organization reference is required", "organizationRef")
|
|
||||||
}
|
|
||||||
|
|
||||||
callback.Name = strings.TrimSpace(callback.Name)
|
|
||||||
callback.Description = trimDescription(callback.Description)
|
|
||||||
|
|
||||||
callback.URL = strings.TrimSpace(callback.URL)
|
|
||||||
if callback.URL == "" {
|
|
||||||
return signingSecretMutation{}, merrors.InvalidArgument("url is required", "url")
|
|
||||||
}
|
|
||||||
if err := validateCallbackURL(callback.URL); err != nil {
|
|
||||||
return signingSecretMutation{}, err
|
|
||||||
}
|
|
||||||
if callback.Name == "" {
|
|
||||||
callback.Name = callback.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := normalizeStatus(callback.Status, a.config.DefaultStatus)
|
|
||||||
if err != nil {
|
|
||||||
return signingSecretMutation{}, err
|
|
||||||
}
|
|
||||||
callback.Status = status
|
|
||||||
callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes)
|
|
||||||
|
|
||||||
callback.RetryPolicy.Backoff.MinDelayMS = defaultInt(callback.RetryPolicy.Backoff.MinDelayMS, defaultRetryMinDelayMS)
|
|
||||||
callback.RetryPolicy.Backoff.MaxDelayMS = defaultInt(callback.RetryPolicy.Backoff.MaxDelayMS, defaultRetryMaxDelayMS)
|
|
||||||
if callback.RetryPolicy.Backoff.MaxDelayMS < callback.RetryPolicy.Backoff.MinDelayMS {
|
|
||||||
callback.RetryPolicy.Backoff.MaxDelayMS = callback.RetryPolicy.Backoff.MinDelayMS
|
|
||||||
}
|
|
||||||
callback.RetryPolicy.MaxAttempts = defaultInt(callback.RetryPolicy.MaxAttempts, defaultRetryMaxAttempts)
|
|
||||||
callback.RetryPolicy.RequestTimeoutMS = defaultInt(callback.RetryPolicy.RequestTimeoutMS, defaultRetryRequestTimeoutMS)
|
|
||||||
callback.RetryPolicy.Headers = normalizeHeaders(callback.RetryPolicy.Headers)
|
|
||||||
|
|
||||||
mode, err := normalizeSigningMode(callback.RetryPolicy.SigningMode)
|
|
||||||
if err != nil {
|
|
||||||
return signingSecretMutation{}, err
|
|
||||||
}
|
|
||||||
callback.RetryPolicy.SigningMode = mode
|
|
||||||
|
|
||||||
existingSecretRef = strings.TrimSpace(existingSecretRef)
|
|
||||||
switch callback.RetryPolicy.SigningMode {
|
|
||||||
case model.CallbackSigningModeNone:
|
|
||||||
return signingSecretMutation{Clear: existingSecretRef != ""}, nil
|
|
||||||
case model.CallbackSigningModeHMACSHA256:
|
|
||||||
if existingSecretRef != "" {
|
|
||||||
return signingSecretMutation{SetSecretRef: existingSecretRef}, nil
|
|
||||||
}
|
|
||||||
if !allowSecretGeneration {
|
|
||||||
return signingSecretMutation{}, merrors.InvalidArgument("signing secret is required for hmac_sha256 callbacks", "retryPolicy.signingMode")
|
|
||||||
}
|
|
||||||
if callback.GetID().IsZero() {
|
|
||||||
callback.SetID(bson.NewObjectID())
|
|
||||||
}
|
|
||||||
secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID())
|
|
||||||
if err != nil {
|
|
||||||
return signingSecretMutation{}, err
|
|
||||||
}
|
|
||||||
return signingSecretMutation{SetSecretRef: secretRef, Generated: generatedSecret}, nil
|
|
||||||
default:
|
|
||||||
return signingSecretMutation{}, merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CallbacksAPI) applySigningSecretMutation(
|
|
||||||
ctx context.Context,
|
|
||||||
accountRef,
|
|
||||||
callbackRef bson.ObjectID,
|
|
||||||
mutation signingSecretMutation,
|
|
||||||
) error {
|
|
||||||
if callbackRef.IsZero() {
|
|
||||||
return merrors.InvalidArgument("callback reference is required", "callbackRef")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(mutation.SetSecretRef) != "" {
|
|
||||||
return a.db.SetSigningSecretRef(ctx, accountRef, callbackRef, mutation.SetSecretRef)
|
|
||||||
}
|
|
||||||
if mutation.Clear {
|
|
||||||
err := a.db.ClearSigningSecretRef(ctx, accountRef, callbackRef)
|
|
||||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CallbacksAPI) callbackResponse(
|
|
||||||
callback *model.Callback,
|
|
||||||
accessToken *sresponse.TokenData,
|
|
||||||
generatedSecret string,
|
|
||||||
created bool,
|
|
||||||
) http.HandlerFunc {
|
|
||||||
if callback == nil || accessToken == nil {
|
|
||||||
return response.Internal(a.Logger, a.Name(), merrors.Internal("failed to build callback response"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return sresponse.Callback(a.Logger, callback, accessToken, generatedSecret, created)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeStatus(raw, fallback model.CallbackStatus) (model.CallbackStatus, error) {
|
|
||||||
candidate := strings.ToLower(strings.TrimSpace(string(raw)))
|
|
||||||
if candidate == "" {
|
|
||||||
candidate = strings.ToLower(strings.TrimSpace(string(fallback)))
|
|
||||||
}
|
|
||||||
|
|
||||||
switch candidate {
|
|
||||||
case "", "active", "enabled":
|
|
||||||
return model.CallbackStatusActive, nil
|
|
||||||
case "disabled", "inactive":
|
|
||||||
return model.CallbackStatusDisabled, nil
|
|
||||||
default:
|
|
||||||
return "", merrors.InvalidArgument("unsupported callback status", "status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSigningMode(raw model.CallbackSigningMode) (model.CallbackSigningMode, error) {
|
|
||||||
mode := strings.ToLower(strings.TrimSpace(string(raw)))
|
|
||||||
switch mode {
|
|
||||||
case "", "none":
|
|
||||||
return model.CallbackSigningModeNone, nil
|
|
||||||
case "hmac_sha256", "hmac-sha256", "hmac":
|
|
||||||
return model.CallbackSigningModeHMACSHA256, nil
|
|
||||||
default:
|
|
||||||
return "", merrors.InvalidArgument("unsupported callback signing mode", "retryPolicy.signingMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeEventTypes(eventTypes []string, defaults []string) []string {
|
|
||||||
if len(eventTypes) == 0 {
|
|
||||||
return normalizeEventTypes(defaults, nil)
|
|
||||||
}
|
|
||||||
seen := make(map[string]struct{}, len(eventTypes))
|
|
||||||
out := make([]string, 0, len(eventTypes))
|
|
||||||
for _, eventType := range eventTypes {
|
|
||||||
value := strings.TrimSpace(eventType)
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seen[value]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
out = append(out, value)
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
if len(defaults) > 0 {
|
|
||||||
return normalizeEventTypes(defaults, nil)
|
|
||||||
}
|
|
||||||
return []string{model.PaymentStatusUpdatedType}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHeaders(headers map[string]string) map[string]string {
|
|
||||||
if len(headers) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make(map[string]string, len(headers))
|
|
||||||
for key, value := range headers {
|
|
||||||
k := strings.TrimSpace(key)
|
|
||||||
if k == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[k] = strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeCallbackMutable(dst, src *model.Callback) {
|
|
||||||
dst.OrganizationRef = src.OrganizationRef
|
|
||||||
dst.Describable = src.Describable
|
|
||||||
dst.Status = src.Status
|
|
||||||
dst.URL = src.URL
|
|
||||||
dst.EventTypes = append([]string(nil), src.EventTypes...)
|
|
||||||
dst.RetryPolicy = model.CallbackRetryPolicy{
|
|
||||||
Backoff: model.CallbackBackoff{
|
|
||||||
MinDelayMS: src.RetryPolicy.Backoff.MinDelayMS,
|
|
||||||
MaxDelayMS: src.RetryPolicy.Backoff.MaxDelayMS,
|
|
||||||
},
|
|
||||||
SigningMode: src.RetryPolicy.SigningMode,
|
|
||||||
Headers: normalizeHeaders(src.RetryPolicy.Headers),
|
|
||||||
MaxAttempts: src.RetryPolicy.MaxAttempts,
|
|
||||||
RequestTimeoutMS: src.RetryPolicy.RequestTimeoutMS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultInt(value, fallback int) int {
|
|
||||||
if value > 0 {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimDescription(in *string) *string {
|
|
||||||
if in == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
value := strings.TrimSpace(*in)
|
|
||||||
if value == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCallbackURL(raw string) error {
|
|
||||||
parsed, err := url.ParseRequestURI(raw)
|
|
||||||
if err != nil {
|
|
||||||
return merrors.InvalidArgument("url is invalid", "url")
|
|
||||||
}
|
|
||||||
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
|
||||||
case "https", "http":
|
|
||||||
default:
|
|
||||||
return merrors.InvalidArgument("url scheme must be http or https", "url")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(parsed.Host) == "" {
|
|
||||||
return merrors.InvalidArgument("url host is required", "url")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package callbacksimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
|
||||||
"github.com/tech/sendico/pkg/vault/kv"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type signingSecretManager interface {
|
|
||||||
Provision(ctx context.Context, organizationRef, callbackRef bson.ObjectID) (secretRef string, generatedSecret string, err error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type vaultSigningSecretManager struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
store kv.Client
|
|
||||||
pathPrefix string
|
|
||||||
field string
|
|
||||||
secretLength int
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
metricsResultSuccess = "success"
|
|
||||||
metricsResultError = "error"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
signingSecretMetricsOnce sync.Once
|
|
||||||
signingSecretStatus *prometheus.CounterVec
|
|
||||||
signingSecretLatency *prometheus.HistogramVec
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureSigningSecretMetrics() {
|
|
||||||
signingSecretMetricsOnce.Do(func() {
|
|
||||||
signingSecretStatus = promauto.NewCounterVec(prometheus.CounterOpts{
|
|
||||||
Namespace: "sendico",
|
|
||||||
Subsystem: "bff_callbacks",
|
|
||||||
Name: "signing_secret_provision_total",
|
|
||||||
Help: "Total callback signing secret provisioning attempts.",
|
|
||||||
}, []string{"result"})
|
|
||||||
signingSecretLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
|
||||||
Namespace: "sendico",
|
|
||||||
Subsystem: "bff_callbacks",
|
|
||||||
Name: "signing_secret_provision_duration_seconds",
|
|
||||||
Help: "Duration of callback signing secret provisioning attempts.",
|
|
||||||
Buckets: prometheus.DefBuckets,
|
|
||||||
}, []string{"result"})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSigningSecretManager(logger mlogger.Logger, cfg callbacksConfig) (signingSecretManager, error) {
|
|
||||||
if err := cfg.validate(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &vaultSigningSecretManager{
|
|
||||||
logger: logger.Named("callbacks_secrets"),
|
|
||||||
pathPrefix: strings.Trim(strings.TrimSpace(cfg.SecretPathPrefix), "/"),
|
|
||||||
field: strings.TrimSpace(cfg.SecretField),
|
|
||||||
secretLength: cfg.SecretLengthBytes,
|
|
||||||
}
|
|
||||||
if manager.pathPrefix == "" {
|
|
||||||
manager.pathPrefix = defaultSigningSecretPathPrefix
|
|
||||||
}
|
|
||||||
if manager.field == "" {
|
|
||||||
manager.field = defaultSigningSecretField
|
|
||||||
}
|
|
||||||
|
|
||||||
if isVaultConfigEmpty(cfg.Vault) {
|
|
||||||
manager.logger.Warn("Callbacks Vault config is not set; hmac signing secret generation is disabled")
|
|
||||||
ensureSigningSecretMetrics()
|
|
||||||
return manager, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
store, err := kv.New(kv.Options{
|
|
||||||
Logger: manager.logger,
|
|
||||||
Config: kv.Config{
|
|
||||||
Address: strings.TrimSpace(cfg.Vault.Address),
|
|
||||||
TokenEnv: strings.TrimSpace(cfg.Vault.TokenEnv),
|
|
||||||
TokenFileEnv: strings.TrimSpace(cfg.Vault.TokenFileEnv),
|
|
||||||
TokenFile: strings.TrimSpace(cfg.Vault.TokenFile),
|
|
||||||
Namespace: strings.TrimSpace(cfg.Vault.Namespace),
|
|
||||||
MountPath: strings.TrimSpace(cfg.Vault.MountPath),
|
|
||||||
},
|
|
||||||
Component: "bff callbacks signing secret manager",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
manager.store = store
|
|
||||||
ensureSigningSecretMetrics()
|
|
||||||
|
|
||||||
return manager, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *vaultSigningSecretManager) Provision(
|
|
||||||
ctx context.Context,
|
|
||||||
organizationRef,
|
|
||||||
callbackRef bson.ObjectID,
|
|
||||||
) (string, string, error) {
|
|
||||||
start := time.Now()
|
|
||||||
result := metricsResultSuccess
|
|
||||||
defer func() {
|
|
||||||
signingSecretStatus.WithLabelValues(result).Inc()
|
|
||||||
signingSecretLatency.WithLabelValues(result).Observe(time.Since(start).Seconds())
|
|
||||||
}()
|
|
||||||
|
|
||||||
if organizationRef.IsZero() {
|
|
||||||
result = metricsResultError
|
|
||||||
return "", "", merrors.InvalidArgument("organization reference is required", "organizationRef")
|
|
||||||
}
|
|
||||||
if callbackRef.IsZero() {
|
|
||||||
result = metricsResultError
|
|
||||||
return "", "", merrors.InvalidArgument("callback reference is required", "callbackRef")
|
|
||||||
}
|
|
||||||
if m.store == nil {
|
|
||||||
result = metricsResultError
|
|
||||||
return "", "", merrors.InvalidArgument("callbacks vault config is required to generate signing secrets", "api.callbacks.vault")
|
|
||||||
}
|
|
||||||
|
|
||||||
secret, err := generateSigningSecret(m.secretLength)
|
|
||||||
if err != nil {
|
|
||||||
result = metricsResultError
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
secretPath := path.Join(m.pathPrefix, organizationRef.Hex(), callbackRef.Hex())
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
m.field: secret,
|
|
||||||
"organization_ref": organizationRef.Hex(),
|
|
||||||
"callback_ref": callbackRef.Hex(),
|
|
||||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
|
||||||
}
|
|
||||||
if err := m.store.Put(ctx, secretPath, payload); err != nil {
|
|
||||||
result = metricsResultError
|
|
||||||
m.logger.Warn("Failed to store callback signing secret", zap.String("path", secretPath), zap.Error(err))
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
secretRef := "vault:" + secretPath + "#" + m.field
|
|
||||||
m.logger.Info("Callback signing secret stored", zap.String("secret_ref", secretRef), mzap.ObjRef("callback_ref", callbackRef))
|
|
||||||
|
|
||||||
return secretRef, secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isVaultConfigEmpty(cfg VaultConfig) bool {
|
|
||||||
return strings.TrimSpace(cfg.Address) == "" &&
|
|
||||||
strings.TrimSpace(cfg.TokenEnv) == "" &&
|
|
||||||
strings.TrimSpace(cfg.TokenFileEnv) == "" &&
|
|
||||||
strings.TrimSpace(cfg.TokenFile) == "" &&
|
|
||||||
strings.TrimSpace(cfg.MountPath) == "" &&
|
|
||||||
strings.TrimSpace(cfg.Namespace) == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateSigningSecret(length int) (string, error) {
|
|
||||||
if length <= 0 {
|
|
||||||
return "", merrors.InvalidArgument("secret length must be greater than zero", "secret_length")
|
|
||||||
}
|
|
||||||
raw := make([]byte, length)
|
|
||||||
if _, err := rand.Read(raw); err != nil {
|
|
||||||
return "", merrors.Internal("failed to generate signing secret: " + err.Error())
|
|
||||||
}
|
|
||||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package callbacksimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
api "github.com/tech/sendico/pkg/api/http"
|
|
||||||
"github.com/tech/sendico/pkg/db/callbacks"
|
|
||||||
"github.com/tech/sendico/pkg/db/transaction"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
eapi "github.com/tech/sendico/server/interface/api"
|
|
||||||
"github.com/tech/sendico/server/internal/server/papitemplate"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CallbacksAPI struct {
|
|
||||||
papitemplate.ProtectedAPI[model.Callback]
|
|
||||||
db callbacks.DB
|
|
||||||
tf transaction.Factory
|
|
||||||
secrets signingSecretManager
|
|
||||||
config callbacksConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CallbacksAPI) Name() mservice.Type {
|
|
||||||
return mservice.Callbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CallbacksAPI) Finish(_ context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPI(apiCtx eapi.API) (*CallbacksAPI, error) {
|
|
||||||
dbFactory := func() (papitemplate.ProtectedDB[model.Callback], error) {
|
|
||||||
return apiCtx.DBFactory().NewCallbacksDB()
|
|
||||||
}
|
|
||||||
|
|
||||||
res := &CallbacksAPI{
|
|
||||||
config: newCallbacksConfig(apiCtx.Config().Callbacks),
|
|
||||||
tf: apiCtx.DBFactory().TransactionFactory(),
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := papitemplate.CreateAPI(apiCtx, dbFactory, mservice.Organizations, mservice.Callbacks)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
res.ProtectedAPI = *p.
|
|
||||||
WithNoCreateNotification().
|
|
||||||
WithNoUpdateNotification().
|
|
||||||
WithNoDeleteNotification().
|
|
||||||
WithCreateHandler(res.create).
|
|
||||||
WithUpdateHandler(res.update).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
if res.db, err = apiCtx.DBFactory().NewCallbacksDB(); err != nil {
|
|
||||||
res.Logger.Warn("Failed to create callbacks database", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if res.secrets, err = newSigningSecretManager(res.Logger, res.config); err != nil {
|
|
||||||
res.Logger.Warn("Failed to initialize callbacks signing secret manager", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
apiCtx.Register().AccountHandler(res.Name(), res.Cph.AddRef("/rotate-secret"), api.Post, res.rotateSecret)
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultCallbackStatus = model.CallbackStatusActive
|
|
||||||
defaultRetryMaxAttempts = 8
|
|
||||||
defaultRetryMinDelayMS = 1000
|
|
||||||
defaultRetryMaxDelayMS = 300000
|
|
||||||
defaultRetryRequestTimeoutMS = 10000
|
|
||||||
defaultSigningSecretLengthBytes = 32
|
|
||||||
defaultSigningSecretField = "value"
|
|
||||||
defaultSigningSecretPathPrefix = "sendico/callbacks"
|
|
||||||
)
|
|
||||||
|
|
||||||
type callbacksConfig struct {
|
|
||||||
DefaultEventTypes []string
|
|
||||||
DefaultStatus model.CallbackStatus
|
|
||||||
SecretPathPrefix string
|
|
||||||
SecretField string
|
|
||||||
SecretLengthBytes int
|
|
||||||
Vault VaultConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
type VaultConfig struct {
|
|
||||||
Address string
|
|
||||||
TokenEnv string
|
|
||||||
TokenFileEnv string
|
|
||||||
TokenFile string
|
|
||||||
Namespace string
|
|
||||||
MountPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newCallbacksConfig(source *eapi.CallbacksConfig) callbacksConfig {
|
|
||||||
cfg := callbacksConfig{
|
|
||||||
DefaultEventTypes: []string{model.PaymentStatusUpdatedType},
|
|
||||||
DefaultStatus: defaultCallbackStatus,
|
|
||||||
SecretPathPrefix: defaultSigningSecretPathPrefix,
|
|
||||||
SecretField: defaultSigningSecretField,
|
|
||||||
SecretLengthBytes: defaultSigningSecretLengthBytes,
|
|
||||||
}
|
|
||||||
if source == nil {
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
if source.SecretPathPrefix != "" {
|
|
||||||
cfg.SecretPathPrefix = source.SecretPathPrefix
|
|
||||||
}
|
|
||||||
if source.SecretField != "" {
|
|
||||||
cfg.SecretField = source.SecretField
|
|
||||||
}
|
|
||||||
if source.SecretLengthBytes > 0 {
|
|
||||||
cfg.SecretLengthBytes = source.SecretLengthBytes
|
|
||||||
}
|
|
||||||
if len(source.DefaultEventTypes) > 0 {
|
|
||||||
cfg.DefaultEventTypes = source.DefaultEventTypes
|
|
||||||
}
|
|
||||||
if source.DefaultStatus != "" {
|
|
||||||
cfg.DefaultStatus = model.CallbackStatus(source.DefaultStatus)
|
|
||||||
}
|
|
||||||
cfg.Vault = VaultConfig{
|
|
||||||
Address: source.Vault.Address,
|
|
||||||
TokenEnv: source.Vault.TokenEnv,
|
|
||||||
TokenFileEnv: source.Vault.TokenFileEnv,
|
|
||||||
TokenFile: source.Vault.TokenFile,
|
|
||||||
Namespace: source.Vault.Namespace,
|
|
||||||
MountPath: source.Vault.MountPath,
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c callbacksConfig) validate() error {
|
|
||||||
if c.SecretLengthBytes <= 0 {
|
|
||||||
return merrors.InvalidArgument("callbacks signing secret length must be greater than zero", "api.callbacks.secret_length_bytes")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package callbacksimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a *CallbacksAPI) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
var input model.Callback
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
||||||
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
|
|
||||||
return response.BadPayload(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
callbackRef := *input.GetID()
|
|
||||||
if callbackRef.IsZero() {
|
|
||||||
return response.Auto(a.Logger, a.Name(), merrors.InvalidArgument("callback ref is required", "id"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing model.Callback
|
|
||||||
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &existing); err != nil {
|
|
||||||
a.Logger.Warn("Failed to fetch callback before update", zap.Error(err))
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
existingSecretRef, err := a.db.GetSigningSecretRef(r.Context(), *account.GetID(), callbackRef)
|
|
||||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
|
||||||
a.Logger.Warn("Failed to fetch callback signing secret metadata", zap.Error(err))
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mergeCallbackMutable(&existing, &input)
|
|
||||||
mutation, err := a.normalizeAndPrepare(r.Context(), &existing, existing.OrganizationRef, existingSecretRef, true)
|
|
||||||
if err != nil {
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
|
|
||||||
if err := a.DB.Update(ctx, *account.GetID(), &existing); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := a.applySigningSecretMutation(ctx, *account.GetID(), callbackRef, mutation); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}); err != nil {
|
|
||||||
a.Logger.Warn("Failed to update callback transaction", zap.Error(err))
|
|
||||||
return response.Auto(a.Logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.callbackResponse(&existing, accessToken, mutation.Generated, false)
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
package paymentapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/discovery"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
|
||||||
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
|
||||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
||||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
documentsServiceName = "BILLING_DOCUMENTS"
|
|
||||||
documentsOperationGet = discovery.OperationDocumentsGet
|
|
||||||
documentsCallTimeout = 10 * time.Second
|
|
||||||
gatewayCallTimeout = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
var allowedOperationGatewayServices = map[mservice.Type]struct{}{
|
|
||||||
mservice.ChainGateway: {},
|
|
||||||
mservice.TronGateway: {},
|
|
||||||
mservice.MntxGateway: {},
|
|
||||||
mservice.PaymentGateway: {},
|
|
||||||
mservice.TgSettle: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PaymentAPI) getOperationDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
|
||||||
orgRef, denied := a.authorizeDocumentDownload(r, account)
|
|
||||||
if denied != nil {
|
|
||||||
return denied
|
|
||||||
}
|
|
||||||
|
|
||||||
query := r.URL.Query()
|
|
||||||
gatewayService := normalizeGatewayService(query.Get("gateway_service"))
|
|
||||||
if gatewayService == "" {
|
|
||||||
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "gateway_service is required")
|
|
||||||
}
|
|
||||||
if _, ok := allowedOperationGatewayServices[gatewayService]; !ok {
|
|
||||||
return response.BadRequest(a.logger, a.Name(), "invalid_parameter", "unsupported gateway_service")
|
|
||||||
}
|
|
||||||
|
|
||||||
operationRef := strings.TrimSpace(query.Get("operation_ref"))
|
|
||||||
if operationRef == "" {
|
|
||||||
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "operation_ref is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
service, gateway, h := a.resolveOperationDocumentDeps(r.Context(), gatewayService)
|
|
||||||
if h != nil {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
op, err := a.fetchGatewayOperation(r.Context(), gateway.InvokeURI, operationRef)
|
|
||||||
if err != nil {
|
|
||||||
a.logger.Warn("Failed to fetch gateway operation for document generation", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
|
|
||||||
return documentErrorResponse(a.logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := operationDocumentRequest(orgRef.Hex(), gatewayService, operationRef, op)
|
|
||||||
|
|
||||||
docResp, err := a.fetchOperationDocument(r.Context(), service.InvokeURI, req)
|
|
||||||
if err != nil {
|
|
||||||
a.logger.Warn("Failed to fetch operation document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
|
|
||||||
return documentErrorResponse(a.logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return operationDocumentResponse(a.logger, a.Name(), docResp, fmt.Sprintf("operation_%s.pdf", sanitizeFilenameComponent(operationRef)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PaymentAPI) authorizeDocumentDownload(r *http.Request, account *model.Account) (bson.ObjectID, http.HandlerFunc) {
|
|
||||||
orgRef, err := a.oph.GetRef(r)
|
|
||||||
if err != nil {
|
|
||||||
a.logger.Warn("Failed to parse organization reference for document request", zap.Error(err), mutil.PLog(a.oph, r))
|
|
||||||
return bson.NilObjectID, response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
|
|
||||||
if err != nil {
|
|
||||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
|
||||||
return bson.NilObjectID, response.Auto(a.logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
a.logger.Debug("Access denied when downloading document", mutil.PLog(a.oph, r))
|
|
||||||
return bson.NilObjectID, response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
return orgRef, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PaymentAPI) resolveOperationDocumentDeps(ctx context.Context, gatewayService mservice.Type) (*discovery.ServiceSummary, *discovery.GatewaySummary, http.HandlerFunc) {
|
|
||||||
if a.discovery == nil {
|
|
||||||
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
lookupResp, err := a.discovery.Lookup(lookupCtx)
|
|
||||||
if err != nil {
|
|
||||||
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
|
|
||||||
return nil, nil, response.Auto(a.logger, a.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
service := findDocumentsService(lookupResp.Services)
|
|
||||||
if service == nil {
|
|
||||||
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
gateway := findGatewayForService(lookupResp.Gateways, gatewayService)
|
|
||||||
if gateway == nil {
|
|
||||||
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "gateway service unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
return service, gateway, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationDocumentResponse(logger mlogger.Logger, source mservice.Type, docResp *documentsv1.GetDocumentResponse, fallbackFilename string) http.HandlerFunc {
|
|
||||||
if docResp == nil || len(docResp.GetContent()) == 0 {
|
|
||||||
return response.Error(logger, source, http.StatusInternalServerError, "empty_document", "document service returned empty payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := strings.TrimSpace(docResp.GetFilename())
|
|
||||||
if filename == "" {
|
|
||||||
filename = strings.TrimSpace(fallbackFilename)
|
|
||||||
}
|
|
||||||
if filename == "" {
|
|
||||||
filename = "document.pdf"
|
|
||||||
}
|
|
||||||
|
|
||||||
mimeType := strings.TrimSpace(docResp.GetMimeType())
|
|
||||||
if mimeType == "" {
|
|
||||||
mimeType = "application/pdf"
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", mimeType)
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
if _, err := w.Write(docResp.GetContent()); err != nil {
|
|
||||||
logger.Warn("Failed to write document response", zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeGatewayService(raw string) mservice.Type {
|
|
||||||
value := strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
if value == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
switch value {
|
|
||||||
case string(mservice.ChainGateway):
|
|
||||||
return mservice.ChainGateway
|
|
||||||
case string(mservice.TronGateway):
|
|
||||||
return mservice.TronGateway
|
|
||||||
case string(mservice.MntxGateway):
|
|
||||||
return mservice.MntxGateway
|
|
||||||
case string(mservice.PaymentGateway):
|
|
||||||
return mservice.PaymentGateway
|
|
||||||
case string(mservice.TgSettle):
|
|
||||||
return mservice.TgSettle
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeFilenameComponent(value string) string {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(len(trimmed))
|
|
||||||
|
|
||||||
for _, r := range trimmed {
|
|
||||||
switch {
|
|
||||||
case r >= 'a' && r <= 'z':
|
|
||||||
b.WriteRune(r)
|
|
||||||
case r >= 'A' && r <= 'Z':
|
|
||||||
b.WriteRune(r)
|
|
||||||
case r >= '0' && r <= '9':
|
|
||||||
b.WriteRune(r)
|
|
||||||
case r == '-', r == '_':
|
|
||||||
b.WriteRune(r)
|
|
||||||
default:
|
|
||||||
b.WriteRune('_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clean := strings.Trim(b.String(), "_")
|
|
||||||
if clean == "" {
|
|
||||||
return "operation"
|
|
||||||
}
|
|
||||||
|
|
||||||
return clean
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PaymentAPI) fetchOperationDocument(ctx context.Context, invokeURI string, req *documentsv1.GetOperationDocumentRequest) (*documentsv1.GetDocumentResponse, error) {
|
|
||||||
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InternalWrap(err, "dial billing documents")
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := documentsv1.NewDocumentServiceClient(conn)
|
|
||||||
|
|
||||||
callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout)
|
|
||||||
defer callCancel()
|
|
||||||
|
|
||||||
return client.GetOperationDocument(callCtx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PaymentAPI) fetchGatewayOperation(ctx context.Context, invokeURI, operationRef string) (*connectorv1.Operation, error) {
|
|
||||||
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InternalWrap(err, "dial gateway connector")
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := connectorv1.NewConnectorServiceClient(conn)
|
|
||||||
|
|
||||||
callCtx, callCancel := context.WithTimeout(ctx, gatewayCallTimeout)
|
|
||||||
defer callCancel()
|
|
||||||
|
|
||||||
resp, err := client.GetOperation(callCtx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(operationRef)})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
op := resp.GetOperation()
|
|
||||||
if op == nil {
|
|
||||||
return nil, merrors.NoData("gateway returned empty operation payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
return op, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findGatewayForService(gateways []discovery.GatewaySummary, gatewayService mservice.Type) *discovery.GatewaySummary {
|
|
||||||
candidates := make([]discovery.GatewaySummary, 0, len(gateways))
|
|
||||||
for _, gw := range gateways {
|
|
||||||
if !gw.Healthy || strings.TrimSpace(gw.InvokeURI) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rail := discovery.NormalizeRail(gw.Rail)
|
|
||||||
network := strings.ToLower(strings.TrimSpace(gw.Network))
|
|
||||||
switch gatewayService {
|
|
||||||
case mservice.MntxGateway:
|
|
||||||
if rail == discovery.NormalizeRail(discovery.RailCardPayout) {
|
|
||||||
candidates = append(candidates, gw)
|
|
||||||
}
|
|
||||||
case mservice.PaymentGateway, mservice.TgSettle:
|
|
||||||
if rail == discovery.NormalizeRail(discovery.RailProviderSettlement) {
|
|
||||||
candidates = append(candidates, gw)
|
|
||||||
}
|
|
||||||
case mservice.TronGateway:
|
|
||||||
if rail == discovery.NormalizeRail(discovery.RailCrypto) && strings.Contains(network, "tron") {
|
|
||||||
candidates = append(candidates, gw)
|
|
||||||
}
|
|
||||||
case mservice.ChainGateway:
|
|
||||||
if rail == discovery.NormalizeRail(discovery.RailCrypto) && !strings.Contains(network, "tron") {
|
|
||||||
candidates = append(candidates, gw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(candidates) == 0 && gatewayService == mservice.ChainGateway {
|
|
||||||
for _, gw := range gateways {
|
|
||||||
if gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" && discovery.NormalizeRail(gw.Rail) == discovery.NormalizeRail(discovery.RailCrypto) {
|
|
||||||
candidates = append(candidates, gw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
best := candidates[0]
|
|
||||||
for _, candidate := range candidates[1:] {
|
|
||||||
if candidate.RoutingPriority > best.RoutingPriority {
|
|
||||||
best = candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &best
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationDocumentRequest(organizationRef string, gatewayService mservice.Type, requestedOperationRef string, op *connectorv1.Operation) *documentsv1.GetOperationDocumentRequest {
|
|
||||||
req := &documentsv1.GetOperationDocumentRequest{
|
|
||||||
OrganizationRef: strings.TrimSpace(organizationRef),
|
|
||||||
GatewayService: string(gatewayService),
|
|
||||||
OperationRef: firstNonEmpty(strings.TrimSpace(op.GetOperationRef()), strings.TrimSpace(requestedOperationRef)),
|
|
||||||
OperationCode: strings.TrimSpace(op.GetType().String()),
|
|
||||||
OperationLabel: operationLabel(op.GetType()),
|
|
||||||
OperationState: strings.TrimSpace(op.GetStatus().String()),
|
|
||||||
Amount: strings.TrimSpace(op.GetMoney().GetAmount()),
|
|
||||||
Currency: strings.TrimSpace(op.GetMoney().GetCurrency()),
|
|
||||||
}
|
|
||||||
|
|
||||||
if ts := op.GetCreatedAt(); ts != nil {
|
|
||||||
req.StartedAtUnixMs = ts.AsTime().UnixMilli()
|
|
||||||
}
|
|
||||||
if ts := op.GetUpdatedAt(); ts != nil {
|
|
||||||
req.CompletedAtUnixMs = ts.AsTime().UnixMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
req.PaymentRef = operationParamValue(op.GetParams(), "payment_ref", "parent_payment_ref", "paymentRef", "parentPaymentRef")
|
|
||||||
req.FailureCode = firstNonEmpty(
|
|
||||||
operationParamValue(op.GetParams(), "failure_code", "provider_code", "error_code"),
|
|
||||||
failureCodeFromStatus(op.GetStatus()),
|
|
||||||
)
|
|
||||||
req.FailureReason = operationParamValue(op.GetParams(), "failure_reason", "provider_message", "error", "message")
|
|
||||||
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationLabel(opType connectorv1.OperationType) string {
|
|
||||||
switch opType {
|
|
||||||
case connectorv1.OperationType_CREDIT:
|
|
||||||
return "Credit"
|
|
||||||
case connectorv1.OperationType_DEBIT:
|
|
||||||
return "Debit"
|
|
||||||
case connectorv1.OperationType_TRANSFER:
|
|
||||||
return "Transfer"
|
|
||||||
case connectorv1.OperationType_PAYOUT:
|
|
||||||
return "Payout"
|
|
||||||
case connectorv1.OperationType_FEE_ESTIMATE:
|
|
||||||
return "Fee Estimate"
|
|
||||||
case connectorv1.OperationType_FX:
|
|
||||||
return "FX"
|
|
||||||
case connectorv1.OperationType_GAS_TOPUP:
|
|
||||||
return "Gas Top Up"
|
|
||||||
default:
|
|
||||||
return strings.TrimSpace(opType.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func failureCodeFromStatus(status connectorv1.OperationStatus) string {
|
|
||||||
switch status {
|
|
||||||
case connectorv1.OperationStatus_OPERATION_FAILED, connectorv1.OperationStatus_OPERATION_CANCELLED:
|
|
||||||
return strings.TrimSpace(status.String())
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationParamValue(params *structpb.Struct, keys ...string) string {
|
|
||||||
if params == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
values := params.AsMap()
|
|
||||||
for _, key := range keys {
|
|
||||||
raw, ok := values[key]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if text := strings.TrimSpace(fmt.Sprint(raw)); text != "" && text != "<nil>" {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func findDocumentsService(services []discovery.ServiceSummary) *discovery.ServiceSummary {
|
|
||||||
for _, svc := range services {
|
|
||||||
if !strings.EqualFold(svc.Service, documentsServiceName) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !svc.Healthy || strings.TrimSpace(svc.InvokeURI) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(svc.Ops) == 0 || hasOperation(svc.Ops, documentsOperationGet) {
|
|
||||||
return &svc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasOperation(ops []string, target string) bool {
|
|
||||||
for _, op := range ops {
|
|
||||||
if strings.EqualFold(strings.TrimSpace(op), target) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func documentErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
|
|
||||||
statusErr, ok := status.FromError(err)
|
|
||||||
if !ok {
|
|
||||||
return response.Internal(logger, source, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch statusErr.Code() {
|
|
||||||
case codes.InvalidArgument:
|
|
||||||
return response.BadRequest(logger, source, "invalid_argument", statusErr.Message())
|
|
||||||
case codes.NotFound:
|
|
||||||
return response.NotFound(logger, source, statusErr.Message())
|
|
||||||
case codes.Unimplemented:
|
|
||||||
return response.NotImplemented(logger, source, statusErr.Message())
|
|
||||||
case codes.FailedPrecondition:
|
|
||||||
return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message())
|
|
||||||
case codes.Unavailable:
|
|
||||||
return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message())
|
|
||||||
default:
|
|
||||||
return response.Internal(logger, source, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package paymentapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
func grpcErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
|
|
||||||
statusErr, ok := status.FromError(err)
|
|
||||||
if !ok {
|
|
||||||
return response.Internal(logger, source, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch statusErr.Code() {
|
|
||||||
case codes.InvalidArgument:
|
|
||||||
return response.BadRequest(logger, source, "invalid_argument", statusErr.Message())
|
|
||||||
case codes.NotFound:
|
|
||||||
return response.NotFound(logger, source, statusErr.Message())
|
|
||||||
case codes.PermissionDenied:
|
|
||||||
return response.AccessDenied(logger, source, statusErr.Message())
|
|
||||||
case codes.Unauthenticated:
|
|
||||||
return response.Unauthorized(logger, source, statusErr.Message())
|
|
||||||
case codes.AlreadyExists, codes.Aborted:
|
|
||||||
return response.DataConflict(logger, source, statusErr.Message())
|
|
||||||
case codes.Unimplemented:
|
|
||||||
return response.NotImplemented(logger, source, statusErr.Message())
|
|
||||||
case codes.FailedPrecondition:
|
|
||||||
return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message())
|
|
||||||
case codes.DeadlineExceeded:
|
|
||||||
return response.Error(logger, source, http.StatusGatewayTimeout, "deadline_exceeded", statusErr.Message())
|
|
||||||
case codes.Unavailable:
|
|
||||||
return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message())
|
|
||||||
default:
|
|
||||||
return response.Internal(logger, source, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
package paymentapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
|
||||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
|
||||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
|
||||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
|
||||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
|
||||||
"github.com/tech/sendico/server/interface/api/srequest"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, error) {
|
|
||||||
if intent == nil {
|
|
||||||
return nil, merrors.InvalidArgument("intent is required")
|
|
||||||
}
|
|
||||||
if err := validatePaymentKind(intent.Kind); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
settlementMode, err := mapSettlementMode(intent.SettlementMode)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
feeTreatment, err := mapFeeTreatment(intent.FeeTreatment)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resolvedSettlementMode, resolvedFeeTreatment, err := payecon.ResolveSettlementAndFee(settlementMode, feeTreatment)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
settlementCurrency := resolveSettlementCurrency(intent)
|
|
||||||
if settlementCurrency == "" {
|
|
||||||
return nil, merrors.InvalidArgument("unable to derive settlement currency from intent")
|
|
||||||
}
|
|
||||||
|
|
||||||
source, err := mapQuoteEndpoint(intent.Source, "intent.source")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
destination, err := mapQuoteEndpoint(intent.Destination, "intent.destination")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
quoteIntent := "ationv2.QuoteIntent{
|
|
||||||
Source: source,
|
|
||||||
Destination: destination,
|
|
||||||
Amount: mapMoney(intent.Amount),
|
|
||||||
SettlementMode: resolvedSettlementMode,
|
|
||||||
FeeTreatment: resolvedFeeTreatment,
|
|
||||||
SettlementCurrency: settlementCurrency,
|
|
||||||
Fx: mapFXIntent(intent),
|
|
||||||
Comment: strings.TrimSpace(intent.Comment),
|
|
||||||
}
|
|
||||||
return quoteIntent, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapFXIntent(intent *srequest.PaymentIntent) *sharedv1.FXIntent {
|
|
||||||
if intent == nil || intent.FX == nil || intent.FX.Pair == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
side := fxv1.Side_SIDE_UNSPECIFIED
|
|
||||||
switch strings.TrimSpace(string(intent.FX.Side)) {
|
|
||||||
case string(srequest.FXSideBuyBaseSellQuote):
|
|
||||||
side = fxv1.Side_BUY_BASE_SELL_QUOTE
|
|
||||||
case string(srequest.FXSideSellBaseBuyQuote):
|
|
||||||
side = fxv1.Side_SELL_BASE_BUY_QUOTE
|
|
||||||
}
|
|
||||||
if side == fxv1.Side_SIDE_UNSPECIFIED {
|
|
||||||
side = fxv1.Side_SELL_BASE_BUY_QUOTE
|
|
||||||
}
|
|
||||||
return &sharedv1.FXIntent{
|
|
||||||
Pair: &fxv1.CurrencyPair{
|
|
||||||
Base: strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Base)),
|
|
||||||
Quote: strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote)),
|
|
||||||
},
|
|
||||||
Side: side,
|
|
||||||
Firm: intent.FX.Firm,
|
|
||||||
TtlMs: intent.FX.TTLms,
|
|
||||||
PreferredProvider: strings.TrimSpace(intent.FX.PreferredProvider),
|
|
||||||
MaxAgeMs: intent.FX.MaxAgeMs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validatePaymentKind(kind srequest.PaymentKind) error {
|
|
||||||
switch strings.TrimSpace(string(kind)) {
|
|
||||||
case string(srequest.PaymentKindPayout), string(srequest.PaymentKindInternalTransfer), string(srequest.PaymentKindFxConversion):
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return merrors.InvalidArgument("unsupported payment kind: " + string(kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSettlementCurrency(intent *srequest.PaymentIntent) string {
|
|
||||||
if intent == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fx := intent.FX
|
|
||||||
if fx != nil && fx.Pair != nil {
|
|
||||||
base := strings.TrimSpace(fx.Pair.Base)
|
|
||||||
quote := strings.TrimSpace(fx.Pair.Quote)
|
|
||||||
switch strings.TrimSpace(string(fx.Side)) {
|
|
||||||
case string(srequest.FXSideBuyBaseSellQuote):
|
|
||||||
if base != "" {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
case string(srequest.FXSideSellBaseBuyQuote):
|
|
||||||
if quote != "" {
|
|
||||||
return quote
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if intent.Amount != nil {
|
|
||||||
return strings.TrimSpace(intent.Amount.Currency)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapQuoteEndpoint(endpoint *srequest.Endpoint, field string) (*endpointv1.PaymentEndpoint, error) {
|
|
||||||
if endpoint == nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + " is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch endpoint.Type {
|
|
||||||
case srequest.EndpointTypeLedger:
|
|
||||||
payload, err := endpoint.DecodeLedger()
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
|
||||||
}
|
|
||||||
method := &ledgerMethodData{
|
|
||||||
LedgerAccountRef: strings.TrimSpace(payload.LedgerAccountRef),
|
|
||||||
ContraLedgerAccountRef: strings.TrimSpace(payload.ContraLedgerAccountRef),
|
|
||||||
}
|
|
||||||
if method.LedgerAccountRef == "" {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".ledger_account_ref is required")
|
|
||||||
}
|
|
||||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, method)
|
|
||||||
|
|
||||||
case srequest.EndpointTypeManagedWallet:
|
|
||||||
payload, err := endpoint.DecodeManagedWallet()
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
|
||||||
}
|
|
||||||
method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.ManagedWalletRef)}
|
|
||||||
if method.WalletID == "" {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".managed_wallet_ref is required")
|
|
||||||
}
|
|
||||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method)
|
|
||||||
|
|
||||||
case srequest.EndpointTypeWallet:
|
|
||||||
payload, err := endpoint.DecodeWallet()
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
|
||||||
}
|
|
||||||
method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.WalletID)}
|
|
||||||
if method.WalletID == "" {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".walletId is required")
|
|
||||||
}
|
|
||||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method)
|
|
||||||
|
|
||||||
case srequest.EndpointTypeExternalChain:
|
|
||||||
payload, err := endpoint.DecodeExternalChain()
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
|
||||||
}
|
|
||||||
method, mapErr := mapExternalChainMethod(payload, field)
|
|
||||||
if mapErr != nil {
|
|
||||||
return nil, mapErr
|
|
||||||
}
|
|
||||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, method)
|
|
||||||
|
|
||||||
case srequest.EndpointTypeCard:
|
|
||||||
payload, err := endpoint.DecodeCard()
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
|
||||||
}
|
|
||||||
method := &pkgmodel.CardPaymentData{
|
|
||||||
Pan: strings.TrimSpace(payload.Pan),
|
|
||||||
FirstName: strings.TrimSpace(payload.FirstName),
|
|
||||||
LastName: strings.TrimSpace(payload.LastName),
|
|
||||||
ExpMonth: uint32ToString(payload.ExpMonth),
|
|
||||||
ExpYear: uint32ToString(payload.ExpYear),
|
|
||||||
Country: strings.TrimSpace(payload.Country),
|
|
||||||
}
|
|
||||||
if method.Pan == "" {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".pan is required")
|
|
||||||
}
|
|
||||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, method)
|
|
||||||
|
|
||||||
case srequest.EndpointTypeCardToken:
|
|
||||||
payload, err := endpoint.DecodeCardToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
|
||||||
}
|
|
||||||
method := &pkgmodel.TokenPaymentData{
|
|
||||||
Token: strings.TrimSpace(payload.Token),
|
|
||||||
Last4: strings.TrimSpace(payload.MaskedPan),
|
|
||||||
}
|
|
||||||
if method.Token == "" {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".token is required")
|
|
||||||
}
|
|
||||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, method)
|
|
||||||
|
|
||||||
case "":
|
|
||||||
return nil, merrors.InvalidArgument(field + " endpoint type is required")
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, merrors.InvalidArgument(field + " endpoint type is unsupported in v2: " + string(endpoint.Type))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapExternalChainMethod(payload srequest.ExternalChainEndpoint, field string) (*pkgmodel.CryptoAddressPaymentData, error) {
|
|
||||||
address := strings.TrimSpace(payload.Address)
|
|
||||||
if address == "" {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".address is required")
|
|
||||||
}
|
|
||||||
if payload.Asset == nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".asset is required")
|
|
||||||
}
|
|
||||||
token := strings.ToUpper(strings.TrimSpace(payload.Asset.TokenSymbol))
|
|
||||||
if token == "" {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".asset.token_symbol is required")
|
|
||||||
}
|
|
||||||
if _, err := mapChainNetwork(payload.Asset.Chain); err != nil {
|
|
||||||
return nil, merrors.InvalidArgument(field + ".asset.chain: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &pkgmodel.CryptoAddressPaymentData{
|
|
||||||
Currency: pkgmodel.Currency(token),
|
|
||||||
Address: address,
|
|
||||||
Network: strings.ToUpper(strings.TrimSpace(string(payload.Asset.Chain))),
|
|
||||||
}
|
|
||||||
if memo := strings.TrimSpace(payload.Memo); memo != "" {
|
|
||||||
result.DestinationTag = &memo
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func endpointFromMethod(methodType endpointv1.PaymentMethodType, data any) (*endpointv1.PaymentEndpoint, error) {
|
|
||||||
raw, err := bson.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InternalWrap(err, "failed to encode payment method data")
|
|
||||||
}
|
|
||||||
method := &endpointv1.PaymentMethod{
|
|
||||||
Type: methodType,
|
|
||||||
Data: raw,
|
|
||||||
}
|
|
||||||
return &endpointv1.PaymentEndpoint{
|
|
||||||
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
|
||||||
PaymentMethod: method,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapMoney(m *paymenttypes.Money) *moneyv1.Money {
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Amount: m.Amount,
|
|
||||||
Currency: m.Currency,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapSettlementMode(mode srequest.SettlementMode) (paymentv1.SettlementMode, error) {
|
|
||||||
switch strings.TrimSpace(string(mode)) {
|
|
||||||
case "", string(srequest.SettlementModeUnspecified):
|
|
||||||
return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, nil
|
|
||||||
case string(srequest.SettlementModeFixSource):
|
|
||||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, nil
|
|
||||||
case string(srequest.SettlementModeFixReceived):
|
|
||||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, nil
|
|
||||||
default:
|
|
||||||
return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, merrors.InvalidArgument("unsupported settlement mode: " + string(mode))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapFeeTreatment(treatment srequest.FeeTreatment) (quotationv2.FeeTreatment, error) {
|
|
||||||
switch strings.TrimSpace(string(treatment)) {
|
|
||||||
case "", string(srequest.FeeTreatmentUnspecified):
|
|
||||||
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, nil
|
|
||||||
case string(srequest.FeeTreatmentAddToSource):
|
|
||||||
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, nil
|
|
||||||
case string(srequest.FeeTreatmentDeductFromDestination):
|
|
||||||
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, nil
|
|
||||||
default:
|
|
||||||
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("unsupported fee treatment: " + string(treatment))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) {
|
|
||||||
switch strings.TrimSpace(string(chain)) {
|
|
||||||
case "", string(srequest.ChainNetworkUnspecified):
|
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
|
|
||||||
case string(srequest.ChainNetworkEthereumMainnet):
|
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
|
||||||
case string(srequest.ChainNetworkArbitrumOne):
|
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
|
||||||
case string(srequest.ChainNetworkTronMainnet):
|
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
|
||||||
case string(srequest.ChainNetworkTronNile):
|
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
|
|
||||||
default:
|
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func uint32ToString(v uint32) string {
|
|
||||||
if v == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strconv.FormatUint(uint64(v), 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ledgerMethodData struct {
|
|
||||||
LedgerAccountRef string `bson:"ledgerAccountRef"`
|
|
||||||
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
package paymentapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
|
||||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
|
||||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
|
||||||
"github.com/tech/sendico/server/interface/api/srequest"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMapQuoteIntent_PropagatesFeeTreatment(t *testing.T) {
|
|
||||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-source-1",
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build source endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
|
||||||
Pan: "2200700142860161",
|
|
||||||
FirstName: "John",
|
|
||||||
LastName: "Doe",
|
|
||||||
ExpMonth: 3,
|
|
||||||
ExpYear: 2030,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
intent := &srequest.PaymentIntent{
|
|
||||||
Kind: srequest.PaymentKindPayout,
|
|
||||||
Source: &source,
|
|
||||||
Destination: &destination,
|
|
||||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
|
||||||
SettlementMode: srequest.SettlementModeFixReceived,
|
|
||||||
FeeTreatment: srequest.FeeTreatmentDeductFromDestination,
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := mapQuoteIntent(intent)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mapQuoteIntent returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got == nil {
|
|
||||||
t.Fatalf("expected mapped quote intent")
|
|
||||||
}
|
|
||||||
if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION {
|
|
||||||
t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapQuoteIntent_InvalidFeeTreatmentFails(t *testing.T) {
|
|
||||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-source-1",
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build source endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
|
||||||
Pan: "2200700142860161",
|
|
||||||
FirstName: "John",
|
|
||||||
LastName: "Doe",
|
|
||||||
ExpMonth: 3,
|
|
||||||
ExpYear: 2030,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
intent := &srequest.PaymentIntent{
|
|
||||||
Kind: srequest.PaymentKindPayout,
|
|
||||||
Source: &source,
|
|
||||||
Destination: &destination,
|
|
||||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
|
||||||
SettlementMode: srequest.SettlementModeFixSource,
|
|
||||||
FeeTreatment: srequest.FeeTreatment("wrong_value"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := mapQuoteIntent(intent); err == nil {
|
|
||||||
t.Fatalf("expected error for invalid fee treatment")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T) {
|
|
||||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-source-1",
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build source endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
|
||||||
Pan: "2200700142860161",
|
|
||||||
FirstName: "John",
|
|
||||||
LastName: "Doe",
|
|
||||||
ExpMonth: 3,
|
|
||||||
ExpYear: 2030,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
intent := &srequest.PaymentIntent{
|
|
||||||
Kind: srequest.PaymentKindPayout,
|
|
||||||
Source: &source,
|
|
||||||
Destination: &destination,
|
|
||||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
|
||||||
SettlementMode: srequest.SettlementModeFixReceived,
|
|
||||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := mapQuoteIntent(intent)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mapQuoteIntent returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got.GetSettlementMode() != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED {
|
|
||||||
t.Fatalf("unexpected settlement mode: got=%s", got.GetSettlementMode().String())
|
|
||||||
}
|
|
||||||
if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE {
|
|
||||||
t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapQuoteIntent_DerivesSettlementCurrencyFromAmountWithoutFX(t *testing.T) {
|
|
||||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-source-1",
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build source endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
|
||||||
Pan: "2200700142860161",
|
|
||||||
FirstName: "John",
|
|
||||||
LastName: "Doe",
|
|
||||||
ExpMonth: 3,
|
|
||||||
ExpYear: 2030,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
intent := &srequest.PaymentIntent{
|
|
||||||
Kind: srequest.PaymentKindPayout,
|
|
||||||
Source: &source,
|
|
||||||
Destination: &destination,
|
|
||||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
|
||||||
SettlementMode: srequest.SettlementModeFixSource,
|
|
||||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := mapQuoteIntent(intent)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got.GetSettlementCurrency() != "USDT" {
|
|
||||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) {
|
|
||||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-source-1",
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build source endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
|
||||||
Pan: "2200700142860161",
|
|
||||||
FirstName: "John",
|
|
||||||
LastName: "Doe",
|
|
||||||
ExpMonth: 3,
|
|
||||||
ExpYear: 2030,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
intent := &srequest.PaymentIntent{
|
|
||||||
Kind: srequest.PaymentKindPayout,
|
|
||||||
Source: &source,
|
|
||||||
Destination: &destination,
|
|
||||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
|
||||||
SettlementMode: srequest.SettlementModeFixSource,
|
|
||||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
|
||||||
FX: &srequest.FXIntent{
|
|
||||||
Pair: &srequest.CurrencyPair{
|
|
||||||
Base: "USDT",
|
|
||||||
Quote: "RUB",
|
|
||||||
},
|
|
||||||
Side: srequest.FXSideSellBaseBuyQuote,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := mapQuoteIntent(intent)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got.GetSettlementCurrency() != "RUB" {
|
|
||||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
|
||||||
}
|
|
||||||
if got.GetFx() == nil || got.GetFx().GetPair() == nil {
|
|
||||||
t.Fatalf("expected fx intent")
|
|
||||||
}
|
|
||||||
if got.GetFx().GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE {
|
|
||||||
t.Fatalf("unexpected fx side: got=%s", got.GetFx().GetSide().String())
|
|
||||||
}
|
|
||||||
if got.GetFx().GetPair().GetBase() != "USDT" || got.GetFx().GetPair().GetQuote() != "RUB" {
|
|
||||||
t.Fatalf("unexpected fx pair: got=%s/%s", got.GetFx().GetPair().GetBase(), got.GetFx().GetPair().GetQuote())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapQuoteIntent_PropagatesFXSideBuyBaseSellQuote(t *testing.T) {
|
|
||||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
|
||||||
ManagedWalletRef: "wallet-source-1",
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build source endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
|
||||||
Pan: "2200700142860161",
|
|
||||||
FirstName: "John",
|
|
||||||
LastName: "Doe",
|
|
||||||
ExpMonth: 3,
|
|
||||||
ExpYear: 2030,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
intent := &srequest.PaymentIntent{
|
|
||||||
Kind: srequest.PaymentKindPayout,
|
|
||||||
Source: &source,
|
|
||||||
Destination: &destination,
|
|
||||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
|
||||||
SettlementMode: srequest.SettlementModeFixSource,
|
|
||||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
|
||||||
FX: &srequest.FXIntent{
|
|
||||||
Pair: &srequest.CurrencyPair{
|
|
||||||
Base: "RUB",
|
|
||||||
Quote: "USDT",
|
|
||||||
},
|
|
||||||
Side: srequest.FXSideBuyBaseSellQuote,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := mapQuoteIntent(intent)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got.GetFx() == nil || got.GetFx().GetPair() == nil {
|
|
||||||
t.Fatalf("expected fx intent")
|
|
||||||
}
|
|
||||||
if got.GetFx().GetSide() != fxv1.Side_BUY_BASE_SELL_QUOTE {
|
|
||||||
t.Fatalf("unexpected fx side: got=%s", got.GetFx().GetSide().String())
|
|
||||||
}
|
|
||||||
if got.GetSettlementCurrency() != "RUB" {
|
|
||||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package paymentapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestInitiateByQuote_ForwardsClientPaymentRef(t *testing.T) {
|
|
||||||
orgRef := bson.NewObjectID()
|
|
||||||
exec := &fakeExecutionClientForBatch{}
|
|
||||||
api := newBatchAPI(exec)
|
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","clientPaymentRef":"client-ref-1"}`
|
|
||||||
rr := invokeInitiateByQuote(t, api, orgRef, body)
|
|
||||||
if got, want := rr.Code, http.StatusOK; got != want {
|
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
|
||||||
}
|
|
||||||
if got, want := len(exec.executeReqs), 1; got != want {
|
|
||||||
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
|
|
||||||
}
|
|
||||||
if got, want := exec.executeReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want {
|
|
||||||
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitiateByQuote_DoesNotForwardLegacyClientPaymentRefFromMetadata(t *testing.T) {
|
|
||||||
orgRef := bson.NewObjectID()
|
|
||||||
exec := &fakeExecutionClientForBatch{}
|
|
||||||
api := newBatchAPI(exec)
|
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"client_payment_ref":"legacy-client-ref"}}`
|
|
||||||
rr := invokeInitiateByQuote(t, api, orgRef, body)
|
|
||||||
if got, want := rr.Code, http.StatusOK; got != want {
|
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
|
||||||
}
|
|
||||||
if got, want := len(exec.executeReqs), 1; got != want {
|
|
||||||
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
|
|
||||||
}
|
|
||||||
if got := exec.executeReqs[0].GetClientPaymentRef(); got != "" {
|
|
||||||
t.Fatalf("expected empty client_payment_ref, got=%q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitiateByQuote_RejectsMetadataIntentRef(t *testing.T) {
|
|
||||||
orgRef := bson.NewObjectID()
|
|
||||||
exec := &fakeExecutionClientForBatch{}
|
|
||||||
api := newBatchAPI(exec)
|
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"intent_ref":"legacy-intent"}}`
|
|
||||||
rr := invokeInitiateByQuote(t, api, orgRef, body)
|
|
||||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
|
||||||
}
|
|
||||||
if got := len(exec.executeReqs); got != 0 {
|
|
||||||
t.Fatalf("expected no execute calls, got=%d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func invokeInitiateByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/by-quote", bytes.NewBufferString(body))
|
|
||||||
routeCtx := chi.NewRouteContext()
|
|
||||||
routeCtx.URLParams.Add("organizations_ref", orgRef.Hex())
|
|
||||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handler := api.initiateByQuote(req, &model.Account{}, &sresponse.TokenData{
|
|
||||||
Token: "token",
|
|
||||||
Expiration: time.Now().UTC().Add(time.Hour),
|
|
||||||
})
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
return rr
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package paymentapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/tech/sendico/pkg/auth"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
|
||||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
|
||||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_ExecutesBatchPayment(t *testing.T) {
|
|
||||||
orgRef := bson.NewObjectID()
|
|
||||||
exec := &fakeExecutionClientForBatch{}
|
|
||||||
api := newBatchAPI(exec)
|
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1"}`
|
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
|
||||||
if got, want := rr.Code, http.StatusOK; got != want {
|
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, want := len(exec.executeBatchReqs), 1; got != want {
|
|
||||||
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
|
|
||||||
}
|
|
||||||
if got := len(exec.executeReqs); got != 0 {
|
|
||||||
t.Fatalf("expected no execute calls, got=%d", got)
|
|
||||||
}
|
|
||||||
if got, want := exec.executeBatchReqs[0].GetQuotationRef(), "quote-1"; got != want {
|
|
||||||
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := exec.executeBatchReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-batch"; got != want {
|
|
||||||
t.Fatalf("idempotency mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_ForwardsClientPaymentRef(t *testing.T) {
|
|
||||||
orgRef := bson.NewObjectID()
|
|
||||||
exec := &fakeExecutionClientForBatch{}
|
|
||||||
api := newBatchAPI(exec)
|
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","clientPaymentRef":"client-ref-1"}`
|
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
|
||||||
if got, want := rr.Code, http.StatusOK; got != want {
|
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, want := len(exec.executeBatchReqs), 1; got != want {
|
|
||||||
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
|
|
||||||
}
|
|
||||||
if got, want := exec.executeBatchReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want {
|
|
||||||
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got := len(exec.executeReqs); got != 0 {
|
|
||||||
t.Fatalf("expected no execute calls, got=%d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_DoesNotForwardLegacyClientPaymentRefFromMetadata(t *testing.T) {
|
|
||||||
orgRef := bson.NewObjectID()
|
|
||||||
exec := &fakeExecutionClientForBatch{}
|
|
||||||
api := newBatchAPI(exec)
|
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","metadata":{"client_payment_ref":"legacy-client-ref"}}`
|
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
|
||||||
if got, want := rr.Code, http.StatusOK; got != want {
|
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, want := len(exec.executeBatchReqs), 1; got != want {
|
|
||||||
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
|
|
||||||
}
|
|
||||||
if got := exec.executeBatchReqs[0].GetClientPaymentRef(); got != "" {
|
|
||||||
t.Fatalf("expected empty client_payment_ref, got=%q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefField(t *testing.T) {
|
|
||||||
orgRef := bson.NewObjectID()
|
|
||||||
exec := &fakeExecutionClientForBatch{}
|
|
||||||
api := newBatchAPI(exec)
|
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRef":"intent-legacy"}`
|
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
|
||||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
|
||||||
}
|
|
||||||
if got := len(exec.executeReqs); got != 0 {
|
|
||||||
t.Fatalf("expected no execute calls, got=%d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefsField(t *testing.T) {
|
|
||||||
orgRef := bson.NewObjectID()
|
|
||||||
exec := &fakeExecutionClientForBatch{}
|
|
||||||
api := newBatchAPI(exec)
|
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}`
|
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
|
||||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
|
||||||
}
|
|
||||||
if got := len(exec.executeReqs); got != 0 {
|
|
||||||
t.Fatalf("expected no execute calls, got=%d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBatchAPI(exec executionClient) *PaymentAPI {
|
|
||||||
return &PaymentAPI{
|
|
||||||
logger: mlogger.Logger(zap.NewNop()),
|
|
||||||
execution: exec,
|
|
||||||
enf: fakeEnforcerForBatch{allowed: true},
|
|
||||||
oph: mutil.CreatePH(mservice.Organizations),
|
|
||||||
permissionRef: bson.NewObjectID(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func invokeInitiatePaymentsByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/by-multiquote", bytes.NewBufferString(body))
|
|
||||||
routeCtx := chi.NewRouteContext()
|
|
||||||
routeCtx.URLParams.Add("organizations_ref", orgRef.Hex())
|
|
||||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
handler := api.initiatePaymentsByQuote(req, &model.Account{}, &sresponse.TokenData{
|
|
||||||
Token: "token",
|
|
||||||
Expiration: time.Now().UTC().Add(time.Hour),
|
|
||||||
})
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
return rr
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeExecutionClientForBatch struct {
|
|
||||||
executeReqs []*orchestrationv2.ExecutePaymentRequest
|
|
||||||
executeBatchReqs []*orchestrationv2.ExecuteBatchPaymentRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeExecutionClientForBatch) ExecutePayment(_ context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
|
|
||||||
f.executeReqs = append(f.executeReqs, req)
|
|
||||||
return &orchestrationv2.ExecutePaymentResponse{
|
|
||||||
Payment: &orchestrationv2.Payment{PaymentRef: bson.NewObjectID().Hex()},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeExecutionClientForBatch) ExecuteBatchPayment(_ context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) {
|
|
||||||
f.executeBatchReqs = append(f.executeBatchReqs, req)
|
|
||||||
return &orchestrationv2.ExecuteBatchPaymentResponse{
|
|
||||||
Payments: []*orchestrationv2.Payment{{PaymentRef: bson.NewObjectID().Hex()}},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*fakeExecutionClientForBatch) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) {
|
|
||||||
return &orchestrationv2.ListPaymentsResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*fakeExecutionClientForBatch) Close() error { return nil }
|
|
||||||
|
|
||||||
type fakeEnforcerForBatch struct {
|
|
||||||
allowed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeEnforcerForBatch) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) {
|
|
||||||
return f.allowed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeEnforcerForBatch) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeEnforcerForBatch) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeEnforcerForBatch) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ auth.Enforcer = (*fakeEnforcerForBatch)(nil)
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
package paymentapiimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
orchestratorclient "github.com/tech/sendico/payments/orchestrator/client"
|
|
||||||
api "github.com/tech/sendico/pkg/api/http"
|
|
||||||
"github.com/tech/sendico/pkg/auth"
|
|
||||||
"github.com/tech/sendico/pkg/discovery"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
|
||||||
msgconsumer "github.com/tech/sendico/pkg/messaging/consumer"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
|
||||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
|
||||||
eapi "github.com/tech/sendico/server/interface/api"
|
|
||||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
)
|
|
||||||
|
|
||||||
type executionClient interface {
|
|
||||||
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
|
||||||
ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error)
|
|
||||||
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
type quotationClient interface {
|
|
||||||
QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error)
|
|
||||||
QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error)
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentAPI struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
execution executionClient
|
|
||||||
quotation quotationClient
|
|
||||||
enf auth.Enforcer
|
|
||||||
oph mutil.ParamHelper
|
|
||||||
discovery *discovery.Client
|
|
||||||
refreshConsumer msg.Consumer
|
|
||||||
refreshMu sync.RWMutex
|
|
||||||
refreshEvent *discovery.RefreshEvent
|
|
||||||
|
|
||||||
permissionRef bson.ObjectID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PaymentAPI) Name() mservice.Type { return mservice.Payments }
|
|
||||||
|
|
||||||
func (a *PaymentAPI) Finish(ctx context.Context) error {
|
|
||||||
if a.execution != nil {
|
|
||||||
if err := a.execution.Close(); err != nil {
|
|
||||||
a.logger.Warn("Failed to close payment orchestrator client", zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.quotation != nil {
|
|
||||||
if err := a.quotation.Close(); err != nil {
|
|
||||||
a.logger.Warn("Failed to close payment quotation client", zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.discovery != nil {
|
|
||||||
a.discovery.Close()
|
|
||||||
}
|
|
||||||
if a.refreshConsumer != nil {
|
|
||||||
a.refreshConsumer.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
|
|
||||||
p := &PaymentAPI{
|
|
||||||
logger: apiCtx.Logger().Named(mservice.Payments),
|
|
||||||
enf: apiCtx.Permissions().Enforcer(),
|
|
||||||
oph: mutil.CreatePH(mservice.Organizations),
|
|
||||||
}
|
|
||||||
|
|
||||||
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
|
|
||||||
if err != nil {
|
|
||||||
p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.permissionRef = desc.ID
|
|
||||||
|
|
||||||
if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator, apiCtx.Config().PaymentQuotation); err != nil {
|
|
||||||
p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := p.initDiscoveryClient(apiCtx.Config()); err != nil {
|
|
||||||
p.logger.Warn("Failed to initialize discovery client", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/quote"), api.Post, p.quotePayment)
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/multiquote"), api.Post, p.quotePayments)
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate)
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/operation"), api.Get, p.getOperationDocument)
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry"), api.Get, p.listDiscoveryRegistry)
|
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
|
|
||||||
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig, quoteCfg *eapi.PaymentOrchestratorConfig) error {
|
|
||||||
if cfg == nil {
|
|
||||||
return merrors.InvalidArgument("payment orchestrator configuration is not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
address, err := resolveClientAddress("payment orchestrator", cfg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
quoteAddress := address
|
|
||||||
quoteInsecure := cfg.Insecure
|
|
||||||
quoteDialTimeout := cfg.DialTimeoutSeconds
|
|
||||||
quoteCallTimeout := cfg.CallTimeoutSeconds
|
|
||||||
if quoteCfg != nil {
|
|
||||||
if addr := strings.TrimSpace(quoteCfg.Address); addr != "" {
|
|
||||||
quoteAddress = addr
|
|
||||||
} else if env := strings.TrimSpace(quoteCfg.AddressEnv); env != "" {
|
|
||||||
if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" {
|
|
||||||
quoteAddress = resolved
|
|
||||||
}
|
|
||||||
}
|
|
||||||
quoteInsecure = quoteCfg.Insecure
|
|
||||||
quoteDialTimeout = quoteCfg.DialTimeoutSeconds
|
|
||||||
quoteCallTimeout = quoteCfg.CallTimeoutSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
clientCfg := orchestratorclient.Config{
|
|
||||||
Address: address,
|
|
||||||
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
|
|
||||||
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
|
|
||||||
Insecure: cfg.Insecure,
|
|
||||||
}
|
|
||||||
|
|
||||||
execution, err := orchestratorclient.New(context.Background(), clientCfg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
quotation, err := newQuotationClient(context.Background(), quotationClientConfig{
|
|
||||||
Address: quoteAddress,
|
|
||||||
DialTimeout: time.Duration(quoteDialTimeout) * time.Second,
|
|
||||||
CallTimeout: time.Duration(quoteCallTimeout) * time.Second,
|
|
||||||
Insecure: quoteInsecure,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
_ = execution.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
a.execution = execution
|
|
||||||
a.quotation = quotation
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveClientAddress(service string, cfg *eapi.PaymentOrchestratorConfig) (string, error) {
|
|
||||||
if cfg == nil {
|
|
||||||
return "", merrors.InvalidArgument(strings.TrimSpace(service) + " configuration is not provided")
|
|
||||||
}
|
|
||||||
address := strings.TrimSpace(cfg.Address)
|
|
||||||
if address != "" {
|
|
||||||
return address, nil
|
|
||||||
}
|
|
||||||
if env := strings.TrimSpace(cfg.AddressEnv); env != "" {
|
|
||||||
if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" {
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
return "", merrors.InvalidArgument(fmt.Sprintf("%s address is not specified and address env %s is empty", strings.TrimSpace(service), env))
|
|
||||||
}
|
|
||||||
return "", merrors.InvalidArgument(strings.TrimSpace(service) + " address is not specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
type quotationClientConfig struct {
|
|
||||||
Address string
|
|
||||||
DialTimeout time.Duration
|
|
||||||
CallTimeout time.Duration
|
|
||||||
Insecure bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *quotationClientConfig) setDefaults() {
|
|
||||||
if c.DialTimeout <= 0 {
|
|
||||||
c.DialTimeout = 5 * time.Second
|
|
||||||
}
|
|
||||||
if c.CallTimeout <= 0 {
|
|
||||||
c.CallTimeout = 3 * time.Second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type grpcQuotationClient struct {
|
|
||||||
conn *grpc.ClientConn
|
|
||||||
client quotationv2.QuotationServiceClient
|
|
||||||
callTimeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func newQuotationClient(_ context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
|
|
||||||
cfg.setDefaults()
|
|
||||||
if strings.TrimSpace(cfg.Address) == "" {
|
|
||||||
return nil, merrors.InvalidArgument("payment quotation: address is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
|
||||||
dialOpts = append(dialOpts, opts...)
|
|
||||||
if cfg.Insecure {
|
|
||||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
} else {
|
|
||||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := grpc.NewClient(cfg.Address, dialOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InternalWrap(err, fmt.Sprintf("payment-quotation: dial %s", cfg.Address))
|
|
||||||
}
|
|
||||||
return &grpcQuotationClient{
|
|
||||||
conn: conn,
|
|
||||||
client: quotationv2.NewQuotationServiceClient(conn),
|
|
||||||
callTimeout: cfg.CallTimeout,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *grpcQuotationClient) Close() error {
|
|
||||||
if c == nil || c.conn == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return c.conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *grpcQuotationClient) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) {
|
|
||||||
callCtx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
return c.client.QuotePayment(callCtx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *grpcQuotationClient) QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) {
|
|
||||||
callCtx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
return c.client.QuotePayments(callCtx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *grpcQuotationClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
||||||
timeout := c.callTimeout
|
|
||||||
if timeout <= 0 {
|
|
||||||
timeout = 3 * time.Second
|
|
||||||
}
|
|
||||||
return context.WithTimeout(ctx, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error {
|
|
||||||
if cfg == nil || cfg.Mw == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
msgCfg := cfg.Mw.Messaging
|
|
||||||
if msgCfg.Driver == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), &msgCfg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
client, err := discovery.NewClient(a.logger, broker, nil, string(a.Name()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
a.discovery = client
|
|
||||||
refreshConsumer, err := msgconsumer.NewConsumer(a.logger, broker, discovery.RefreshUIEvent())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
a.refreshConsumer = refreshConsumer
|
|
||||||
go func() {
|
|
||||||
if err := refreshConsumer.ConsumeMessages(a.handleRefreshEvent); err != nil {
|
|
||||||
a.logger.Warn("Discovery refresh consumer stopped", zap.Error(err))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user